From f8191133c83b74200c5f851d012051527da899d6 Mon Sep 17 00:00:00 2001 From: debian Date: Wed, 4 Mar 2026 16:17:03 -0500 Subject: [PATCH] docs: enterprise refactor plan with ralph specs --- .dockerignore | 10 + .env.example | 12 + .github/workflows/abe-example.yml | 48 + .gitignore | 3 + .ralph/.loop_start_sha | 1 + .ralph/AGENT.md | 185 +- .ralph/PROMPT.md | 410 +- .ralph/fix_plan.md | 461 +- .ralph/progress.json | 1 + .ralph/specs/legacy/ai-enrichment.md | 130 + .ralph/specs/legacy/api-security.md | 59 + .ralph/specs/legacy/api-server.md | 187 + .ralph/specs/legacy/cli-cicd.md | 118 + .ralph/specs/legacy/database.md | 99 + .ralph/specs/legacy/docker.md | 102 + .ralph/specs/legacy/exploration-config.md | 84 + .ralph/specs/legacy/frontend-v2.md | 72 + .ralph/specs/legacy/frontend.md | 99 + .ralph/specs/legacy/fuzzing.md | 94 + .ralph/specs/legacy/interfaces.md | 164 + .../legacy/multi-browser-accessibility.md | 119 + .ralph/specs/legacy/network-chaos.md | 88 + .ralph/specs/legacy/notifications.md | 64 + .ralph/specs/legacy/output-format.md | 130 + .ralph/specs/legacy/performance-metrics.md | 124 + .ralph/specs/legacy/production-hardening.md | 77 + .ralph/specs/legacy/project-structure.md | 138 + .ralph/specs/legacy/scheduled-monitoring.md | 79 + .ralph/specs/legacy/visual-regression.md | 124 + .ralph/specs/phase-01-shared-domain.md | 134 + .../specs/phase-02-shared-infrastructure.md | 136 + .ralph/specs/phase-07-api-server.md | 216 + .ralph/specs/phase-08-job-queue.md | 66 + .ralph/specs/phase-09-auth-module.md | 148 + .ralph/specs/phase-10-frontend-shell.md | 129 + CLAUDE.md | 59 + Dockerfile | 46 + README.md | 127 +- data/abe.db | Bin 0 -> 4096 bytes data/abe.db-shm | Bin 0 -> 32768 bytes data/abe.db-wal | Bin 0 -> 115392 bytes dist/cli.js | 252 + dist/core/AnomalyDetector.js | 137 + dist/core/ExplorationConfig.js | 53 + dist/core/ExplorationEngine.js | 197 + dist/core/Logger.js | 66 + dist/core/StateGraph.js | 83 + dist/core/interfaces.js | 6 + dist/db/AnomalyRepository.js | 76 + dist/db/ScheduleRepository.js | 82 + dist/db/SessionRepository.js | 53 + dist/db/VisualBaselineRepository.js | 77 + dist/db/connection.js | 43 + dist/db/migrations.js | 126 + dist/index.js | 86 + dist/plugins/agents/PlaywrightAgent.js | 501 ++ .../collectors/AccessibilityCollector.js | 124 + .../collectors/DOMSnapshotCollector.js | 56 + dist/plugins/collectors/NetworkCollector.js | 18 + .../collectors/PerformanceCollector.js | 177 + .../plugins/collectors/ScreenshotCollector.js | 63 + .../collectors/VisualRegressionCollector.js | 155 + dist/plugins/exporters/JSONExporter.js | 97 + dist/plugins/exporters/MarkdownExporter.js | 113 + dist/plugins/fuzzers/FuzzingEngine.js | 139 + dist/plugins/fuzzers/InputTypeDetector.js | 52 + .../strategies/BoundaryValueStrategy.js | 26 + .../fuzzers/strategies/EmptyValueStrategy.js | 19 + .../strategies/OversizedStringStrategy.js | 28 + .../strategies/SpecialCharsStrategy.js | 26 + .../strategies/TypeMismatchStrategy.js | 31 + dist/plugins/interfaces.js | 7 + .../reproducers/PlaywrightReproducer.js | 59 + dist/replay.js | 84 + dist/server/SessionStore.js | 398 + dist/server/enrichment/AIEnrichmentService.js | 71 + dist/server/enrichment/ClaudeProvider.js | 88 + dist/server/enrichment/OllamaProvider.js | 63 + dist/server/enrichment/OpenAIProvider.js | 81 + dist/server/index.js | 199 + dist/server/logger.js | 13 + dist/server/middleware/auth.js | 21 + .../notifications/NotificationService.js | 121 + dist/server/notifications/SlackNotifier.js | 57 + dist/server/notifications/WebhookNotifier.js | 25 + dist/server/routes/anomalies.js | 93 + dist/server/routes/config.js | 48 + dist/server/routes/schedules.js | 122 + dist/server/routes/sessions.js | 104 + dist/server/routes/visual.js | 52 + dist/server/scheduler/SchedulerService.js | 140 + docker-compose.yml | 37 + frontend/.dockerignore | 2 + frontend/.gitignore | 24 + frontend/Dockerfile | 20 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 13 + frontend/nginx.conf | 28 + frontend/package-lock.json | 5404 +++++++++++++ frontend/package.json | 41 + frontend/public/vite.svg | 1 + frontend/src/App.css | 42 + frontend/src/App.tsx | 22 + frontend/src/__tests__/AnomalyList.test.tsx | 77 + .../src/__tests__/NewSessionForm.test.tsx | 97 + frontend/src/__tests__/SeverityBadge.test.tsx | 34 + frontend/src/assets/react.svg | 1 + frontend/src/components/AnomalyCard.tsx | 35 + frontend/src/components/AnomalyList.tsx | 179 + frontend/src/components/LiveFeed.tsx | 45 + frontend/src/components/NewSessionForm.tsx | 434 ++ frontend/src/components/SessionList.tsx | 52 + frontend/src/components/SeverityBadge.tsx | 23 + frontend/src/hooks/useApi.ts | 74 + frontend/src/hooks/useSocket.ts | 40 + frontend/src/index.css | 9 + frontend/src/main.tsx | 10 + frontend/src/pages/AnomalyDetail.tsx | 370 + frontend/src/pages/Dashboard.tsx | 91 + frontend/src/pages/SessionDetail.tsx | 247 + frontend/src/pages/Settings.tsx | 414 + frontend/src/pages/VisualReview.tsx | 281 + frontend/src/types.ts | 201 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 18 + jest.config.ts | 6 + package-lock.json | 6676 +++++++++++++++++ package.json | 55 + run-setup.sh | 12 + src/cli.ts | 243 + src/core/AnomalyDetector.ts | 168 + src/core/ExplorationConfig.ts | 153 + src/core/ExplorationEngine.ts | 296 + src/core/Logger.ts | 35 + src/core/StateGraph.ts | 96 + src/core/interfaces.ts | 185 + src/db/AnomalyRepository.ts | 111 + src/db/ScheduleRepository.ts | 116 + src/db/SessionRepository.ts | 96 + src/db/VisualBaselineRepository.ts | 144 + src/db/connection.ts | 41 + src/db/migrations.ts | 126 + src/index.ts | 94 + src/plugins/agents/PlaywrightAgent.ts | 542 ++ .../collectors/AccessibilityCollector.ts | 116 + .../collectors/DOMSnapshotCollector.ts | 30 + src/plugins/collectors/NetworkCollector.ts | 17 + .../collectors/PerformanceCollector.ts | 190 + src/plugins/collectors/ScreenshotCollector.ts | 38 + .../collectors/VisualRegressionCollector.ts | 171 + src/plugins/exporters/JSONExporter.ts | 68 + src/plugins/exporters/MarkdownExporter.ts | 88 + src/plugins/fuzzers/FuzzingEngine.ts | 140 + src/plugins/fuzzers/InputTypeDetector.ts | 64 + .../strategies/BoundaryValueStrategy.ts | 25 + .../fuzzers/strategies/EmptyValueStrategy.ts | 18 + .../strategies/OversizedStringStrategy.ts | 29 + .../strategies/SpecialCharsStrategy.ts | 26 + .../strategies/TypeMismatchStrategy.ts | 30 + src/plugins/interfaces.ts | 12 + .../reproducers/PlaywrightReproducer.ts | 69 + src/replay.ts | 66 + src/server/SessionStore.ts | 430 ++ src/server/enrichment/AIEnrichmentService.ts | 80 + src/server/enrichment/ClaudeProvider.ts | 106 + src/server/enrichment/OllamaProvider.ts | 72 + src/server/enrichment/OpenAIProvider.ts | 91 + src/server/index.ts | 237 + src/server/logger.ts | 10 + src/server/middleware/auth.ts | 21 + .../notifications/NotificationService.ts | 125 + src/server/notifications/SlackNotifier.ts | 61 + src/server/notifications/WebhookNotifier.ts | 24 + src/server/routes/anomalies.ts | 96 + src/server/routes/config.ts | 64 + src/server/routes/schedules.ts | 122 + src/server/routes/sessions.ts | 118 + src/server/routes/visual.ts | 56 + src/server/scheduler/SchedulerService.ts | 121 + tests/core/AnomalyDetector.test.ts | 147 + tests/core/ExplorationEngine.test.ts | 196 + tests/core/StateGraph.test.ts | 136 + tests/db/repositories.test.ts | 140 + tests/plugins/accessibility.test.ts | 197 + tests/plugins/agents/PlaywrightAgent.test.ts | 143 + tests/plugins/collectors.test.ts | 102 + tests/plugins/explorationConfig.test.ts | 108 + tests/plugins/exporters/JSONExporter.test.ts | 111 + .../exporters/MarkdownExporter.test.ts | 133 + tests/plugins/fuzzers.test.ts | 246 + tests/plugins/networkChaos.test.ts | 289 + tests/plugins/performanceCollector.test.ts | 185 + tests/plugins/reproducers.test.ts | 109 + tests/plugins/visualRegression.test.ts | 128 + tests/server/aiProviders.test.ts | 187 + tests/server/auth.test.ts | 75 + tests/server/cli.test.ts | 91 + tests/server/notifications.test.ts | 131 + tests/server/scheduler.test.ts | 151 + tests/server/server.test.ts | 224 + tsconfig.json | 15 + 204 files changed, 32722 insertions(+), 422 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/abe-example.yml create mode 100644 .ralph/.loop_start_sha create mode 100644 .ralph/progress.json create mode 100644 .ralph/specs/legacy/ai-enrichment.md create mode 100644 .ralph/specs/legacy/api-security.md create mode 100644 .ralph/specs/legacy/api-server.md create mode 100644 .ralph/specs/legacy/cli-cicd.md create mode 100644 .ralph/specs/legacy/database.md create mode 100644 .ralph/specs/legacy/docker.md create mode 100644 .ralph/specs/legacy/exploration-config.md create mode 100644 .ralph/specs/legacy/frontend-v2.md create mode 100644 .ralph/specs/legacy/frontend.md create mode 100644 .ralph/specs/legacy/fuzzing.md create mode 100644 .ralph/specs/legacy/interfaces.md create mode 100644 .ralph/specs/legacy/multi-browser-accessibility.md create mode 100644 .ralph/specs/legacy/network-chaos.md create mode 100644 .ralph/specs/legacy/notifications.md create mode 100644 .ralph/specs/legacy/output-format.md create mode 100644 .ralph/specs/legacy/performance-metrics.md create mode 100644 .ralph/specs/legacy/production-hardening.md create mode 100644 .ralph/specs/legacy/project-structure.md create mode 100644 .ralph/specs/legacy/scheduled-monitoring.md create mode 100644 .ralph/specs/legacy/visual-regression.md create mode 100644 .ralph/specs/phase-01-shared-domain.md create mode 100644 .ralph/specs/phase-02-shared-infrastructure.md create mode 100644 .ralph/specs/phase-07-api-server.md create mode 100644 .ralph/specs/phase-08-job-queue.md create mode 100644 .ralph/specs/phase-09-auth-module.md create mode 100644 .ralph/specs/phase-10-frontend-shell.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 data/abe.db create mode 100644 data/abe.db-shm create mode 100644 data/abe.db-wal create mode 100644 dist/cli.js create mode 100644 dist/core/AnomalyDetector.js create mode 100644 dist/core/ExplorationConfig.js create mode 100644 dist/core/ExplorationEngine.js create mode 100644 dist/core/Logger.js create mode 100644 dist/core/StateGraph.js create mode 100644 dist/core/interfaces.js create mode 100644 dist/db/AnomalyRepository.js create mode 100644 dist/db/ScheduleRepository.js create mode 100644 dist/db/SessionRepository.js create mode 100644 dist/db/VisualBaselineRepository.js create mode 100644 dist/db/connection.js create mode 100644 dist/db/migrations.js create mode 100644 dist/index.js create mode 100644 dist/plugins/agents/PlaywrightAgent.js create mode 100644 dist/plugins/collectors/AccessibilityCollector.js create mode 100644 dist/plugins/collectors/DOMSnapshotCollector.js create mode 100644 dist/plugins/collectors/NetworkCollector.js create mode 100644 dist/plugins/collectors/PerformanceCollector.js create mode 100644 dist/plugins/collectors/ScreenshotCollector.js create mode 100644 dist/plugins/collectors/VisualRegressionCollector.js create mode 100644 dist/plugins/exporters/JSONExporter.js create mode 100644 dist/plugins/exporters/MarkdownExporter.js create mode 100644 dist/plugins/fuzzers/FuzzingEngine.js create mode 100644 dist/plugins/fuzzers/InputTypeDetector.js create mode 100644 dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js create mode 100644 dist/plugins/fuzzers/strategies/EmptyValueStrategy.js create mode 100644 dist/plugins/fuzzers/strategies/OversizedStringStrategy.js create mode 100644 dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js create mode 100644 dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js create mode 100644 dist/plugins/interfaces.js create mode 100644 dist/plugins/reproducers/PlaywrightReproducer.js create mode 100644 dist/replay.js create mode 100644 dist/server/SessionStore.js create mode 100644 dist/server/enrichment/AIEnrichmentService.js create mode 100644 dist/server/enrichment/ClaudeProvider.js create mode 100644 dist/server/enrichment/OllamaProvider.js create mode 100644 dist/server/enrichment/OpenAIProvider.js create mode 100644 dist/server/index.js create mode 100644 dist/server/logger.js create mode 100644 dist/server/middleware/auth.js create mode 100644 dist/server/notifications/NotificationService.js create mode 100644 dist/server/notifications/SlackNotifier.js create mode 100644 dist/server/notifications/WebhookNotifier.js create mode 100644 dist/server/routes/anomalies.js create mode 100644 dist/server/routes/config.js create mode 100644 dist/server/routes/schedules.js create mode 100644 dist/server/routes/sessions.js create mode 100644 dist/server/routes/visual.js create mode 100644 dist/server/scheduler/SchedulerService.js create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/__tests__/AnomalyList.test.tsx create mode 100644 frontend/src/__tests__/NewSessionForm.test.tsx create mode 100644 frontend/src/__tests__/SeverityBadge.test.tsx create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/AnomalyCard.tsx create mode 100644 frontend/src/components/AnomalyList.tsx create mode 100644 frontend/src/components/LiveFeed.tsx create mode 100644 frontend/src/components/NewSessionForm.tsx create mode 100644 frontend/src/components/SessionList.tsx create mode 100644 frontend/src/components/SeverityBadge.tsx create mode 100644 frontend/src/hooks/useApi.ts create mode 100644 frontend/src/hooks/useSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AnomalyDetail.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/SessionDetail.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/VisualReview.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 jest.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 run-setup.sh create mode 100644 src/cli.ts create mode 100644 src/core/AnomalyDetector.ts create mode 100644 src/core/ExplorationConfig.ts create mode 100644 src/core/ExplorationEngine.ts create mode 100644 src/core/Logger.ts create mode 100644 src/core/StateGraph.ts create mode 100644 src/core/interfaces.ts create mode 100644 src/db/AnomalyRepository.ts create mode 100644 src/db/ScheduleRepository.ts create mode 100644 src/db/SessionRepository.ts create mode 100644 src/db/VisualBaselineRepository.ts create mode 100644 src/db/connection.ts create mode 100644 src/db/migrations.ts create mode 100644 src/index.ts create mode 100644 src/plugins/agents/PlaywrightAgent.ts create mode 100644 src/plugins/collectors/AccessibilityCollector.ts create mode 100644 src/plugins/collectors/DOMSnapshotCollector.ts create mode 100644 src/plugins/collectors/NetworkCollector.ts create mode 100644 src/plugins/collectors/PerformanceCollector.ts create mode 100644 src/plugins/collectors/ScreenshotCollector.ts create mode 100644 src/plugins/collectors/VisualRegressionCollector.ts create mode 100644 src/plugins/exporters/JSONExporter.ts create mode 100644 src/plugins/exporters/MarkdownExporter.ts create mode 100644 src/plugins/fuzzers/FuzzingEngine.ts create mode 100644 src/plugins/fuzzers/InputTypeDetector.ts create mode 100644 src/plugins/fuzzers/strategies/BoundaryValueStrategy.ts create mode 100644 src/plugins/fuzzers/strategies/EmptyValueStrategy.ts create mode 100644 src/plugins/fuzzers/strategies/OversizedStringStrategy.ts create mode 100644 src/plugins/fuzzers/strategies/SpecialCharsStrategy.ts create mode 100644 src/plugins/fuzzers/strategies/TypeMismatchStrategy.ts create mode 100644 src/plugins/interfaces.ts create mode 100644 src/plugins/reproducers/PlaywrightReproducer.ts create mode 100644 src/replay.ts create mode 100644 src/server/SessionStore.ts create mode 100644 src/server/enrichment/AIEnrichmentService.ts create mode 100644 src/server/enrichment/ClaudeProvider.ts create mode 100644 src/server/enrichment/OllamaProvider.ts create mode 100644 src/server/enrichment/OpenAIProvider.ts create mode 100644 src/server/index.ts create mode 100644 src/server/logger.ts create mode 100644 src/server/middleware/auth.ts create mode 100644 src/server/notifications/NotificationService.ts create mode 100644 src/server/notifications/SlackNotifier.ts create mode 100644 src/server/notifications/WebhookNotifier.ts create mode 100644 src/server/routes/anomalies.ts create mode 100644 src/server/routes/config.ts create mode 100644 src/server/routes/schedules.ts create mode 100644 src/server/routes/sessions.ts create mode 100644 src/server/routes/visual.ts create mode 100644 src/server/scheduler/SchedulerService.ts create mode 100644 tests/core/AnomalyDetector.test.ts create mode 100644 tests/core/ExplorationEngine.test.ts create mode 100644 tests/core/StateGraph.test.ts create mode 100644 tests/db/repositories.test.ts create mode 100644 tests/plugins/accessibility.test.ts create mode 100644 tests/plugins/agents/PlaywrightAgent.test.ts create mode 100644 tests/plugins/collectors.test.ts create mode 100644 tests/plugins/explorationConfig.test.ts create mode 100644 tests/plugins/exporters/JSONExporter.test.ts create mode 100644 tests/plugins/exporters/MarkdownExporter.test.ts create mode 100644 tests/plugins/fuzzers.test.ts create mode 100644 tests/plugins/networkChaos.test.ts create mode 100644 tests/plugins/performanceCollector.test.ts create mode 100644 tests/plugins/reproducers.test.ts create mode 100644 tests/plugins/visualRegression.test.ts create mode 100644 tests/server/aiProviders.test.ts create mode 100644 tests/server/auth.test.ts create mode 100644 tests/server/cli.test.ts create mode 100644 tests/server/notifications.test.ts create mode 100644 tests/server/scheduler.test.ts create mode 100644 tests/server/server.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b0335a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +logs +reports +data +.ralph +tests +frontend +.git +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..354df29 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +ABE_API_KEY=change-me-in-production +ABE_CORS_ORIGIN=http://localhost:5173 +ABE_PORT=3001 +ABE_DB_PATH=./data/abe.db +ABE_REPORTS_DIR=./reports +ABE_LOGS_DIR=./logs +ABE_MAX_CONCURRENT_SESSIONS=3 +ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz +ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe +ABE_NOTIFY_MIN_SEVERITY=high +ABE_LOG_LEVEL=info +NODE_ENV=production diff --git a/.github/workflows/abe-example.yml b/.github/workflows/abe-example.yml new file mode 100644 index 0000000..0f0b9ae --- /dev/null +++ b/.github/workflows/abe-example.yml @@ -0,0 +1,48 @@ +name: ABE Exploratory Testing + +on: + push: + branches: [main] + pull_request: + +jobs: + explore: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Start application + run: docker-compose up -d app + # assumes the project has a docker-compose with the target app + + - name: Wait for app + run: npx wait-on http://localhost:3000 --timeout 30000 + + - name: Run ABE + run: | + npm run abe -- run \ + --url http://localhost:3000 \ + --max-states 30 \ + --fail-on-severity high \ + --output junit + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: abe-reports + path: reports/ + + - name: Publish test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: abe-results.xml diff --git a/.gitignore b/.gitignore index b345356..c8e2ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ .ralph/docs/generated/* !.ralph/docs/generated/.gitkeep +# Environment +.env + # General logs *.log diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha new file mode 100644 index 0000000..42a6bbe --- /dev/null +++ b/.ralph/.loop_start_sha @@ -0,0 +1 @@ +4c92712d204993bdd6ff2a7b60583e28ac4f87b1 diff --git a/.ralph/AGENT.md b/.ralph/AGENT.md index 26378ab..b1e7e7e 100644 --- a/.ralph/AGENT.md +++ b/.ralph/AGENT.md @@ -1,158 +1,69 @@ -# Agent Build Instructions +# ABE — Build, Test & Development Commands -## Project Setup +## Install dependencies ```bash -# Install dependencies (example for Node.js project) npm install - -# Or for Python project -pip install -r requirements.txt - -# Or for Rust project -cargo build +cd frontend && npm install && cd .. ``` -## Running Tests +## Build (backend) ```bash -# Node.js -npm test - -# Python -pytest - -# Rust -cargo test -``` - -## Build Commands -```bash -# Production build npm run build -# or -cargo build --release ``` -## Development Server +## Build (frontend) ```bash -# Start development server -npm run dev -# or -cargo run +cd frontend && npm run build ``` -## Key Learnings -- Update this section when you learn new build optimizations -- Document any gotchas or special setup requirements -- Keep track of the fastest test/build cycle +## Test +```bash +npm run test +``` -## Feature Development Quality Standards +## Lint +```bash +npm run lint +``` -**CRITICAL**: All new features MUST meet the following mandatory requirements before being considered complete. +## Type check +```bash +npm run typecheck +``` -### Testing Requirements +## Dev mode +```bash +npm run dev # backend con hot reload +cd frontend && npm run dev # frontend dev server +``` -- **Minimum Coverage**: 85% code coverage ratio required for all new code -- **Test Pass Rate**: 100% - all tests must pass, no exceptions -- **Test Types Required**: - - Unit tests for all business logic and services - - Integration tests for API endpoints or main functionality - - End-to-end tests for critical user workflows -- **Coverage Validation**: Run coverage reports before marking features complete: - ```bash - # Examples by language/framework - npm run test:coverage - pytest --cov=src tests/ --cov-report=term-missing - cargo tarpaulin --out Html - ``` -- **Test Quality**: Tests must validate behavior, not just achieve coverage metrics -- **Test Documentation**: Complex test scenarios must include comments explaining the test strategy +## Database +```bash +npm run db:migrate # ejecutar migraciones Kysely +``` -### Git Workflow Requirements +## Docker +```bash +docker compose up -d --build +docker compose logs -f +docker compose down +``` -Before moving to the next feature, ALL changes must be: +## Verificación completa (ejecutar después de CADA tarea) +```bash +npm run build && cd frontend && npm run build && cd .. && npm run test +``` -1. **Committed with Clear Messages**: - ```bash - git add . - git commit -m "feat(module): descriptive message following conventional commits" - ``` - - Use conventional commit format: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, etc. - - Include scope when applicable: `feat(api):`, `fix(ui):`, `test(auth):` - - Write descriptive messages that explain WHAT changed and WHY +## Commit después de tarea completada +```bash +git add -A && git commit -m "fase(X.Y): descripción" +``` -2. **Pushed to Remote Repository**: - ```bash - git push origin - ``` - - Never leave completed features uncommitted - - Push regularly to maintain backup and enable collaboration - - Ensure CI/CD pipelines pass before considering feature complete +## Notas +- Source code: src/ +- Frontend: frontend/ +- Tests: junto al código (*.test.ts) o en tests/ +- Reports output: reports/ +- Logs: logs/ +- Database: data/abe.db -3. **Branch Hygiene**: - - Work on feature branches, never directly on `main` - - Branch naming convention: `feature/`, `fix/`, `docs/` - - Create pull requests for all significant changes - -4. **Ralph Integration**: - - Update .ralph/fix_plan.md with new tasks before starting work - - Mark items complete in .ralph/fix_plan.md upon completion - - Update .ralph/PROMPT.md if development patterns change - - Test features work within Ralph's autonomous loop - -### Documentation Requirements - -**ALL implementation documentation MUST remain synchronized with the codebase**: - -1. **Code Documentation**: - - Language-appropriate documentation (JSDoc, docstrings, etc.) - - Update inline comments when implementation changes - - Remove outdated comments immediately - -2. **Implementation Documentation**: - - Update relevant sections in this AGENT.md file - - Keep build and test commands current - - Update configuration examples when defaults change - - Document breaking changes prominently - -3. **README Updates**: - - Keep feature lists current - - Update setup instructions when dependencies change - - Maintain accurate command examples - - Update version compatibility information - -4. **AGENT.md Maintenance**: - - Add new build patterns to relevant sections - - Update "Key Learnings" with new insights - - Keep command examples accurate and tested - - Document new testing patterns or quality gates - -### Feature Completion Checklist - -Before marking ANY feature as complete, verify: - -- [ ] All tests pass with appropriate framework command -- [ ] Code coverage meets 85% minimum threshold -- [ ] Coverage report reviewed for meaningful test quality -- [ ] Code formatted according to project standards -- [ ] Type checking passes (if applicable) -- [ ] All changes committed with conventional commit messages -- [ ] All commits pushed to remote repository -- [ ] .ralph/fix_plan.md task marked as complete -- [ ] Implementation documentation updated -- [ ] Inline code comments updated or added -- [ ] .ralph/AGENT.md updated (if new patterns introduced) -- [ ] Breaking changes documented -- [ ] Features tested within Ralph loop (if applicable) -- [ ] CI/CD pipeline passes - -### Rationale - -These standards ensure: -- **Quality**: High test coverage and pass rates prevent regressions -- **Traceability**: Git commits and .ralph/fix_plan.md provide clear history of changes -- **Maintainability**: Current documentation reduces onboarding time and prevents knowledge loss -- **Collaboration**: Pushed changes enable team visibility and code review -- **Reliability**: Consistent quality gates maintain production stability -- **Automation**: Ralph integration ensures continuous development practices - -**Enforcement**: AI agents should automatically apply these standards to all feature development tasks without requiring explicit instruction for each task. diff --git a/.ralph/PROMPT.md b/.ralph/PROMPT.md index 48193e6..02be6f8 100644 --- a/.ralph/PROMPT.md +++ b/.ralph/PROMPT.md @@ -1,296 +1,182 @@ -# Ralph Development Instructions - -## Context -You are Ralph, an autonomous AI development agent working on a [YOUR PROJECT NAME] project. - -## Current Objectives -1. Study .ralph/specs/* to learn about the project specifications -2. Review .ralph/fix_plan.md for current priorities -3. Implement the highest priority item using best practices -4. Use parallel subagents for complex tasks (max 100 concurrent) -5. Run tests after each implementation -6. Update documentation and fix_plan.md - -## Key Principles -- ONE task per loop - focus on the most important thing -- Search the codebase before assuming something isn't implemented -- Use subagents for expensive operations (file searching, analysis) -- Write comprehensive tests with clear documentation -- Update .ralph/fix_plan.md with your learnings -- Commit working changes with descriptive messages - -## Protected Files (DO NOT MODIFY) -The following files and directories are part of Ralph's infrastructure. -NEVER delete, move, rename, or overwrite these under any circumstances: -- .ralph/ (entire directory and all contents) -- .ralphrc (project configuration) - -When performing cleanup, refactoring, or restructuring tasks: -- These files are NOT part of your project code -- They are Ralph's internal control files that keep the development loop running -- Deleting them will break Ralph and halt all autonomous development - -## 🧪 Testing Guidelines (CRITICAL) -- LIMIT testing to ~20% of your total effort per loop -- PRIORITIZE: Implementation > Documentation > Tests -- Only write tests for NEW functionality you implement -- Do NOT refactor existing tests unless broken -- Do NOT add "additional test coverage" as busy work -- Focus on CORE functionality first, comprehensive testing later - -## Execution Guidelines -- Before making changes: search codebase using subagents -- After implementation: run ESSENTIAL tests for the modified code only -- If tests fail: fix them as part of your current work -- Keep .ralph/AGENT.md updated with build/run instructions -- Document the WHY behind tests and implementations -- No placeholder implementations - build it properly - -## 🎯 Status Reporting (CRITICAL - Ralph needs this!) - -**IMPORTANT**: At the end of your response, ALWAYS include this status block: - -``` ----RALPH_STATUS--- -STATUS: IN_PROGRESS | COMPLETE | BLOCKED -TASKS_COMPLETED_THIS_LOOP: -FILES_MODIFIED: -TESTS_STATUS: PASSING | FAILING | NOT_RUN -WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING -EXIT_SIGNAL: false | true -RECOMMENDATION: ----END_RALPH_STATUS--- -``` - -### When to set EXIT_SIGNAL: true - -Set EXIT_SIGNAL to **true** when ALL of these conditions are met: -1. ✅ All items in fix_plan.md are marked [x] -2. ✅ All tests are passing (or no tests exist for valid reasons) -3. ✅ No errors or warnings in the last execution -4. ✅ All requirements from specs/ are implemented -5. ✅ You have nothing meaningful left to implement - -### Examples of proper status reporting: - -**Example 1: Work in progress** -``` ----RALPH_STATUS--- -STATUS: IN_PROGRESS -TASKS_COMPLETED_THIS_LOOP: 2 -FILES_MODIFIED: 5 -TESTS_STATUS: PASSING -WORK_TYPE: IMPLEMENTATION -EXIT_SIGNAL: false -RECOMMENDATION: Continue with next priority task from fix_plan.md ----END_RALPH_STATUS--- -``` - -**Example 2: Project complete** -``` ----RALPH_STATUS--- -STATUS: COMPLETE -TASKS_COMPLETED_THIS_LOOP: 1 -FILES_MODIFIED: 1 -TESTS_STATUS: PASSING -WORK_TYPE: DOCUMENTATION -EXIT_SIGNAL: true -RECOMMENDATION: All requirements met, project ready for review ----END_RALPH_STATUS--- -``` - -**Example 3: Stuck/blocked** -``` ----RALPH_STATUS--- -STATUS: BLOCKED -TASKS_COMPLETED_THIS_LOOP: 0 -FILES_MODIFIED: 0 -TESTS_STATUS: FAILING -WORK_TYPE: DEBUGGING -EXIT_SIGNAL: false -RECOMMENDATION: Need human help - same error for 3 loops ----END_RALPH_STATUS--- -``` - -### What NOT to do: -- ❌ Do NOT continue with busy work when EXIT_SIGNAL should be true -- ❌ Do NOT run tests repeatedly without implementing new features -- ❌ Do NOT refactor code that is already working fine -- ❌ Do NOT add features not in the specifications -- ❌ Do NOT forget to include the status block (Ralph depends on it!) - -## 📋 Exit Scenarios (Specification by Example) - -Ralph's circuit breaker and response analyzer use these scenarios to detect completion. -Each scenario shows the exact conditions and expected behavior. - -### Scenario 1: Successful Project Completion -**Given**: -- All items in .ralph/fix_plan.md are marked [x] -- Last test run shows all tests passing -- No errors in recent logs/ -- All requirements from .ralph/specs/ are implemented - -**When**: You evaluate project status at end of loop - -**Then**: You must output: -``` ----RALPH_STATUS--- -STATUS: COMPLETE -TASKS_COMPLETED_THIS_LOOP: 1 -FILES_MODIFIED: 1 -TESTS_STATUS: PASSING -WORK_TYPE: DOCUMENTATION -EXIT_SIGNAL: true -RECOMMENDATION: All requirements met, project ready for review ----END_RALPH_STATUS--- -``` - -**Ralph's Action**: Detects EXIT_SIGNAL=true, gracefully exits loop with success message +# ABE — Autonomous Bug Explorer +## Instrucciones Maestras para Claude Code (via Ralph) --- -### Scenario 2: Test-Only Loop Detected -**Given**: -- Last 3 loops only executed tests (npm test, bats, pytest, etc.) -- No new files were created -- No existing files were modified -- No implementation work was performed +## Visión del proyecto -**When**: You start a new loop iteration +ABE es una plataforma enterprise self-hosted de descubrimiento autónomo de bugs +en aplicaciones web. Explora apps como un usuario real, inyecta inputs inválidos +(fuzzing), detecta anomalías, y genera bug reports reproducibles. -**Then**: You must output: -``` ----RALPH_STATUS--- -STATUS: IN_PROGRESS -TASKS_COMPLETED_THIS_LOOP: 0 -FILES_MODIFIED: 0 -TESTS_STATUS: PASSING -WORK_TYPE: TESTING -EXIT_SIGNAL: false -RECOMMENDATION: All tests passing, no implementation needed ----END_RALPH_STATUS--- -``` +**Posicionamiento**: "Playwright discovers what you test. ABE discovers what you miss." -**Ralph's Action**: Increments test_only_loops counter, exits after 3 consecutive test-only loops +Modelo open-core enterprise self-hosted: +- Free/OSS: exploración autónoma + reports básicos +- Pro: dashboards avanzados, integraciones, CLI/CI +- Enterprise: SSO, RBAC avanzado, LDAP, audit logs, licencia --- -### Scenario 3: Stuck on Recurring Error -**Given**: -- Same error appears in last 5 consecutive loops -- No progress on fixing the error -- Error message is identical or very similar +## Estado actual del código -**When**: You encounter the same error again - -**Then**: You must output: +Las fases 1-11 del proyecto original están implementadas con esta estructura: ``` ----RALPH_STATUS--- -STATUS: BLOCKED -TASKS_COMPLETED_THIS_LOOP: 0 -FILES_MODIFIED: 2 -TESTS_STATUS: FAILING -WORK_TYPE: DEBUGGING -EXIT_SIGNAL: false -RECOMMENDATION: Stuck on [error description] - human intervention needed ----END_RALPH_STATUS--- +src/ +├── core/ ← interfaces.ts, ExplorationEngine, StateGraph, AnomalyDetector +├── plugins/ ← PlaywrightAgent, collectors, exporters, fuzzers, reproducers +├── server/ ← Express API server + socket.io +├── db/ ← SQLite repositories (better-sqlite3) +├── cli.ts ← CLI entry point +frontend/ ← React + Vite + Tailwind (básico) ``` -**Ralph's Action**: Circuit breaker detects repeated errors, opens circuit after 5 loops +El objetivo es REFACTORIZAR este código existente hacia una arquitectura +modular hexagonal, migrando pieza por pieza sin romper funcionalidad. + +**ESTRATEGIA DE MIGRACIÓN**: No reescribir de cero. Mover código existente +a la nueva estructura, adaptar interfaces, y verificar que todo sigue funcionando +después de cada movimiento. --- -### Scenario 4: No Work Remaining -**Given**: -- All tasks in fix_plan.md are complete -- You analyze .ralph/specs/ and find nothing new to implement -- Code quality is acceptable -- Tests are passing +## Arquitectura objetivo: Modular Monolith Hexagonal -**When**: You search for work to do and find none - -**Then**: You must output: +### Principio fundamental ``` ----RALPH_STATUS--- -STATUS: COMPLETE -TASKS_COMPLETED_THIS_LOOP: 0 -FILES_MODIFIED: 0 -TESTS_STATUS: PASSING -WORK_TYPE: DOCUMENTATION -EXIT_SIGNAL: true -RECOMMENDATION: No remaining work, all .ralph/specs implemented ----END_RALPH_STATUS--- +Infrastructure → Application → Domain +(el código SIEMPRE apunta hacia adentro, nunca al revés) ``` -**Ralph's Action**: Detects completion signal, exits loop immediately +### Estructura de carpetas OBJETIVO +``` +src/ +├── shared/ +│ ├── domain/ +│ │ ├── Entity.ts +│ │ ├── AggregateRoot.ts +│ │ ├── ValueObject.ts +│ │ ├── UniqueId.ts +│ │ ├── Result.ts +│ │ └── DomainEvent.ts +│ ├── application/ +│ │ ├── UseCase.ts +│ │ ├── EventBus.ts +│ │ └── EventHandler.ts +│ └── infrastructure/ +│ ├── InProcessEventBus.ts +│ ├── DatabaseConnection.ts +│ ├── Logger.ts +│ ├── Config.ts +│ └── StorageProvider.ts +│ +├── modules/ +│ ├── crawling/ +│ │ ├── domain/ (entities, value-objects, events, ports) +│ │ ├── application/ (commands, queries, event-handlers) +│ │ └── infrastructure/(adapters, repositories, http) +│ ├── fuzzing/ (misma estructura) +│ ├── findings/ (misma estructura) +│ ├── auth/ (misma estructura) +│ ├── reporting/ (misma estructura) +│ ├── integrations/ (misma estructura) +│ ├── scheduling/ (misma estructura) +│ └── licensing/ (misma estructura) +│ +├── api/ +│ ├── server.ts +│ ├── router.ts +│ └── middleware/ +├── realtime/ +│ └── SocketGateway.ts +├── jobs/ +│ ├── JobQueue.ts +│ ├── SQLiteJobQueue.ts +│ └── workers/ +├── cli/ +│ └── abe.ts +└── main.ts ← composition root +``` --- -### Scenario 5: Making Progress -**Given**: -- Tasks remain in .ralph/fix_plan.md -- Implementation is underway -- Files are being modified -- Tests are passing or being fixed +## Reglas de arquitectura INQUEBRANTABLES -**When**: You complete a task successfully - -**Then**: You must output: -``` ----RALPH_STATUS--- -STATUS: IN_PROGRESS -TASKS_COMPLETED_THIS_LOOP: 3 -FILES_MODIFIED: 7 -TESTS_STATUS: PASSING -WORK_TYPE: IMPLEMENTATION -EXIT_SIGNAL: false -RECOMMENDATION: Continue with next task from .ralph/fix_plan.md ----END_RALPH_STATUS--- -``` - -**Ralph's Action**: Continues loop, circuit breaker stays CLOSED (normal operation) +1. **Domain layer NO importa nada externo** — ni Kysely, ni Express, ni Playwright. +2. **Cross-module communication SOLO via EventBus** — NUNCA import directo entre módulos. +3. **Cada módulo exporta SOLO su facade** via `index.ts`. +4. **Controllers son THIN** — parsean request, llaman use case, formatean response. +5. **Use Cases retornan Result** — NUNCA throw para errores de negocio. +6. **Un archivo = una clase = una responsabilidad**. +7. **Determinista** — no usar Math.random() sin seed. Loguear siempre el seed. +8. **Serializable** — entities y value objects JSON.stringify-able. +9. **No AI en el core loop** — AIEnrichment es post-proceso opcional. +10. **Plugins nunca se importan desde core** — core solo define interfaces/ports. --- -### Scenario 6: Blocked on External Dependency -**Given**: -- Task requires external API, library, or human decision -- Cannot proceed without missing information -- Have tried reasonable workarounds +## Stack tecnológico -**When**: You identify the blocker +### Backend +- Runtime: Node.js 20 + TypeScript 5.x (strict mode) +- HTTP: Express.js 4.x +- WebSocket: socket.io 4.x +- Database: Kysely (query builder) + better-sqlite3 (default) | pg (enterprise) +- Validation: Zod (schemas compartidos frontend/backend) +- Auth: Better Auth + CASL +- Browser: Playwright +- Logger: Pino + pino-pretty (dev) +- Jobs: SQLite-backed queue custom con worker_threads +- Scheduler: node-cron +- Security: Helmet, express-rate-limit, cors +- API docs: zod-to-openapi + Scalar UI +- Testing: Vitest + supertest (integration) -**Then**: You must output: -``` ----RALPH_STATUS--- -STATUS: BLOCKED -TASKS_COMPLETED_THIS_LOOP: 0 -FILES_MODIFIED: 0 -TESTS_STATUS: NOT_RUN -WORK_TYPE: IMPLEMENTATION -EXIT_SIGNAL: false -RECOMMENDATION: Blocked on [specific dependency] - need [what's needed] ----END_RALPH_STATUS--- -``` - -**Ralph's Action**: Logs blocker, may exit after multiple blocked loops +### Frontend +- React 18 + Vite + TypeScript +- shadcn/ui (Radix UI + Tailwind CSS) +- Tremor + Recharts (charts/dashboards) +- TanStack Table + TanStack Query +- Zustand (client state) +- React Hook Form + Zod resolver +- socket.io-client +- Framer Motion --- -## File Structure -- .ralph/: Ralph-specific configuration and documentation - - specs/: Project specifications and requirements - - fix_plan.md: Prioritized TODO list - - AGENT.md: Project build and run instructions - - PROMPT.md: This file - Ralph development instructions - - logs/: Loop execution logs - - docs/generated/: Auto-generated documentation -- src/: Source code implementation -- examples/: Example usage and test cases +## REGLAS OBLIGATORIAS PARA CADA TAREA -## Current Task -Follow .ralph/fix_plan.md and choose the most important item to implement next. -Use your judgment to prioritize what will have the biggest impact on project progress. +### Antes de empezar +1. Leer la tarea actual del fix_plan.md +2. Leer la spec correspondiente en .ralph/specs/ SI existe +3. Verificar que las dependencias (tareas previas) están completas -Remember: Quality over speed. Build it right the first time. Know when you're done. +### Después de CADA tarea individual +1. `npm run build` — DEBE compilar sin errores +2. `cd frontend && npm run build` — DEBE compilar sin errores +3. `npm run test` — DEBE pasar (o no romper tests existentes) +4. Si ALGUNO falla → NO marcar tarea → arreglar PRIMERO + +### Después de marcar tarea como completa +1. `git add -A` +2. `git commit -m "fase(X.Y): descripción breve de la tarea"` + Ejemplo: `git commit -m "fase(1.3): create ValueObject base class with equals"` +3. Verificar que el commit se hizo correctamente + +### Reglas de código +- Todo nuevo código DEBE tener tipos explícitos (CERO `any`) +- Imports ordenados: node_modules → shared → modules → relative +- Nombres: PascalCase para clases, camelCase para funciones/variables +- Cada módulo nuevo DEBE tener al menos un test unitario +- Código existente que se MUEVE debe seguir funcionando igual + +--- + +## Señal de completado + +Cuando TODAS las tareas en fix_plan.md estén marcadas [x]: + +RALPH_STATUS: + completion_indicators: done + EXIT_SIGNAL: true + summary: "ABE enterprise refactor complete." diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 07212af..2faa3cb 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -1,27 +1,444 @@ -# Ralph Fix Plan +# ABE Enterprise Refactor — Fix Plan -## High Priority -- [ ] Set up basic project structure and build system -- [ ] Define core data structures and types -- [ ] Implement basic input/output handling -- [ ] Create test framework and initial tests +## REGLAS CRÍTICAS +1. NO pasar a la siguiente tarea si el build falla +2. Hacer `git commit` después de CADA tarea completada +3. Leer la spec en `.ralph/specs/` ANTES de cada phase +4. Los tests DEBEN pasar antes de marcar [x] +5. Formato commit: `git commit -m "fase(X.Y): descripción"` -## Medium Priority -- [ ] Add error handling and validation -- [ ] Implement core business logic -- [ ] Add configuration management -- [ ] Create user documentation +--- -## Low Priority -- [ ] Performance optimization -- [ ] Extended feature set -- [ ] Integration with external services -- [ ] Advanced error recovery +## Phase 0: Hotfix — Build actual funcional [PENDIENTE] -## Completed -- [x] Project initialization +- [ ] 0.1: Fix errores TypeScript en src/ que impidan compilación (IAnomaly import, NodeListOf iterator, cualquier otro) +- [ ] 0.2: Verificar `npm run build` pasa con 0 errores +- [ ] 0.3: Verificar `cd frontend && npm run build` pasa con 0 errores +- [ ] 0.4: Verificar que la app arranca con `npm run dev` sin crash +- [ ] 0.5: Commit: `git add -A && git commit -m "fase(0): fix build errors"` -## Notes -- Focus on MVP functionality first -- Ensure each feature is properly tested -- Update this file after each major milestone +--- + +## Phase 1: Shared Domain — Building Blocks [PENDIENTE] +Spec: `.ralph/specs/phase-01-shared-domain.md` + +- [ ] 1.1: Crear directorio `src/shared/domain/` +- [ ] 1.2: Crear `src/shared/domain/Result.ts` — Result con Ok(), Err(), isOk(), isErr() +- [ ] 1.3: Crear `src/shared/domain/UniqueId.ts` — UUID v4 wrapper con create(), toString(), equals() +- [ ] 1.4: Crear `src/shared/domain/Entity.ts` — base class con _id: UniqueId, equals() +- [ ] 1.5: Crear `src/shared/domain/AggregateRoot.ts` — extends Entity + domainEvents[], addDomainEvent(), clearEvents() +- [ ] 1.6: Crear `src/shared/domain/ValueObject.ts` — base class inmutable con props frozen, equals() +- [ ] 1.7: Crear `src/shared/domain/DomainEvent.ts` — interface: eventId, eventName, aggregateId, occurredOn, payload +- [ ] 1.8: Crear `src/shared/application/UseCase.ts` — interface: execute(req) → Promise> +- [ ] 1.9: Crear `src/shared/application/EventBus.ts` — interface: publish(event), subscribe(name, handler) +- [ ] 1.10: Crear `src/shared/application/EventHandler.ts` — interface: handle(event) → Promise +- [ ] 1.11: Crear `src/shared/domain/index.ts` — barrel export de todo shared/domain +- [ ] 1.12: Crear `src/shared/application/index.ts` — barrel export de todo shared/application +- [ ] 1.13: Tests unitarios: Result (Ok/Err/isOk/isErr), Entity (equals by id), ValueObject (equals by props), UniqueId (create/equals) +- [ ] 1.14: Verificar build completo + commit: `fase(1): shared domain building blocks` + +--- + +## Phase 2: Shared Infrastructure [PENDIENTE] +Spec: `.ralph/specs/phase-02-shared-infrastructure.md` + +- [ ] 2.1: Instalar deps: `npm i kysely better-sqlite3 pino pino-pretty zod helmet express-rate-limit dotenv uuid` + `npm i -D @types/better-sqlite3 @types/uuid` +- [ ] 2.2: Crear `src/shared/infrastructure/Config.ts` — Zod schema para TODAS las env vars con defaults sensatos +- [ ] 2.3: Crear `src/shared/infrastructure/Logger.ts` — Pino factory: createLogger(config) retorna pino.Logger, pino-pretty en dev +- [ ] 2.4: Crear `src/shared/infrastructure/DatabaseConnection.ts` — Kysely factory: createDatabase(config) soporta SQLite (default) y PostgreSQL (si config.db.driver === 'postgres') +- [ ] 2.5: Crear `src/shared/infrastructure/InProcessEventBus.ts` — implementa EventBus con Node EventEmitter, logging de eventos, error handling en handlers +- [ ] 2.6: Crear `src/shared/infrastructure/StorageProvider.ts` — interface IStorageProvider (save/get/delete/exists) + LocalStorageProvider (filesystem) +- [ ] 2.7: Crear `src/shared/infrastructure/index.ts` — barrel export +- [ ] 2.8: Crear `src/db/migrations/001_initial_schema.ts` — migración Kysely que crea las tablas existentes (sessions, states, actions, anomalies, notifications) con IF NOT EXISTS +- [ ] 2.9: Crear `src/db/migrator.ts` — setup Kysely Migrator + función runMigrations() +- [ ] 2.10: Añadir script `"db:migrate"` a package.json +- [ ] 2.11: Tests: Config validation (valid + invalid), EventBus (publish/subscribe/error handling), StorageProvider (save/get/delete) +- [ ] 2.12: Verificar build completo + commit: `fase(2): shared infrastructure layer` + +--- + +## Phase 3: Crawling Module — Domain + Application [PENDIENTE] +Spec: `.ralph/specs/phase-03-crawling-domain.md` + +- [ ] 3.1: Crear `src/modules/crawling/domain/entities/CrawlSession.ts` — AggregateRoot con url, status, seed, maxStates, statesVisited, config +- [ ] 3.2: Crear `src/modules/crawling/domain/entities/CrawlState.ts` — Entity con url, title, domSnapshot, visitCount +- [ ] 3.3: Crear `src/modules/crawling/domain/entities/CrawlAction.ts` — Entity con type, selector, value, seed, stateId, sequenceOrder +- [ ] 3.4: Crear value objects: `Url.ts`, `Selector.ts`, `SessionStatus.ts` (running/completed/failed/stopped) +- [ ] 3.5: Crear events: `CrawlStarted.ts`, `StateDiscovered.ts`, `ActionExecuted.ts`, `CrawlCompleted.ts`, `CrawlFailed.ts` +- [ ] 3.6: Crear ports: `ICrawlerEngine.ts` (launch/close/discoverActions/executeAction/captureState), `ICrawlSessionRepository.ts` (save/findById/findAll/update), `IStateRepository.ts` +- [ ] 3.7: Crear `application/commands/StartCrawlCommand.ts` — use case que valida config, crea CrawlSession, emite CrawlStarted +- [ ] 3.8: Crear `application/commands/StopCrawlCommand.ts` — use case que para sesión, emite CrawlCompleted +- [ ] 3.9: Crear `application/queries/GetSessionQuery.ts` y `ListSessionsQuery.ts` +- [ ] 3.10: Crear `modules/crawling/index.ts` — barrel export público +- [ ] 3.11: Tests: CrawlSession creation + domain events, StartCrawlCommand con mock repository +- [ ] 3.12: Verificar build + commit: `fase(3): crawling module domain and application` + +--- + +## Phase 4: Crawling Module — Infrastructure (migración código existente) [PENDIENTE] +Spec: `.ralph/specs/phase-04-crawling-infrastructure.md` + +- [ ] 4.1: Copiar `src/plugins/agents/PlaywrightAgent.ts` → `src/modules/crawling/infrastructure/adapters/PlaywrightCrawlerEngine.ts`, adaptar para implementar ICrawlerEngine port +- [ ] 4.2: Copiar `src/core/StateGraph.ts` → `src/modules/crawling/infrastructure/adapters/StateGraph.ts`, mantener lógica BFS +- [ ] 4.3: Copiar `src/core/ExplorationEngine.ts` → `src/modules/crawling/infrastructure/adapters/ExplorationOrchestrator.ts`, adaptar para usar ports en vez de imports directos +- [ ] 4.4: Crear `infrastructure/repositories/KyselyCrawlSessionRepository.ts` — implementa ICrawlSessionRepository con Kysely +- [ ] 4.5: Crear `infrastructure/repositories/KyselyStateRepository.ts` +- [ ] 4.6: Crear `infrastructure/http/CrawlingController.ts` — Express routes: POST /api/sessions, GET /api/sessions, GET /api/sessions/:id, DELETE /api/sessions/:id +- [ ] 4.7: Verificar que crear sesión + ejecutar crawl funciona end-to-end +- [ ] 4.8: Verificar build + commit: `fase(4): crawling infrastructure with migrated code` + +--- + +## Phase 5: Findings Module [PENDIENTE] +Spec: `.ralph/specs/phase-05-findings-module.md` + +- [ ] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace +- [ ] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed) +- [ ] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts` +- [ ] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts` +- [ ] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts` +- [ ] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts` +- [ ] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding +- [ ] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts` +- [ ] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter) +- [ ] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace +- [ ] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas +- [ ] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json +- [ ] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros +- [ ] 5.14: Verificar build + commit: `fase(5): findings module complete` + +--- + +## Phase 6: Fuzzing Module [PENDIENTE] +Spec: `.ralph/specs/phase-06-fuzzing-module.md` + +- [ ] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity) +- [ ] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts` +- [ ] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts` +- [ ] 6.4: Crear port: `IFuzzerEngine.ts` +- [ ] 6.5: Crear `commands/RunFuzzCommand.ts` +- [ ] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing +- [ ] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary) +- [ ] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts` → `infrastructure/adapters/` +- [ ] 6.9: Crear `infrastructure/http/FuzzingController.ts` +- [ ] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos +- [ ] 6.11: Verificar build + commit: `fase(6): fuzzing module complete` + +--- + +## Phase 7: API Server Refactor + Composition Root [PENDIENTE] +Spec: `.ralph/specs/phase-07-api-server.md` + +- [ ] 7.1: Crear `src/api/middleware/errorHandler.ts` — AppError hierarchy (ValidationError, AuthenticationError, ForbiddenError, NotFoundError) + global error handler +- [ ] 7.2: Crear `src/api/middleware/requestId.ts` — genera UUID por request, adjunta a req + pino child logger +- [ ] 7.3: Crear `src/api/middleware/notFound.ts` — 404 handler para rutas no encontradas +- [ ] 7.4: Crear `src/api/server.ts` — Express app con middleware stack: requestId → helmet → cors → rateLimit → bodyParser → routes → notFound → errorHandler +- [ ] 7.5: Crear `src/api/router.ts` — registra routes de TODOS los módulos (crawling, findings, fuzzing) +- [ ] 7.6: Crear `src/realtime/SocketGateway.ts` — socket.io server que subscribe a EventBus y emite a clientes +- [ ] 7.7: Crear `src/main.ts` — composition root: load config → create logger → create db → run migrations → create event bus → create repositories → create use cases → subscribe handlers → create controllers → create Express → create socket.io → start listening +- [ ] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit +- [ ] 7.9: Health endpoints: GET /health/live (process alive), GET /health/ready (DB check) +- [ ] 7.10: Verificar que TODOS los endpoints existentes siguen funcionando tras refactor +- [ ] 7.11: Verificar build + commit: `fase(7): api server refactor with composition root` + +--- + +## Phase 8: Job Queue System [PENDIENTE] +Spec: `.ralph/specs/phase-08-job-queue.md` + +- [ ] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive +- [ ] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker +- [ ] 8.3: Migración Kysely: tabla jobs +- [ ] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job +- [ ] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background +- [ ] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based +- [ ] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry +- [ ] 8.8: Verificar build + commit: `fase(8): sqlite job queue system` + +--- + +## Phase 9: Auth Module [PENDIENTE] +Spec: `.ralph/specs/phase-09-auth-module.md` + +- [ ] 9.1: Instalar: `npm i better-auth @casl/ability argon2` +- [ ] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `Team.ts` (Entity), `ApiKey.ts` (Entity) +- [ ] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts` +- [ ] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts` +- [ ] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts` +- [ ] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts` +- [ ] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts` +- [ ] 9.8: Crear `infrastructure/better-auth/authConfig.ts` — setup Better Auth con SQLite adapter, email+password, organization plugin con roles +- [ ] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role (owner: manage all, admin: manage all except delete org, member: create/read sessions+findings, viewer: read all) +- [ ] 9.10: Crear `application/middleware/AuthMiddleware.ts` — intenta session cookie → JWT → API key → 401 +- [ ] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL por ruta +- [ ] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts` +- [ ] 9.13: Crear `infrastructure/http/AuthController.ts` — POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, GET /api/auth/setup-required +- [ ] 9.14: Migración Kysely: tablas users, organizations, teams, org_members, api_keys, auth_sessions +- [ ] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true } +- [ ] 9.16: POST /api/auth/setup — crea primer user como owner + organización default +- [ ] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /health/* y /api/auth/* +- [ ] 9.18: Tests: register, login, RBAC permissions (admin can create session, viewer cannot) +- [ ] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl` + +--- + +## Phase 10: Frontend — shadcn/ui Shell [PENDIENTE] +Spec: `.ralph/specs/phase-10-frontend-shell.md` + +- [ ] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind) +- [ ] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert +- [ ] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook` +- [ ] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings) +- [ ] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu +- [ ] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet +- [ ] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component +- [ ] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage +- [ ] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401 +- [ ] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider +- [ ] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme +- [ ] 10.12: Crear pages/Login.tsx — form email + password con shadcn +- [ ] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org) +- [ ] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup +- [ ] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup +- [ ] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth` + +--- + +## Phase 11: Dashboard Page [PENDIENTE] +Spec: `.ralph/specs/phase-11-dashboard.md` + +- [ ] 11.1: Instalar en frontend: `npm i tremor recharts` +- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats +- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession +- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect +- [ ] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage +- [ ] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días +- [ ] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity +- [ ] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id +- [ ] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id +- [ ] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente +- [ ] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile +- [ ] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings +- [ ] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime` + +--- + +## Phase 12: Sessions Pages [PENDIENTE] +Spec: `.ralph/specs/phase-12-sessions-pages.md` + +- [ ] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section +- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable +- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs +- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly) +- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges +- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only +- [ ] 12.7: Progress bar estados explorados / maxStates +- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id) +- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed` + +--- + +## Phase 13: Findings Pages [PENDIENTE] +Spec: `.ralph/specs/phase-13-findings-pages.md` + +- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search +- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout +- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb +- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer) +- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI" +- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON" +- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed +- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul +- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view` + +--- + +## Phase 14: Settings Pages [PENDIENTE] +Spec: `.ralph/specs/phase-14-settings-pages.md` + +- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections +- [ ] 14.2: Section "Profile" — cambiar nombre, email, password +- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles +- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar +- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones +- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity +- [ ] 14.7: Section "Appearance" — tema dark/light, accent color +- [ ] 14.8: Section "License" — ver status licencia, input para activar key +- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages` + +--- + +## Phase 15: Reporting Module [PENDIENTE] +Spec: `.ralph/specs/phase-15-reporting.md` + +- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts` +- [ ] 15.2: Crear port: `IReportGenerator.ts` +- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión +- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo +- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF +- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download +- [ ] 15.7: Integrar con job queue: generación async +- [ ] 15.8: Migración Kysely: tabla reports +- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar +- [ ] 15.10: Tests: GenerateReportCommand con mock generator +- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation` + +--- + +## Phase 16: Integrations Module [PENDIENTE] +Spec: `.ralph/specs/phase-16-integrations.md` + +- [ ] 16.1: Instalar: `npm i @slack/web-api @octokit/rest` +- [ ] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity) +- [ ] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts` +- [ ] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding) +- [ ] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos) +- [ ] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link +- [ ] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps +- [ ] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots +- [ ] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas +- [ ] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks +- [ ] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries +- [ ] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook) +- [ ] 16.13: Tests: webhook dispatch + HMAC verification +- [ ] 16.14: Verificar build completo + commit: `fase(16): integrations module` + +--- + +## Phase 17: Licensing Module [PENDIENTE] +Spec: `.ralph/specs/phase-17-licensing.md` + +- [ ] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts` +- [ ] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements) +- [ ] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled +- [ ] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays +- [ ] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request +- [ ] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status +- [ ] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno) +- [ ] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.) +- [ ] 17.9: Frontend: License section en Settings +- [ ] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks +- [ ] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation` + +--- + +## Phase 18: CLI + CI/CD [PENDIENTE] +Spec: `.ralph/specs/phase-18-cli-cicd.md` + +- [ ] 18.1: Instalar: `npm i commander` +- [ ] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key +- [ ] 18.3: Comando `abe report` — genera report de una sesión por id +- [ ] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas +- [ ] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test +- [ ] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error +- [ ] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite +- [ ] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright) +- [ ] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo +- [ ] 18.10: Actualizar README.md con sección CLI +- [ ] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration` + +--- + +## Phase 19: Scheduling Module Refactor [PENDIENTE] + +- [ ] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure) +- [ ] 19.2: Crear Schedule aggregate con cron validation (Zod) +- [ ] 19.3: Integrar con job queue +- [ ] 19.4: Crear SchedulingController con CRUD + toggle +- [ ] 19.5: Frontend: Schedules section en Settings +- [ ] 19.6: Verificar build + commit: `fase(19): scheduling module refactor` + +--- + +## Phase 20: Visual Regression Refactor [PENDIENTE] + +- [ ] 20.1: Migrar visual regression existente → nueva estructura modular +- [ ] 20.2: Integrar con StorageProvider para screenshots +- [ ] 20.3: Refactorizar frontend /visual-review con shadcn/ui components +- [ ] 20.4: Verificar build + commit: `fase(20): visual regression refactor` + +--- + +## Phase 21: API Documentation [PENDIENTE] + +- [ ] 21.1: Instalar: `npm i @asteasolutions/zod-to-openapi @scalar/express-api-reference` +- [ ] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response) +- [ ] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas +- [ ] 21.4: Montar Scalar UI en GET /api-docs +- [ ] 21.5: Servir spec JSON en GET /api-docs/openapi.json +- [ ] 21.6: Verificar que todos los endpoints están documentados +- [ ] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar` + +--- + +## Phase 22: Docker Production [PENDIENTE] + +- [ ] 22.1: Refactorizar Dockerfile backend: multi-stage, node:20-alpine, tini como init, non-root user, HEALTHCHECK +- [ ] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx +- [ ] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file +- [ ] 22.4: Crear docker-compose.prod.yml +- [ ] 22.5: Crear .dockerignore optimizado +- [ ] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm +- [ ] 22.7: Verificar imagen final < 200MB +- [ ] 22.8: Verificar docker compose up funciona end-to-end +- [ ] 22.9: Commit: `fase(22): docker production setup` + +--- + +## Phase 23: Observability [PENDIENTE] + +- [ ] 23.1: Request correlation: requestId en CADA log entry via pino child logger +- [ ] 23.2: Structured error logging con contexto (userId, sessionId, etc.) +- [ ] 23.3: Liveness probe: GET /health/live +- [ ] 23.4: Readiness probe: GET /health/ready (DB + job queue check) +- [ ] 23.5: Startup probe: medir tiempo de arranque, loguear +- [ ] 23.6: Commit: `fase(23): observability and health probes` + +--- + +## Phase 24: Onboarding + First-Run [PENDIENTE] + +- [ ] 24.1: Detectar first-run en frontend (GET /api/auth/setup-required) +- [ ] 24.2: Wizard multi-step: paso 1 crear admin, paso 2 nombre org, paso 3 "Start your first exploration" con URL input +- [ ] 24.3: Empty states: ilustraciones/mensajes en tablas vacías ("No findings yet. Start an exploration!") +- [ ] 24.4: Commit: `fase(24): onboarding and first-run experience` + +--- + +## Phase 25: Polish + Quality [PENDIENTE] + +- [ ] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes +- [ ] 25.2: Loading skeletons en todas las pages (shadcn Skeleton) +- [ ] 25.3: Error boundaries en cada page +- [ ] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard) +- [ ] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack +- [ ] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing +- [ ] 25.7: CONTRIBUTING.md +- [ ] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado +- [ ] 25.9: Commit: `fase(25): polish and quality improvements` + +--- + +## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY] + +- [ ] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy +- [ ] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace) +- [ ] 26.3: Per-organization IdP configuration +- [ ] 26.4: LDAP/AD integration via passport-ldapauth +- [ ] 26.5: MFA (TOTP) support +- [ ] 26.6: Audit log completo (who did what, when) +- [ ] 26.7: Session management dashboard (ver/revocar sessions activas) +- [ ] 26.8: Feature-gated tras LICENSE enterprise +- [ ] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap` + +--- + +## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY] + +- [ ] 27.1: Data retention policies (auto-delete findings > X days) +- [ ] 27.2: Backup/restore CLI tool +- [ ] 27.3: White-labeling (CSS custom properties + logo upload) +- [ ] 27.4: PostgreSQL support validado end-to-end +- [ ] 27.5: Email notifications (nodemailer + templates) +- [ ] 27.6: Kubernetes Helm chart +- [ ] 27.7: Commit: `fase(27): advanced enterprise features` diff --git a/.ralph/progress.json b/.ralph/progress.json new file mode 100644 index 0000000..8f361f8 --- /dev/null +++ b/.ralph/progress.json @@ -0,0 +1 @@ +{"status": "completed", "timestamp": "2026-03-04 04:32:44"} diff --git a/.ralph/specs/legacy/ai-enrichment.md b/.ralph/specs/legacy/ai-enrichment.md new file mode 100644 index 0000000..e10a2fb --- /dev/null +++ b/.ralph/specs/legacy/ai-enrichment.md @@ -0,0 +1,130 @@ +# ABE — AI Bug Report Enrichment Specification + +## Concepto +Este es el diferenciador más importante de ABE frente a cualquier competidor. +Después de detectar una anomalía, ABE puede usar una LLM para enriquecer +el bug report con un análisis inteligente: causa probable, impacto, +sugerencia de fix, y prompt listo para usar con Claude/GPT. + +## IMPORTANTE: esto es una capa OPCIONAL sobre el core determinista. +El core engine nunca llama a LLMs. El enriquecimiento es post-procesado, +ejecutado solo si el usuario lo configura. + +## Qué genera la IA + +### 1. Root Cause Analysis +A partir del action trace, HTTP log, console errors y DOM snapshot, +la IA propone la causa más probable del bug. +Ejemplo: "The 500 error is likely caused by missing server-side validation +of the email field. The server crashes when receiving an empty string +where a valid email is expected." + +### 2. User Impact Assessment +La IA evalúa el impacto del bug en términos de negocio: +"This bug blocks users from completing registration. Any user who +submits an empty email will encounter an unhandled server error, +preventing account creation." + +### 3. Suggested Fix +La IA propone un fix concreto: +"Add server-side validation: check if email is present and valid +before processing. Return a 422 with a descriptive error message +instead of propagating the exception." + +### 4. AI-Ready Debug Prompt +Un prompt completo listo para copiar y pegar en Claude/ChatGPT: +``` +Bug Report Context: +- Type: HTTP 500 on form submission +- Steps to reproduce: [exact action trace] +- Error: [exact error message] +- Request: POST /api/register with body {"email": ""} +- Response: 500 Internal Server Error + +Please analyze this bug and provide: +1. Root cause +2. Code fix +3. Test case to prevent regression +``` + +## Implementación + +### Provider abstraction +```typescript +interface IAIProvider { + name: string; + enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise; +} + +interface IEnrichmentContext { + domSnapshot: string; + httpLog: IHttpResponse[]; + consoleErrors: string[]; + actionTrace: IAction[]; + pageTitle: string; + url: string; +} + +interface IAIEnrichment { + rootCause: string; + userImpact: string; + suggestedFix: string; + debugPrompt: string; + confidence: 'low' | 'medium' | 'high'; + generatedAt: number; + provider: string; + model: string; +} +``` + +### Providers implementados +- `ClaudeProvider` — usa Anthropic API (claude-3-5-haiku — rápido y barato) +- `OpenAIProvider` — usa OpenAI API (gpt-4o-mini) +- `OllamaProvider` — usa Ollama local (llama3.2 — sin API key, offline) + +### Cuándo se ejecuta +- Automático: si `aiEnrichment.autoEnrich: true`, se ejecuta tras cada anomalía high/critical +- Manual: botón "Enrich with AI" en AnomalyDetail page +- No bloquea: el bug report se guarda sin enriquecimiento, la IA lo añade async + +## Configuración en .env +``` +ABE_AI_PROVIDER=claude # claude | openai | ollama | none +ABE_AI_API_KEY=sk-ant-xxx # Anthropic key (si provider=claude) +ABE_OPENAI_API_KEY=sk-xxx # OpenAI key (si provider=openai) +ABE_OLLAMA_URL=http://localhost:11434 # (si provider=ollama) +ABE_AI_MODEL=claude-haiku-4-5 # modelo específico (opcional) +ABE_AI_AUTO_ENRICH=false # default false para no incurrir en costes +ABE_AI_MIN_SEVERITY=high # solo enriquecer high/critical automáticamente +``` + +## Modelo de datos — añadir a SQLite + +### Añadir columna a anomalies +```sql +ALTER TABLE anomalies ADD COLUMN ai_enrichment_json TEXT; +ALTER TABLE anomalies ADD COLUMN ai_enriched_at INTEGER; +``` + +## Frontend — AI panel en AnomalyDetail + +Si la anomalía tiene ai_enrichment_json, mostrar panel "AI Analysis" con: +- 🔍 Root Cause (texto con ícono) +- 👥 User Impact (texto con ícono) +- 🔧 Suggested Fix (bloque de código si contiene código) +- 📋 "Copy debug prompt" button (copia el debugPrompt al clipboard) +- Badge: "Analyzed by Claude" / "Analyzed by GPT-4o-mini" / "Analyzed by Llama 3.2" +- Timestamp de cuándo se generó + +Si no tiene enriquecimiento, mostrar botón "✨ Analyze with AI" que llama a: +POST /api/anomalies/:id/enrich + +## Endpoint nuevo + +### POST /api/anomalies/:anomalyId/enrich +Dispara el enriquecimiento de una anomalía concreta (async). +Response inmediata: { status: 'enriching' } +Cuando termina, emite WebSocket event: anomaly:enriched { anomalyId, enrichment } + +### GET /api/anomalies/:anomalyId — actualizado +Incluye ai_enrichment si está disponible. diff --git a/.ralph/specs/legacy/api-security.md b/.ralph/specs/legacy/api-security.md new file mode 100644 index 0000000..e222474 --- /dev/null +++ b/.ralph/specs/legacy/api-security.md @@ -0,0 +1,59 @@ +# ABE — API Security Specification + +## Authentication: API Key + +All API endpoints require an API key passed in the header: +`X-ABE-API-Key: ` + +If missing or invalid → 401 Unauthorized. + +## Configuration + +API key is set via environment variable: `ABE_API_KEY` +If not set, server logs a warning and runs without auth (dev mode only). + +## Implementation + +Create `src/server/middleware/auth.ts`: +```typescript +export function apiKeyAuth(req, res, next) { + const apiKey = process.env.ABE_API_KEY; + if (!apiKey) return next(); // dev mode: no auth + const provided = req.headers['x-abe-api-key']; + if (!provided || provided !== apiKey) { + return res.status(401).json({ error: 'Invalid or missing API key' }); + } + next(); +} +``` + +Apply this middleware to ALL routes EXCEPT: +- GET /health +- GET /ready + +## CORS + +Only allow requests from the frontend origin. +Configure via environment variable: `ABE_CORS_ORIGIN` (default: `http://localhost:5173`) + +## Rate Limiting + +Add `express-rate-limit`: +- Max 20 POST /api/sessions per hour per IP +- Max 200 requests per minute per IP for other endpoints + +## Environment Variables (full list for .env) +``` +ABE_API_KEY=change-me-in-production +ABE_CORS_ORIGIN=http://localhost:5173 +ABE_PORT=3001 +ABE_DB_PATH=./data/abe.db +ABE_REPORTS_DIR=./reports +ABE_LOGS_DIR=./logs +NODE_ENV=production +``` + +## docker-compose update + +Add .env file support and environment variables to docker-compose.yml. +Add a volumes entry for `data/` directory for SQLite persistence. diff --git a/.ralph/specs/legacy/api-server.md b/.ralph/specs/legacy/api-server.md new file mode 100644 index 0000000..17c1121 --- /dev/null +++ b/.ralph/specs/legacy/api-server.md @@ -0,0 +1,187 @@ +# ABE — API Server Specification + +## Arquitectura general +``` +React (puerto 5173) + ↕ HTTP REST + WebSocket +API Server Express (puerto 3001) + ↕ imports directos +ExplorationEngine (core) +``` + +El servidor vive en `src/server/` y es el único punto de entrada al motor desde el exterior. El frontend NUNCA importa código del core directamente. + +--- + +## Tecnología del servidor + +- Framework: Express.js +- WebSocket: socket.io (para streaming en tiempo real) +- Archivos: `src/server/index.ts` y `src/server/routes/` + +--- + +## REST Endpoints + +### POST /api/sessions +Lanza una nueva exploración. + +Request body: +```json +{ + "url": "http://localhost:3000", + "seed": 42, + "maxStates": 50 +} +``` + +Response: +```json +{ + "sessionId": "sess_abc123", + "status": "running", + "startedAt": "2025-01-15T10:00:00.000Z" +} +``` + +--- + +### GET /api/sessions +Lista todas las sesiones (activas e históricas). + +Response: +```json +[ + { + "sessionId": "sess_abc123", + "url": "http://localhost:3000", + "status": "running", + "startedAt": "2025-01-15T10:00:00.000Z", + "anomaliesFound": 3, + "statesVisited": 12 + } +] +``` + +--- + +### GET /api/sessions/:sessionId +Detalle de una sesión específica. + +Response: +```json +{ + "sessionId": "sess_abc123", + "url": "http://localhost:3000", + "status": "completed", + "startedAt": "2025-01-15T10:00:00.000Z", + "finishedAt": "2025-01-15T10:05:00.000Z", + "statesVisited": 12, + "anomaliesFound": 3, + "seed": 42 +} +``` + +--- + +### DELETE /api/sessions/:sessionId +Detiene una sesión activa. + +Response: +```json +{ "stopped": true } +``` + +--- + +### GET /api/anomalies +Lista todas las anomalías encontradas (todas las sesiones). + +Query params opcionales: `?sessionId=sess_abc123&severity=high` + +Response: +```json +[ + { + "id": "anom_a1b2c3", + "sessionId": "sess_abc123", + "type": "http_error", + "severity": "high", + "description": "Form returns HTTP 500 on empty email", + "timestamp": 1705312200000, + "screenshotUrl": "/api/anomalies/anom_a1b2c3/screenshot" + } +] +``` + +--- + +### GET /api/anomalies/:anomalyId +Detalle completo de una anomalía incluyendo pasos de reproducción. + +Response: el objeto IAnomaly completo serializado (definido en interfaces.md) + +--- + +### GET /api/anomalies/:anomalyId/screenshot +Devuelve la imagen PNG del screenshot de la anomalía. + +Response: imagen binaria con Content-Type: image/png + +--- + +### POST /api/anomalies/:anomalyId/replay +Lanza el replay de una anomalía específica. + +Response: +```json +{ + "replayId": "replay_xyz", + "status": "running" +} +``` + +--- + +## WebSocket Events (socket.io) + +El cliente se conecta a `ws://localhost:3001` y escucha estos eventos: + +### Eventos que emite el SERVIDOR → cliente + +`session:started` +```json +{ "sessionId": "sess_abc123", "url": "http://localhost:3000" } +``` + +`state:discovered` +```json +{ "sessionId": "sess_abc123", "stateId": "s_xyz", "url": "/register", "title": "Register" } +``` + +`action:executed` +```json +{ "sessionId": "sess_abc123", "actionType": "click", "selector": "button#submit", "timestamp": 1705312197000 } +``` + +`anomaly:detected` +```json +{ "sessionId": "sess_abc123", "anomalyId": "anom_a1b2c3", "type": "http_error", "severity": "high", "description": "..." } +``` + +`session:completed` +```json +{ "sessionId": "sess_abc123", "statesVisited": 12, "anomaliesFound": 3 } +``` + +`session:error` +```json +{ "sessionId": "sess_abc123", "error": "Target URL unreachable" } +``` + +### Eventos que emite el CLIENTE → servidor + +`session:stop` +```json +{ "sessionId": "sess_abc123" } +``` diff --git a/.ralph/specs/legacy/cli-cicd.md b/.ralph/specs/legacy/cli-cicd.md new file mode 100644 index 0000000..6faa11e --- /dev/null +++ b/.ralph/specs/legacy/cli-cicd.md @@ -0,0 +1,118 @@ +# ABE — CLI & CI/CD Integration Specification + +## CLI Entry Point + +File: `src/cli.ts` +Script in package.json: `"abe": "ts-node src/cli.ts"` +Global after install: `npx abe` or `abe` if installed globally. + +## CLI Usage +```bash +# Basic run +abe run --url http://localhost:3000 + +# With auth +abe run --url http://app.com \ + --auth-type login_flow \ + --login-url http://app.com/login \ + --username test@app.com \ + --password secret + +# With scope limits +abe run --url http://app.com \ + --max-states 30 \ + --max-depth 4 \ + --allowed-domains app.com + +# CI mode: exit 1 if any anomaly found +abe run --url http://localhost:3000 --fail-on-anomaly + +# CI mode: exit 1 only on high/critical anomalies +abe run --url http://localhost:3000 --fail-on-severity high + +# Output formats +abe run --url http://localhost:3000 --output json # prints JSON summary to stdout +abe run --url http://localhost:3000 --output junit # generates junit.xml for CI + +# Connect to a running ABE server instead of running inline +abe run --url http://localhost:3000 --server http://abe-server:3001 --api-key mykey +``` + +## Exit Codes + +- 0 → exploration complete, no anomalies (or no anomalies above threshold) +- 1 → anomalies found above threshold +- 2 → exploration failed (target unreachable, auth failed, etc.) + +## stdout JSON output (--output json) +```json +{ + "sessionId": "sess_abc123", + "url": "http://localhost:3000", + "duration_ms": 45000, + "states_visited": 12, + "anomalies": [ + { + "id": "anom_xyz", + "type": "http_error", + "severity": "high", + "description": "Form returns 500 on empty email", + "report_path": "reports/anom_xyz/report.json" + } + ], + "exit_code": 1 +} +``` + +## JUnit XML output (--output junit) + +Generates `abe-results.xml` compatible with Jenkins, GitHub Actions, GitLab CI: +- Each anomaly = one failing test case +- Each explored state = one passing test case + +## GitHub Actions Example Workflow + +Create file: `.github/workflows/abe-example.yml` in the repo: +```yaml +name: ABE Exploratory Testing + +on: + push: + branches: [main] + pull_request: + +jobs: + explore: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Start application + run: docker-compose up -d app + # assumes the project has a docker-compose with the target app + + - name: Wait for app + run: npx wait-on http://localhost:3000 --timeout 30000 + + - name: Run ABE + run: | + npm install -g abe-explorer # or: npx abe + abe run \ + --url http://localhost:3000 \ + --max-states 30 \ + --fail-on-severity high \ + --output junit + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: abe-reports + path: reports/ + + - name: Publish test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: abe-results.xml +``` diff --git a/.ralph/specs/legacy/database.md b/.ralph/specs/legacy/database.md new file mode 100644 index 0000000..870f52a --- /dev/null +++ b/.ralph/specs/legacy/database.md @@ -0,0 +1,99 @@ +# ABE — Database Specification (SQLite) + +## Rationale +File-based storage loses all data on container restart. +SQLite requires zero extra services and is perfect for self-hosted deployment. + +## Library +Use `better-sqlite3` (synchronous, faster than async alternatives for this use case). + +## Location +Database file: `data/abe.db` (persisted via Docker volume) + +## Schema + +### Table: sessions +```sql +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running', + seed INTEGER NOT NULL, + max_states INTEGER NOT NULL DEFAULT 50, + states_visited INTEGER NOT NULL DEFAULT 0, + anomalies_found INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + finished_at INTEGER, + config_json TEXT NOT NULL DEFAULT '{}' +); +``` + +### Table: states +```sql +CREATE TABLE IF NOT EXISTS states ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + url TEXT NOT NULL, + title TEXT NOT NULL, + dom_snapshot_path TEXT, + visit_count INTEGER NOT NULL DEFAULT 0, + discovered_at INTEGER NOT NULL +); +``` + +### Table: actions +```sql +CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + state_id TEXT NOT NULL REFERENCES states(id), + type TEXT NOT NULL, + selector TEXT, + value TEXT, + url TEXT, + seed INTEGER NOT NULL, + executed_at INTEGER NOT NULL, + sequence_order INTEGER NOT NULL +); +``` + +### Table: anomalies +```sql +CREATE TABLE IF NOT EXISTS anomalies ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + type TEXT NOT NULL, + severity TEXT NOT NULL, + description TEXT NOT NULL, + action_trace_json TEXT NOT NULL, + evidence_json TEXT NOT NULL, + screenshot_path TEXT, + dom_snapshot_path TEXT, + detected_at INTEGER NOT NULL +); +``` + +### Table: notifications +```sql +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + anomaly_id TEXT NOT NULL REFERENCES anomalies(id), + channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + sent_at INTEGER, + error TEXT +); +``` + +## Repository Pattern + +Create `src/db/` with: +- `src/db/connection.ts` — singleton SQLite connection, runs migrations on startup +- `src/db/SessionRepository.ts` — CRUD for sessions +- `src/db/AnomalyRepository.ts` — CRUD for anomalies, includes filter by session/severity +- `src/db/migrations.ts` — runs all CREATE TABLE IF NOT EXISTS on startup + +## Rules +- All DB operations are synchronous (better-sqlite3 is sync) +- Repositories are injected into the API server, never imported directly by core engine +- The engine emits events → the API server listens and persists to DB diff --git a/.ralph/specs/legacy/docker.md b/.ralph/specs/legacy/docker.md new file mode 100644 index 0000000..8f61cc8 --- /dev/null +++ b/.ralph/specs/legacy/docker.md @@ -0,0 +1,102 @@ +# ABE — Docker Specification + +## Objetivo +Permitir arrancar todo el proyecto (backend + frontend) con un solo comando: +docker-compose up --build + +## Backend Dockerfile (raíz del proyecto) +```dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build +EXPOSE 3001 +CMD ["node", "dist/server/index.js"] +``` + +## Frontend Dockerfile (frontend/Dockerfile) + +Usa build multistage: primero compila con Node, luego sirve con nginx. +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +## nginx.conf (frontend/nginx.conf) + +Necesario para que React Router funcione correctamente (todas las rutas apuntan a index.html): +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:3001; + } + + location /socket.io { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +## docker-compose.yml (raíz) +```yaml +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "3001:3001" + environment: + - NODE_ENV=production + - PORT=3001 + volumes: + - ./reports:/app/reports + - ./logs:/app/logs + networks: + - abe-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "5173:80" + depends_on: + - backend + networks: + - abe-network + +networks: + abe-network: + driver: bridge +``` + +## Notas importantes +- El frontend en producción (nginx) hace proxy de /api y /socket.io al backend +- Los volúmenes reports/ y logs/ persisten datos entre reinicios del contenedor +- El frontend se accede en http://localhost:5173 +- El backend se accede en http://localhost:3001 diff --git a/.ralph/specs/legacy/exploration-config.md b/.ralph/specs/legacy/exploration-config.md new file mode 100644 index 0000000..e46685f --- /dev/null +++ b/.ralph/specs/legacy/exploration-config.md @@ -0,0 +1,84 @@ +# ABE — Exploration Scope & Target Authentication Specification + +## Exploration Config Object + +This config is passed via POST /api/sessions and stored in sessions.config_json. +```typescript +interface ExplorationConfig { + // Scope + allowedDomains: string[]; // e.g. ["localhost", "myapp.com"] — never follow external links + maxStates: number; // default: 50 — stop after this many unique states + maxDepth: number; // default: 5 — max click depth from start URL + actionDelayMs: number; // default: 500 — wait between actions (politeness) + sessionTimeoutMs: number; // default: 300000 (5 min) — hard stop + + // Exclusions + excludedPaths: string[]; // e.g. ["/logout", "/admin"] — never navigate here + excludedSelectors: string[]; // e.g. ["button.delete", "a[href*='delete']"] + + // Target authentication + auth: AuthConfig | null; + + // Fuzzing + fuzzingEnabled: boolean; // default: true + fuzzingIntensity: 'low' | 'medium' | 'high'; // default: 'medium' +} + +type AuthConfig = + | { type: 'cookies'; cookies: Array<{ name: string; value: string; domain: string }> } + | { type: 'headers'; headers: Record } + | { type: 'login_flow'; loginUrl: string; usernameSelector: string; passwordSelector: string; submitSelector: string; username: string; password: string } +``` + +## Scope Rules (enforced in PlaywrightAgent) + +1. Before navigating to any URL, check if hostname is in allowedDomains. If not, skip. +2. Before executing any action, check if current path matches excludedPaths. If yes, skip. +3. Before clicking any element, check if it matches excludedSelectors. If yes, skip. +4. Stop exploration when statesVisited >= maxStates OR depth >= maxDepth OR elapsed > sessionTimeoutMs. + +## Authentication Flow + +### type: 'cookies' +Inject cookies before the first navigation using playwright context.addCookies(). + +### type: 'headers' +Set extra HTTP headers on the browser context using context.setExtraHTTPHeaders(). + +### type: 'login_flow' +Before starting exploration: +1. Navigate to loginUrl +2. Fill usernameSelector with username +3. Fill passwordSelector with password +4. Click submitSelector +5. Wait for navigation to complete +6. Verify we are no longer on loginUrl (if still there, login failed → abort session with error) +7. Proceed with exploration from startUrl + +## Updated POST /api/sessions request body +```json +{ + "url": "http://localhost:3000", + "seed": 42, + "config": { + "allowedDomains": ["localhost"], + "maxStates": 50, + "maxDepth": 5, + "actionDelayMs": 500, + "sessionTimeoutMs": 300000, + "excludedPaths": ["/logout"], + "excludedSelectors": [], + "auth": { + "type": "login_flow", + "loginUrl": "http://localhost:3000/login", + "usernameSelector": "input[name='email']", + "passwordSelector": "input[name='password']", + "submitSelector": "button[type='submit']", + "username": "test@example.com", + "password": "password123" + }, + "fuzzingEnabled": true, + "fuzzingIntensity": "medium" + } +} +``` diff --git a/.ralph/specs/legacy/frontend-v2.md b/.ralph/specs/legacy/frontend-v2.md new file mode 100644 index 0000000..b33cf8a --- /dev/null +++ b/.ralph/specs/legacy/frontend-v2.md @@ -0,0 +1,72 @@ +# ABE — Frontend v2 Specification + +## New pages and components to add + +### New Page: Settings (ruta: /settings) + +Sections: +1. API Key — show current key, button to copy +2. Notifications — form to set Slack webhook URL and min severity (calls PATCH /api/config) +3. Default Exploration Config — form with default values for maxStates, maxDepth, delay, excluded paths +4. About — version, links to docs + +### Updated: NewSessionForm + +Add fields: +- Allowed Domains (chips input, default: hostname of URL) +- Max States (number, default 50) +- Max Depth (number, default 5) +- Action Delay ms (number, default 500) +- Excluded Paths (chips input) +- Auth Type (select: none / cookies / headers / login_flow) + - If login_flow: show loginUrl, usernameSelector, passwordSelector, submitSelector, username, password + - If cookies: textarea for JSON cookie array + - If headers: key-value pairs input +- Fuzzing enabled (toggle) +- Fuzzing intensity (select: low / medium / high) + +### Updated: Dashboard + +Add stats bar at the top with 4 numbers: +- Total sessions +- Total anomalies found +- Critical/High anomalies (highlighted in red) +- Sessions running now + +### Updated: AnomalyList + +Add filter bar: +- Filter by severity (multi-select: low, medium, high, critical) +- Filter by type (multi-select: http_error, js_exception, etc.) +- Filter by session (dropdown) +- Search by description (text input) +- Sort by: newest first / severity desc + +### Updated: AnomalyDetail + +Add: +- Download button → downloads report.json +- Download MD button → downloads report.md +- Copy replay command button → copies `abe replay --anomaly-id anom_xxx` to clipboard + +### New Component: SeverityBadge + +Reusable badge component used everywhere: +- critical → red bg, white text +- high → orange bg, white text +- medium → yellow bg, dark text +- low → blue bg, white text + +### New API endpoints needed (add to api-server spec) + +PATCH /api/config +- Updates server config (slack webhook, min severity, defaults) +- Body: Partial +- Returns: updated ServerConfig + +GET /api/config +- Returns current server config (without API key value) + +GET /api/stats +- Returns: { totalSessions, totalAnomalies, criticalHighCount, runningSessions } +- Used by dashboard stats bar diff --git a/.ralph/specs/legacy/frontend.md b/.ralph/specs/legacy/frontend.md new file mode 100644 index 0000000..512916b --- /dev/null +++ b/.ralph/specs/legacy/frontend.md @@ -0,0 +1,99 @@ +# ABE — Frontend Specification + +## Tecnología +- React 18 + TypeScript +- Vite (bundler, más simple que webpack) +- TailwindCSS (estilos sin escribir CSS manual) +- socket.io-client (WebSocket) +- React Router v6 (navegación entre páginas) + +## Ubicación +El frontend vive en `frontend/` en la raíz del proyecto, completamente separado de `src/`. +``` +frontend/ +├── src/ +│ ├── pages/ +│ │ ├── Dashboard.tsx ← página principal +│ │ ├── SessionDetail.tsx ← detalle de una sesión en vivo +│ │ └── AnomalyDetail.tsx ← detalle de un bug report +│ ├── components/ +│ │ ├── NewSessionForm.tsx ← formulario para lanzar exploración +│ │ ├── SessionList.tsx ← lista de sesiones +│ │ ├── AnomalyList.tsx ← lista de anomalías +│ │ ├── LiveFeed.tsx ← stream en tiempo real de eventos +│ │ └── AnomalyCard.tsx ← tarjeta de una anomalía +│ ├── hooks/ +│ │ ├── useSocket.ts ← conexión WebSocket reutilizable +│ │ └── useApi.ts ← fetch helper para la API REST +│ ├── types.ts ← tipos TypeScript del frontend (espejo de interfaces.ts) +│ ├── App.tsx ← router principal +│ └── main.tsx ← entry point +├── index.html +├── vite.config.ts +├── tailwind.config.ts +└── package.json +``` + +--- + +## Página 1 — Dashboard (ruta: `/`) + +Contiene: +- Botón "New Exploration" que abre el formulario +- `NewSessionForm`: campos URL y Seed, botón Start +- `SessionList`: tabla con todas las sesiones (estado, URL, anomalías encontradas, fecha) +- `AnomalyList`: lista de las últimas anomalías de todas las sesiones + +--- + +## Página 2 — Session Detail (ruta: `/sessions/:sessionId`) + +Contiene: +- Header con URL explorada, estado (running/completed), seed +- Botón "Stop" si la sesión está activa +- `LiveFeed`: lista en tiempo real de eventos WebSocket + - Cada evento muestra icono + texto + timestamp + - Scroll automático al último evento + - Colores: verde para state:discovered, amarillo para action:executed, rojo para anomaly:detected +- `AnomalyList`: anomalías encontradas en esta sesión (se actualiza en tiempo real) + +--- + +## Página 3 — Anomaly Detail (ruta: `/anomalies/:anomalyId`) + +Contiene: +- Header con tipo, severidad (badge de color), descripción +- Sección "Reproduction Steps": lista numerada de acciones +- Sección "Evidence": + - Screenshot a tamaño completo (imagen) + - Botón para ver DOM snapshot (abre en nueva pestaña) +- Sección "HTTP Log": tabla con requests (URL, método, status, duración) +- Sección "Raw Errors": bloque de código con los errores textuales +- Botón "Run Replay": llama a POST /api/anomalies/:id/replay y muestra estado + +--- + +## Colores de severidad (badges) +- critical → rojo (#ef4444) +- high → naranja (#f97316) +- medium → amarillo (#eab308) +- low → azul (#3b82f6) + +--- + +## Conexión con la API + +Todas las llamadas van a `http://localhost:3001`. +En `vite.config.ts` configurar proxy para `/api` y `/socket.io` apuntando a `localhost:3001`. +```typescript +// vite.config.ts +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:3001', + '/socket.io': { target: 'http://localhost:3001', ws: true } + } + } +}) +``` diff --git a/.ralph/specs/legacy/fuzzing.md b/.ralph/specs/legacy/fuzzing.md new file mode 100644 index 0000000..72ed73a --- /dev/null +++ b/.ralph/specs/legacy/fuzzing.md @@ -0,0 +1,94 @@ +# ABE — Fuzzing / Disruption Module Specification + +## Purpose +This is ABE's core differentiator. Instead of only clicking valid elements, +ABE injects abnormal inputs into forms to provoke unexpected server behavior. + +## Architecture +``` +src/plugins/fuzzers/ +├── FuzzingEngine.ts ← orchestrator, decides when and how to fuzz +├── strategies/ +│ ├── EmptyValueStrategy.ts +│ ├── OversizedStringStrategy.ts +│ ├── SpecialCharsStrategy.ts +│ ├── TypeMismatchStrategy.ts +│ └── BoundaryValueStrategy.ts +└── InputTypeDetector.ts ← detects field type from DOM attributes +``` + +## InputTypeDetector + +Detects field type from: input[type], input[name], input[placeholder], label text, aria-label. +```typescript +type DetectedInputType = + | 'email' | 'password' | 'number' | 'date' | 'phone' + | 'url' | 'search' | 'text' | 'textarea' | 'select' | 'file' +``` + +## Fuzzing Strategies + +### EmptyValueStrategy +Submits forms with all fields empty. Catches missing server-side validation. +Applies to: all input types. +Values: `""`, `" "` (space only), `"\t"` (tab). + +### OversizedStringStrategy +Submits strings far beyond expected length. Catches buffer issues and UI overflow. +Applies to: text, email, password, textarea. +Values by intensity: +- low: 256 chars +- medium: 1024 chars +- high: 10000 chars + unicode chars + +### SpecialCharsStrategy +Injects characters that break SQL, HTML, and shell contexts. +Applies to: text, email, search, textarea. +Values: +``` +' OR 1=1 -- + +../../etc/passwd +${7*7} +\x00\x01\x02 +``` + +### TypeMismatchStrategy +Submits wrong data types for the field. +- email field → "not-an-email", "12345", "@@@" +- number field → "abc", "-999999", "9.9.9", "NaN" +- date field → "yesterday", "32/13/2025", "0000-00-00" +- url field → "javascript:alert(1)", "not a url" +- phone field → "000", "++++", "abcdefghij" + +### BoundaryValueStrategy +Tests values at the edges of expected ranges. +- number field → 0, -1, 2147483647, 2147483648, -2147483648 +- date field → "1900-01-01", "2099-12-31", "1970-01-01" + +## Fuzzing Execution Flow +``` +For each form discovered in state: + 1. InputTypeDetector analyzes each field + 2. FuzzingEngine selects strategies based on fuzzingIntensity: + - low: EmptyValue + TypeMismatch only + - medium: + OversizedString + BoundaryValue + - high: + SpecialChars + 3. For each strategy, fill all fields with fuzz values + 4. Submit the form + 5. Observe response via AnomalyDetector + 6. Record results +``` + +## AnomalyDetector additions for fuzzing + +Add these new anomaly types: +- `validation_bypass` — server accepted clearly invalid input (e.g. submitted empty required email, got 200) +- `server_error_on_fuzz` — server returned 500 on a fuzzed input +- `xss_reflection` — fuzzed script tag appears in response body + +## Integration point + +FuzzingEngine is called from ExplorationEngine AFTER normal action discovery, +only when `config.fuzzingEnabled === true`. +It is passed as an optional plugin, so the core engine doesn't depend on it directly. diff --git a/.ralph/specs/legacy/interfaces.md b/.ralph/specs/legacy/interfaces.md new file mode 100644 index 0000000..8b8e6f7 --- /dev/null +++ b/.ralph/specs/legacy/interfaces.md @@ -0,0 +1,164 @@ +# ABE — Core Interfaces Specification + +## Regla fundamental +`src/core/` solo puede importar desde este documento. +`src/plugins/` implementa estas interfaces, nunca al revés. + +--- + +## IState + +Representa un estado único de la aplicación explorada. +```typescript +interface IState { + id: string; // hash SHA1 del snapshot DOM + URL + url: string; // URL completa en este estado + title: string; // document.title + timestamp: number; // Date.now() cuando se capturó + domSnapshot: string; // outerHTML del body serializado + visitCount: number; // cuántas veces se ha visitado este estado +} +``` + +--- + +## IAction + +Representa una acción que el agente puede ejecutar. +```typescript +interface IAction { + id: string; // uuid v4 generado al crear la acción + type: 'click' | 'fill' | 'navigate' | 'select' | 'submit'; + selector?: string; // CSS selector del elemento (si aplica) + value?: string; // valor a introducir (para fill/select) + url?: string; // destino (solo para navigate) + timestamp: number; // cuando se ejecutó + seed: number; // semilla usada para selección aleatoria + stateId: string; // ID del estado desde el que se ejecutó +} +``` + +--- + +## IObservation + +Lo que el agente observa DESPUÉS de ejecutar una acción. +```typescript +interface IObservation { + id: string; // uuid v4 + actionId: string; // acción que provocó esta observación + newStateId: string; // ID del nuevo estado resultante + httpResponses: IHttpResponse[]; // todas las requests durante la acción + consoleErrors: string[]; // mensajes de console.error capturados + jsExceptions: string[]; // excepciones JS no capturadas + timestamp: number; +} + +interface IHttpResponse { + url: string; + status: number; + method: string; + durationMs: number; +} +``` + +--- + +## IAnomaly + +Una desviación detectada del comportamiento esperado. +```typescript +interface IAnomaly { + id: string; // uuid v4 + type: AnomalyType; + severity: 'low' | 'medium' | 'high' | 'critical'; + observationId: string; // observación que la provocó + actionTrace: IAction[]; // secuencia exacta de acciones que llevaron aquí + description: string; // texto legible explicando qué pasó + evidence: IAnomalyEvidence; + timestamp: number; +} + +type AnomalyType = + | 'http_error' // respuesta HTTP 4xx o 5xx + | 'js_exception' // excepción JavaScript no capturada + | 'console_error' // console.error detectado + | 'navigation_fail' // navegación no completada + | 'element_missing' // elemento esperado desaparece + | 'timeout'; // acción excede tiempo límite + +interface IAnomalyEvidence { + screenshotPath?: string; // ruta relativa al screenshot + domSnapshotPath?: string; // ruta relativa al DOM serializado + httpLog?: IHttpResponse[]; // requests relevantes + rawErrors?: string[]; // errores textuales originales +} +``` + +--- + +## IInteractionAgent (plugin interface) + +Lo que cualquier agente de interacción debe implementar. +```typescript +interface IInteractionAgent { + launch(url: string): Promise; + close(): Promise; + discoverActions(state: IState): Promise; + executeAction(action: IAction): Promise; + captureState(): Promise; +} +``` + +--- + +## ICollector (plugin interface) + +Lo que cualquier colector de contexto debe implementar. +```typescript +interface ICollector { + name: string; + collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise; +} +``` + +--- + +## IReproducer + +Genera un script de replay a partir de una traza de acciones. +```typescript +interface IReproducer { + serialize(trace: IAction[]): string; // JSON serializado + deserialize(raw: string): IAction[]; // reconstruye la traza + generateScript(trace: IAction[]): string; // script Playwright ejecutable +} +``` + +--- + +## IExporter (plugin interface) + +Transforma una anomalía en un reporte consumible. +```typescript +interface IExporter { + format: 'markdown' | 'json'; + export(anomaly: IAnomaly, outputDir: string): Promise; // retorna la ruta del archivo generado +} +``` + +--- + +## StateGraph + +No es una interfaz pero su contrato debe ser explícito. +```typescript +class StateGraph { + addState(state: IState): void; + hasState(stateId: string): boolean; + recordTransition(fromId: string, action: IAction, toId: string): void; + getUnvisited(): IState[]; // estados con visitCount === 0 + getNextToExplore(): IState | null; // heurística BFS por defecto + toJSON(): object; // serializable para logs +} +``` diff --git a/.ralph/specs/legacy/multi-browser-accessibility.md b/.ralph/specs/legacy/multi-browser-accessibility.md new file mode 100644 index 0000000..c336b43 --- /dev/null +++ b/.ralph/specs/legacy/multi-browser-accessibility.md @@ -0,0 +1,119 @@ +# ABE — Multi-Browser, Mobile Emulation & Accessibility Specification + +## Multi-browser testing + +### Browsers soportados (via Playwright) +- chromium (Chrome/Edge) — siempre disponible +- firefox — opcional +- webkit (Safari) — opcional + +### Configuración en ExplorationConfig +```typescript +browsers: Array<'chromium' | 'firefox' | 'webkit'>; // default: ['chromium'] +``` + +### Comportamiento +Cuando se especifican múltiples browsers: +- ABE ejecuta la misma exploración en paralelo en cada browser +- Cada browser crea su propia sub-sesión con el mismo seed +- Los resultados se agrupan bajo la misma sesión padre +- Las anomalías incluyen qué browser las detectó +- Anomalías que aparecen en TODOS los browsers → severity += 1 level +- Anomalías que aparecen solo en un browser → añadir tag "browser-specific: webkit" + +### Añadir a IAnomaly +```typescript +browser: 'chromium' | 'firefox' | 'webkit'; +browserVersion: string; +``` + +--- + +## Mobile Viewport Emulation + +### Devices predefinidos (usar Playwright devices) +```typescript +type MobileDevice = + | 'iPhone 14' + | 'iPhone 14 Pro Max' + | 'Pixel 7' + | 'Galaxy S23' + | 'iPad Pro' + | 'none' // desktop (default) +``` + +### En ExplorationConfig +```typescript +mobileDevice: MobileDevice; // default: 'none' +viewport: { width: number; height: number } | null; // override manual +``` + +### Implementación en PlaywrightAgent +```typescript +// Si mobileDevice !== 'none': +const device = playwright.devices[config.mobileDevice]; +const context = await browser.newContext({ ...device }); +``` + +### Anomalías específicas de mobile +Añadir tipo: `mobile_layout_issue` — detectado cuando: +- Un elemento clickable tiene menos de 44x44px (WCAG touch target) +- Hay scroll horizontal inesperado (viewport overflow) +- Un elemento está fuera del viewport en mobile + +--- + +## Accessibility Testing (axe-core) + +### Librería +Usar `@axe-core/playwright` (integración oficial axe + Playwright). + +### Cuándo ejecutar +Después de cada acción que cambia el estado (navigation + click que resulta en nuevo estado). +NO ejecutar en cada acción fill (demasiado frecuente). + +### Implementación +```typescript +import { checkA11y } from 'axe-playwright'; + +// En PlaywrightAgent, después de captureState(): +async function runAccessibilityCheck(page: Page): Promise { + const results = await checkA11y(page, undefined, { + detailedReport: true, + detailedReportOptions: { html: true }, + }); + return results.violations.map(v => ({ + id: v.id, + impact: v.impact, // 'minor' | 'moderate' | 'serious' | 'critical' + description: v.description, + helpUrl: v.helpUrl, + nodes: v.nodes.length, + selector: v.nodes[0]?.target?.join(', '), + })); +} +``` + +### Nuevo tipo de anomalía +- type: `accessibility_violation` +- severity mapping desde axe impact: + - minor → low + - moderate → medium + - serious → high + - critical → critical +- description: "[axe] {violation.description}" +- evidence: { helpUrl, affectedNodes, wcagCriteria } + +### En ExplorationConfig +```typescript +accessibility: { + enabled: boolean; // default: true + minImpact: 'minor' | 'moderate' | 'serious' | 'critical'; // default: 'serious' + wcagLevel: 'A' | 'AA' | 'AAA'; // default: 'AA' +} +``` + +### En el bug report +Añadir sección "Accessibility Violations" en report.md con: +- Lista de violaciones con impact badge +- Link a la documentación de cada regla (helpUrl de axe) +- Selector CSS del elemento afectado diff --git a/.ralph/specs/legacy/network-chaos.md b/.ralph/specs/legacy/network-chaos.md new file mode 100644 index 0000000..869838c --- /dev/null +++ b/.ralph/specs/legacy/network-chaos.md @@ -0,0 +1,88 @@ +# ABE — Network Chaos Specification + +## Concepto +Inspirado en Gremlin y LitmusChaos, pero aplicado a nivel de browser. +ABE puede simular condiciones de red adversas durante la exploración +para descubrir cómo se comporta el app en redes lentas, intermitentes, +o con servicios externos fallando. + +## Esto es diferente al fuzzing de inputs: +- Fuzzing: inputs inválidos en formularios +- Network chaos: condiciones de red adversas (latencia, pérdida de paquetes, timeout) + +## Implementación via Playwright CDP + +Playwright expone Chrome DevTools Protocol (CDP) que permite controlar la red: +```typescript +// En PlaywrightAgent +async function applyNetworkCondition(condition: NetworkCondition): Promise { + const client = await this.page.context().newCDPSession(this.page); + await client.send('Network.emulateNetworkConditions', { + offline: condition.offline, + downloadThroughput: condition.downloadKbps * 1024 / 8, + uploadThroughput: condition.uploadKbps * 1024 / 8, + latency: condition.latencyMs, + }); +} +``` + +## Perfiles de red predefinidos +```typescript +const NETWORK_PROFILES = { + 'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false }, + 'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false }, + '2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false }, + 'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true }, + 'none': null // sin limitación (default) +} +``` + +## API request interception (simular servicios caídos) +```typescript +// Simular que un endpoint específico falla con 503 +await page.route('**/api/payment**', route => { + route.fulfill({ status: 503, body: 'Service Unavailable' }); +}); + +// Simular latencia en un endpoint específico +await page.route('**/api/search**', async route => { + await new Promise(r => setTimeout(r, 3000)); // 3s delay + route.continue(); +}); +``` + +## Configuración en ExplorationConfig +```typescript +networkChaos: { + enabled: boolean; // default: false + profile: keyof typeof NETWORK_PROFILES; // default: 'none' + blockedEndpoints: string[]; // glob patterns — responden 503 + slowEndpoints: Array<{ + pattern: string; // glob + delayMs: number; + }>; +} +``` + +## Anomalías específicas de network chaos + +Añadir tipos al AnomalyDetector: + +- `offline_handling_missing` — app muestra pantalla en blanco o error no controlado cuando está offline +- `slow_network_no_feedback` — con slow-3g, la app no muestra loading indicator (detectado si CLS=0 pero LCP>5000ms y no hay elemento con rol 'progressbar' o 'status') +- `external_service_crash` — cuando un endpoint bloqueado causa error 500 en el frontend + +## Integración con el flujo de exploración + +NetworkChaos se aplica de forma secuencial, no simultánea: +1. Primera pasada: exploración normal (baseline) +2. Segunda pasada (si networkChaos.enabled): misma seed, con perfil de red aplicado +3. Comparar resultados: nuevas anomalías que aparecen solo en la segunda pasada son network-related + +## Frontend — Network Chaos Config + +En NewSessionForm, añadir sección collapsible "Network Chaos": +- Toggle "Enable network chaos" +- Select perfil: Fast 3G / Slow 3G / 2G / Offline +- Textarea "Blocked endpoints" (uno por línea, glob patterns) +- Lista "Slow endpoints" con campo pattern + delay ms diff --git a/.ralph/specs/legacy/notifications.md b/.ralph/specs/legacy/notifications.md new file mode 100644 index 0000000..4594753 --- /dev/null +++ b/.ralph/specs/legacy/notifications.md @@ -0,0 +1,64 @@ +# ABE — Notifications Specification + +## Purpose +When ABE finds an anomaly autonomously, notify the team immediately. + +## Supported Channels + +### 1. Slack Webhook +Environment variable: `ABE_SLACK_WEBHOOK_URL` + +Payload sent to Slack on anomaly:detected: +```json +{ + "text": "🐛 ABE found a bug!", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*ABE Bug Report*\n*Severity:* 🔴 HIGH\n*Type:* http_error\n*Description:* Form returns HTTP 500 on empty email\n*Session:* sess_abc123\n*Target:* http://localhost:3000" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { "type": "plain_text", "text": "View Report" }, + "url": "http://localhost:5173/anomalies/anom_abc123" + } + ] + } + ] +} +``` + +Only send for severity: high or critical (configurable via `ABE_NOTIFY_MIN_SEVERITY`). + +### 2. Generic Webhook +Environment variable: `ABE_WEBHOOK_URL` + +POST request with the full IAnomaly object as JSON body. +Includes header: `X-ABE-Event: anomaly.detected` + +## Implementation + +Create `src/server/notifications/`: +- `NotificationService.ts` — main service, called after anomaly is persisted to DB +- `SlackNotifier.ts` — implements Slack webhook +- `WebhookNotifier.ts` — implements generic webhook + +NotificationService.notify(anomaly) is called from the API server +after every anomaly:detected event from the engine. + +## Configuration (environment variables) +``` +ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz +ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe +ABE_NOTIFY_MIN_SEVERITY=high # low | medium | high | critical +``` + +## Notification record +Every notification attempt (success or failure) is saved to the notifications table in SQLite. +Failed notifications are retried once after 60 seconds. diff --git a/.ralph/specs/legacy/output-format.md b/.ralph/specs/legacy/output-format.md new file mode 100644 index 0000000..c21bdc3 --- /dev/null +++ b/.ralph/specs/legacy/output-format.md @@ -0,0 +1,130 @@ +# ABE — Output Format Specification + +Cada anomalía genera DOS archivos en `reports/{anomaly-id}/`: + +--- + +## 1. report.json — Para consumo por AI y tooling +```json +{ + "version": "1.0", + "generated_at": "2025-01-15T10:30:00.000Z", + "environment": { + "target_url": "http://localhost:3000", + "abe_version": "0.1.0", + "os": "linux", + "node_version": "20.x" + }, + "anomaly": { + "id": "anom_a1b2c3d4", + "type": "http_error", + "severity": "high", + "description": "Form submission returns HTTP 500 on empty email field", + "timestamp": 1705312200000 + }, + "reproduction": { + "seed": 42, + "steps": [ + { + "step": 1, + "action_type": "navigate", + "url": "http://localhost:3000/register", + "timestamp": 1705312195000 + }, + { + "step": 2, + "action_type": "fill", + "selector": "input[name='email']", + "value": "", + "timestamp": 1705312196000 + }, + { + "step": 3, + "action_type": "click", + "selector": "button[type='submit']", + "timestamp": 1705312197000 + } + ] + }, + "evidence": { + "screenshot": "screenshot.png", + "dom_snapshot": "dom.html", + "http_log": [ + { + "url": "http://localhost:3000/api/register", + "method": "POST", + "status": 500, + "duration_ms": 234 + } + ], + "console_errors": [], + "js_exceptions": [] + } +} +``` + +--- + +## 2. report.md — Para lectura humana + +El archivo Markdown debe tener exactamente esta estructura: +```markdown +# Bug Report — [tipo de anomalía] — [fecha] + +## Summary +[Una frase describiendo qué pasó y dónde] + +## Severity +[low | medium | high | critical] — [justificación en una frase] + +## Reproduction Steps + +1. Navigate to `[url]` +2. [acción 2] +3. [acción 3] +... + +**Seed used**: `42` +**Replay command**: `npm run replay -- --report reports/anom_a1b2c3d4/report.json` + +## Observed Behavior +[Qué ocurrió exactamente — errores, respuestas HTTP, mensajes] + +## Evidence +- Screenshot: `reports/anom_a1b2c3d4/screenshot.png` +- DOM Snapshot: `reports/anom_a1b2c3d4/dom.html` +- HTTP Log: [tabla con las requests relevantes] + +## Raw Errors +\`\`\` +[errores textuales tal cual aparecieron] +\`\`\` +``` + +--- + +## Estructura de carpetas de salida +``` +reports/ +└── anom_a1b2c3d4/ + ├── report.json ← estructurado para AI + ├── report.md ← legible para humanos + ├── screenshot.png ← captura en el momento de la anomalía + └── dom.html ← snapshot completo del DOM + +logs/ +└── session_20250115_103000.jsonl ← una línea JSON por evento +``` + +--- + +## Formato del log de sesión (.jsonl) + +Cada línea es un objeto JSON independiente: +```jsonl +{"event":"session_start","timestamp":1705312190000,"seed":42,"target":"http://localhost:3000"} +{"event":"state_discovered","timestamp":1705312191000,"state_id":"s_abc123","url":"/","title":"Home"} +{"event":"action_executed","timestamp":1705312196000,"action_id":"act_xyz","type":"fill","selector":"input[name='email']","value":""} +{"event":"anomaly_detected","timestamp":1705312197000,"anomaly_id":"anom_a1b2c3d4","type":"http_error","severity":"high"} +{"event":"session_end","timestamp":1705312210000,"states_visited":3,"anomalies_found":1} +``` diff --git a/.ralph/specs/legacy/performance-metrics.md b/.ralph/specs/legacy/performance-metrics.md new file mode 100644 index 0000000..b580c8f --- /dev/null +++ b/.ralph/specs/legacy/performance-metrics.md @@ -0,0 +1,124 @@ +# ABE — Performance Metrics Specification + +## Concepto +Durante la exploración, ABE captura métricas de rendimiento de cada +estado visitado. Inspirado en Checkly y Datadog RUM. +Esto permite detectar anomalías de rendimiento además de errores funcionales. + +## Métricas capturadas por estado +```typescript +interface IPerformanceMetrics { + stateId: string; + url: string; + timestamp: number; + + // Navigation Timing (disponibles via Playwright) + ttfb: number; // Time to First Byte (ms) + domContentLoaded: number; // DOMContentLoaded event (ms) + loadComplete: number; // Load event (ms) + + // Core Web Vitals (via web-vitals library injected) + lcp: number | null; // Largest Contentful Paint (ms) + cls: number | null; // Cumulative Layout Shift (score) + fid: number | null; // First Input Delay (ms) - solo tras interacción + inp: number | null; // Interaction to Next Paint (ms) + + // Resource counts + totalRequests: number; + failedRequests: number; + totalTransferSize: number; // bytes +} +``` + +## Implementación + +### TTFB, DOMContentLoaded, Load +Via `page.evaluate()` usando `performance.timing` después de navigation: +```typescript +const timing = await page.evaluate(() => ({ + ttfb: performance.timing.responseStart - performance.timing.requestStart, + domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart, + loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart, +})); +``` + +### Core Web Vitals +Inyectar el script de `web-vitals` (npm) en la página: +```typescript +await page.addScriptTag({ url: 'https://unpkg.com/web-vitals/dist/web-vitals.iife.js' }); +const vitals = await page.evaluate(() => new Promise(resolve => { + const result = {}; + webVitals.getLCP(m => result.lcp = m.value); + webVitals.getCLS(m => result.cls = m.value); + webVitals.getINP(m => result.inp = m.value); + setTimeout(() => resolve(result), 3000); // wait 3s for vitals +})); +``` + +## Anomalías de rendimiento (nuevos tipos) + +Añadir al AnomalyDetector con umbrales basados en Core Web Vitals de Google: + +| Métrica | Good | Needs Improvement | Poor (anomalía) | +|---------|---------|-------------------|-----------------| +| LCP | <2500ms | 2500-4000ms | >4000ms → high | +| CLS | <0.1 | 0.1-0.25 | >0.25 → medium | +| INP | <200ms | 200-500ms | >500ms → high | +| TTFB | <800ms | 800-1800ms | >1800ms → medium| + +Tipo de anomalía: `performance_degradation` + +## Modelo de datos — añadir a SQLite + +### Table: performance_metrics +```sql +CREATE TABLE IF NOT EXISTS performance_metrics ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + state_id TEXT NOT NULL, + url TEXT NOT NULL, + ttfb INTEGER, + dom_content_loaded INTEGER, + load_complete INTEGER, + lcp INTEGER, + cls REAL, + fid INTEGER, + inp INTEGER, + total_requests INTEGER, + failed_requests INTEGER, + total_transfer_size INTEGER, + captured_at INTEGER NOT NULL +); +``` + +## Frontend — Performance tab + +Añadir tab "Performance" en SessionDetail: +- Tabla con todos los estados visitados y sus métricas +- Columnas con color coded: verde/amarillo/rojo según umbrales de Google +- Gráfico de barras: LCP por estado (para identificar páginas lentas) +- Summary cards: peor LCP, peor CLS, peor TTFB de la sesión + +## En el bug report + +Si hay anomalía performance_degradation, añadir sección en report.md: +``` +## Performance Issue +- LCP: 5200ms (threshold: 4000ms) ❌ +- CLS: 0.08 ✅ +- TTFB: 2100ms (threshold: 1800ms) ❌ +- Total page size: 4.2MB +``` + +## Configuración + +Añadir a ExplorationConfig: +```typescript +performance: { + enabled: boolean; // default: true + lcpThresholdMs: number; // default: 4000 + clsThreshold: number; // default: 0.25 + inpThresholdMs: number; // default: 500 + ttfbThresholdMs: number; // default: 1800 +} +``` diff --git a/.ralph/specs/legacy/production-hardening.md b/.ralph/specs/legacy/production-hardening.md new file mode 100644 index 0000000..cbcd26a --- /dev/null +++ b/.ralph/specs/legacy/production-hardening.md @@ -0,0 +1,77 @@ +# ABE — Production Hardening Specification + +## Health Endpoints (no auth required) + +### GET /health +Returns 200 if server is up. +```json +{ "status": "ok", "version": "0.1.0", "uptime_seconds": 3600 } +``` + +### GET /ready +Returns 200 if server is ready to accept requests (DB connected, no critical errors). +Returns 503 if not ready. +```json +{ "status": "ready", "db": "connected", "active_sessions": 2 } +``` + +Used by Docker HEALTHCHECK and Kubernetes readiness probes. + +## Docker improvements + +### Backend Dockerfile +Add HEALTHCHECK: +```dockerfile +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3001/health || exit 1 +``` + +### docker-compose.yml updates +- Add healthcheck to backend service +- Add `restart: unless-stopped` to both services +- Add `data/` volume for SQLite persistence +- Load `.env` file: `env_file: .env` +- Add `depends_on: backend: condition: service_healthy` to frontend + +### .env.example file +Create `.env.example` in repo root with all variables and example values. +`.env` added to `.gitignore`. + +## Error handling improvements + +Global Express error handler in `src/server/index.ts`: +- Catch all unhandled errors +- Log with timestamp and stack trace +- Return consistent JSON error format: +```json +{ "error": "Internal server error", "code": "INTERNAL_ERROR", "timestamp": 1705312200000 } +``` + +Never expose stack traces in production (NODE_ENV=production). + +## Graceful shutdown + +On SIGTERM/SIGINT: +1. Stop accepting new sessions +2. Wait for active sessions to finish (max 30s) +3. Close DB connection +4. Exit 0 + +## Concurrency limits + +- Max concurrent exploration sessions: configurable via `ABE_MAX_CONCURRENT_SESSIONS` (default: 3) +- If limit reached, POST /api/sessions returns 429 with: +```json +{ "error": "Max concurrent sessions reached", "active": 3, "limit": 3 } +``` + +## Logging improvements + +Replace console.log with structured logger (use `pino`): +```typescript +log.info({ sessionId, url, event: 'session_started' }, 'Session started') +log.error({ anomalyId, error }, 'Failed to capture screenshot') +``` + +All logs go to stdout (Docker captures them). +Log level configurable via `ABE_LOG_LEVEL` env var (default: 'info'). diff --git a/.ralph/specs/legacy/project-structure.md b/.ralph/specs/legacy/project-structure.md new file mode 100644 index 0000000..64dc7d7 --- /dev/null +++ b/.ralph/specs/legacy/project-structure.md @@ -0,0 +1,138 @@ +# ABE — Project Structure Specification + +## Árbol completo de archivos a crear +``` +abe/ +├── src/ +│ ├── core/ +│ │ ├── interfaces.ts ← TODAS las interfaces (IState, IAction, etc.) +│ │ ├── StateGraph.ts ← implementación del grafo de estados +│ │ ├── ExplorationEngine.ts ← loop principal de exploración +│ │ └── AnomalyDetector.ts ← reglas heurísticas de detección +│ ├── plugins/ +│ │ ├── agents/ +│ │ │ └── PlaywrightAgent.ts ← implementa IInteractionAgent +│ │ ├── collectors/ +│ │ │ ├── ScreenshotCollector.ts +│ │ │ ├── NetworkCollector.ts +│ │ │ └── DOMSnapshotCollector.ts +│ │ ├── exporters/ +│ │ │ ├── MarkdownExporter.ts +│ │ │ └── JSONExporter.ts +│ │ └── reproducers/ +│ │ └── PlaywrightReproducer.ts +│ └── index.ts ← punto de entrada, conecta todo +│ +├── tests/ +│ ├── core/ +│ │ ├── StateGraph.test.ts +│ │ ├── ExplorationEngine.test.ts +│ │ └── AnomalyDetector.test.ts +│ └── plugins/ +│ ├── agents/ +│ │ └── PlaywrightAgent.test.ts +│ └── exporters/ +│ ├── MarkdownExporter.test.ts +│ └── JSONExporter.test.ts +│ +├── reports/ ← generado en runtime, ignorado por git +├── logs/ ← generado en runtime, ignorado por git +│ +├── package.json +├── tsconfig.json +├── jest.config.ts +└── CLAUDE.md +``` + +--- + +## Reglas de importación — MUY IMPORTANTE +``` +✅ PERMITIDO: + src/core/ExplorationEngine.ts → importa de src/core/interfaces.ts + src/plugins/agents/PlaywrightAgent.ts → importa de src/core/interfaces.ts + src/index.ts → importa de src/core/ Y src/plugins/ + +❌ PROHIBIDO: + src/core/ExplorationEngine.ts → importa de src/plugins/ (rompe el desacoplamiento) + src/plugins/agents/A.ts → importa de src/plugins/exporters/B.ts (plugins no se conocen entre sí) +``` + +--- + +## Cómo se conecta todo en src/index.ts + +El archivo de entrada debe seguir este patrón: +```typescript +// src/index.ts +import { ExplorationEngine } from './core/ExplorationEngine'; +import { StateGraph } from './core/StateGraph'; +import { PlaywrightAgent } from './plugins/agents/PlaywrightAgent'; +import { ScreenshotCollector } from './plugins/collectors/ScreenshotCollector'; +import { NetworkCollector } from './plugins/collectors/NetworkCollector'; +import { DOMSnapshotCollector } from './plugins/collectors/DOMSnapshotCollector'; +import { JSONExporter } from './plugins/exporters/JSONExporter'; +import { MarkdownExporter } from './plugins/exporters/MarkdownExporter'; +import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer'; + +const graph = new StateGraph(); +const agent = new PlaywrightAgent(); +const collectors = [new ScreenshotCollector(), new NetworkCollector(), new DOMSnapshotCollector()]; +const exporters = [new JSONExporter(), new MarkdownExporter()]; +const reproducer = new PlaywrightReproducer(); + +const engine = new ExplorationEngine({ graph, agent, collectors, exporters, reproducer }); + +engine.run({ url: process.argv[2] || 'http://localhost:3000', seed: 42 }); +``` + +--- + +## package.json — scripts obligatorios +```json +{ + "name": "abe", + "version": "0.1.0", + "scripts": { + "build": "tsc", + "test": "jest", + "typecheck": "tsc --noEmit", + "lint": "eslint src/ tests/", + "explore": "ts-node src/index.ts", + "replay": "ts-node src/replay.ts" + } +} +``` + +--- + +## tsconfig.json — configuración base +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} +``` + +--- + +## jest.config.ts — configuración base +```typescript +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], +}; +``` diff --git a/.ralph/specs/legacy/scheduled-monitoring.md b/.ralph/specs/legacy/scheduled-monitoring.md new file mode 100644 index 0000000..f63f86a --- /dev/null +++ b/.ralph/specs/legacy/scheduled-monitoring.md @@ -0,0 +1,79 @@ +# ABE — Scheduled Monitoring Specification + +## Concepto +ABE puede ejecutar exploraciones de forma automática en intervalos definidos, +sin intervención humana. Esto convierte ABE de una herramienta manual +en un sistema de monitorización continua, al estilo Checkly. + +## Modelo de datos — añadir a SQLite + +### Table: schedules +```sql +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + config_json TEXT NOT NULL, + cron_expression TEXT NOT NULL, -- e.g. "0 */6 * * *" (every 6h) + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at INTEGER, + next_run_at INTEGER, + created_at INTEGER NOT NULL +); +``` + +## Expresiones cron soportadas (presets en la UI) + +| Label | Cron | +|------------------|----------------| +| Every 15 minutes | */15 * * * * | +| Every hour | 0 * * * * | +| Every 6 hours | 0 */6 * * * | +| Every day at 2am | 0 2 * * * | +| Every Monday 9am | 0 9 * * 1 | + +## Implementación + +Usar `node-cron` para el scheduler. +Crear `src/server/scheduler/SchedulerService.ts`: +- En startup, carga todos los schedules con enabled=1 de la DB +- Registra un cron job por cada schedule +- Cuando dispara, llama internamente a POST /api/sessions con la config guardada +- Actualiza last_run_at y next_run_at en la DB después de cada disparo +- Si la sesión anterior sigue running, skip este tick y log warning + +## API endpoints nuevos + +### GET /api/schedules +Lista todos los schedules. + +### POST /api/schedules +Crea un nuevo schedule. +Body: +```json +{ + "name": "Production daily check", + "url": "https://myapp.com", + "config": { ... mismo ExplorationConfig ... }, + "cronExpression": "0 2 * * *", + "enabled": true +} +``` + +### PATCH /api/schedules/:id +Actualiza o activa/desactiva un schedule. + +### DELETE /api/schedules/:id +Elimina un schedule. + +## Frontend — nueva sección en Settings + +Añadir tab "Schedules" en /settings: +- Lista de schedules activos con: nombre, URL, cron, última ejecución, próxima ejecución, toggle activo/inactivo +- Botón "New Schedule" abre modal con: nombre, URL, config de exploración, selector de frecuencia (presets + custom cron) +- Badge "Running" si hay una sesión activa del schedule en este momento + +## Notificaciones específicas de schedules + +Cuando un schedule dispara una exploración y encuentra anomalías high/critical, +enviar notificación con el subject: "[SCHEDULED] ABE found bugs in {url}" diff --git a/.ralph/specs/legacy/visual-regression.md b/.ralph/specs/legacy/visual-regression.md new file mode 100644 index 0000000..8ebcef6 --- /dev/null +++ b/.ralph/specs/legacy/visual-regression.md @@ -0,0 +1,124 @@ +# ABE — Visual Regression Testing Specification + +## Concepto +ABE toma screenshots durante la exploración. En vez de solo guardarlos, +los compara contra una baseline aprobada para detectar cambios visuales +inesperados entre ejecuciones. Inspirado en Percy y Chromatic, +pero integrado directamente en el flujo de exploración autónoma. + +## Cómo funciona + +### Primera ejecución (sin baseline) +1. ABE explora el app, toma screenshots de cada estado descubierto +2. Todos los screenshots se marcan como "pending review" en la UI +3. El usuario aprueba o rechaza cada uno desde la GUI +4. Los aprobados se convierten en la BASELINE + +### Ejecuciones posteriores +1. ABE explora el app, toma screenshots de cada estado +2. Para cada screenshot, busca la baseline correspondiente por state_id (hash DOM+URL) +3. Si no hay baseline: marcar como "new state", notificar +4. Si hay baseline: comparar usando pixelmatch (npm library) +5. Si diff > threshold (default 0.1%): crear anomalía tipo visual_regression +6. Si diff <= threshold: marcar como "passed" + +## Librería de comparación + +Usar `pixelmatch` (npm) para comparación pixel a pixel. +Usar `sharp` para resize y normalización de imágenes antes de comparar. +```typescript +import pixelmatch from 'pixelmatch'; +import sharp from 'sharp'; + +async function compareScreenshots( + baselinePath: string, + currentPath: string, + diffOutputPath: string, + threshold: number = 0.1 +): Promise<{ diffPixels: number; diffPercent: number; hasDiff: boolean }> { + // resize both to same dimensions, compare, generate diff image +} +``` + +## Modelo de datos — añadir a SQLite + +### Table: visual_baselines +```sql +CREATE TABLE IF NOT EXISTS visual_baselines ( + id TEXT PRIMARY KEY, + state_id TEXT NOT NULL, + url TEXT NOT NULL, + screenshot_path TEXT NOT NULL, + approved_at INTEGER NOT NULL, + approved_by TEXT DEFAULT 'user', + width INTEGER NOT NULL, + height INTEGER NOT NULL +); +``` + +### Table: visual_comparisons +```sql +CREATE TABLE IF NOT EXISTS visual_comparisons ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + state_id TEXT NOT NULL, + baseline_id TEXT, + current_screenshot_path TEXT NOT NULL, + diff_screenshot_path TEXT, + diff_pixels INTEGER, + diff_percent REAL, + status TEXT NOT NULL, -- 'passed' | 'failed' | 'new_state' | 'pending' + created_at INTEGER NOT NULL +); +``` + +## Nuevo tipo de anomalía + +Añadir a AnomalyDetector: +- type: `visual_regression` +- severity: calculado por diff_percent: + - < 1% → low + - 1-5% → medium + - 5-15% → high + - > 15% → critical +- description: "Visual regression detected: X% of pixels changed" +- evidence: baseline screenshot + current screenshot + diff image (highlighted in red) + +## Nuevo endpoint de API + +### GET /api/visual/comparisons +Lista todas las comparaciones de la sesión más reciente. +Query: ?status=failed&sessionId=xxx + +### POST /api/visual/baselines/:comparisonId/approve +Aprueba un screenshot como nueva baseline. + +### POST /api/visual/baselines/:comparisonId/reject +Rechaza (anomalía confirmada, no actualizar baseline). + +### POST /api/visual/baselines/approve-all +Aprueba todos los "new_state" pendientes de una sesión. + +## Frontend — nueva sección Visual Review + +Nueva página /visual-review: +- Grid de cards, cada una muestra: URL del estado, thumbnail del screenshot actual +- Filtros: passed | failed | new_state | pending +- Click en una card abre modal con: + - Vista lado a lado: baseline izquierda, actual derecha + - Vista diff: imagen con píxeles cambiados en rojo + - Porcentaje de cambio + - Botones: Approve as new baseline | Mark as bug | Ignore +- Bulk actions: "Approve all new states", "Mark all failed as bugs" + +## Configuración + +Añadir a ExplorationConfig: +```typescript +visualRegression: { + enabled: boolean; // default: true + threshold: number; // default: 0.001 (0.1%) + screenshotFullPage: boolean; // default: false (solo viewport) + ignoreSelectors: string[]; // e.g. [".timestamp", ".ad-banner"] — excluir zonas dinámicas +} +``` diff --git a/.ralph/specs/phase-01-shared-domain.md b/.ralph/specs/phase-01-shared-domain.md new file mode 100644 index 0000000..43ba2e9 --- /dev/null +++ b/.ralph/specs/phase-01-shared-domain.md @@ -0,0 +1,134 @@ +# Phase 1: Shared Domain — Building Blocks + +## Objetivo +Crear las clases base que TODOS los módulos usarán. Esto es el cimiento. + +## Result.ts +```typescript +// Discriminated union, no classes +type ResultOk = { readonly ok: true; readonly value: T }; +type ResultErr = { readonly ok: false; readonly error: E }; +export type Result = ResultOk | ResultErr; + +export const Ok = (value: T): Result => ({ ok: true, value }); +export const Err = (error: E): Result => ({ ok: false, error }); +export function isOk(r: Result): r is ResultOk { return r.ok; } +export function isErr(r: Result): r is ResultErr { return !r.ok; } +``` + +## UniqueId.ts +```typescript +import { v4 as uuidv4 } from 'uuid'; + +export class UniqueId { + private constructor(private readonly value: string) {} + static create(): UniqueId { return new UniqueId(uuidv4()); } + static from(value: string): UniqueId { return new UniqueId(value); } + toString(): string { return this.value; } + equals(other?: UniqueId): boolean { + if (!other) return false; + return this.value === other.value; + } +} +``` + +## Entity.ts +```typescript +export abstract class Entity { + protected readonly _id: UniqueId; + protected props: T; + + constructor(props: T, id?: UniqueId) { + this._id = id ?? UniqueId.create(); + this.props = props; + } + + get id(): UniqueId { return this._id; } + + equals(other?: Entity): boolean { + if (!other) return false; + return this._id.equals(other._id); + } +} +``` + +## AggregateRoot.ts +```typescript +export abstract class AggregateRoot extends Entity { + private _domainEvents: DomainEvent[] = []; + + get domainEvents(): ReadonlyArray { + return this._domainEvents; + } + + protected addDomainEvent(event: DomainEvent): void { + this._domainEvents.push(event); + } + + clearEvents(): DomainEvent[] { + const events = [...this._domainEvents]; + this._domainEvents = []; + return events; + } +} +``` + +## ValueObject.ts +```typescript +export abstract class ValueObject { + protected readonly props: T; + + constructor(props: T) { + this.props = Object.freeze(props); + } + + equals(other?: ValueObject): boolean { + if (!other) return false; + return JSON.stringify(this.props) === JSON.stringify(other.props); + } +} +``` + +## DomainEvent.ts +```typescript +export interface DomainEvent { + readonly eventId: string; + readonly eventName: string; + readonly aggregateId: string; + readonly occurredOn: Date; + readonly payload: Record; +} +``` + +## UseCase.ts +```typescript +export interface UseCase { + execute(request: TRequest): Promise>; +} +``` + +## EventBus.ts + EventHandler.ts +```typescript +// EventBus.ts +export interface EventBus { + publish(event: DomainEvent): Promise; + subscribe(eventName: string, handler: EventHandler): void; +} + +// EventHandler.ts +export interface EventHandler { + handle(event: DomainEvent): Promise; +} +``` + +## Tests requeridos (mínimo) +1. Result: Ok crea value accesible, Err crea error accesible, isOk/isErr discriminan +2. UniqueId: create genera string válido, equals funciona, from preserva valor +3. Entity: equals compara por id (no por props) +4. ValueObject: equals compara por props, props son inmutables + +## IMPORTANTE +- Estos archivos NO importan NADA externo excepto 'uuid' +- NO usar decorators +- NO usar classes abstractas complicadas — mantener simple +- Cada archivo exporta UNA cosa principal diff --git a/.ralph/specs/phase-02-shared-infrastructure.md b/.ralph/specs/phase-02-shared-infrastructure.md new file mode 100644 index 0000000..ef97603 --- /dev/null +++ b/.ralph/specs/phase-02-shared-infrastructure.md @@ -0,0 +1,136 @@ +# Phase 2: Shared Infrastructure + +## Config.ts +Usa Zod para validar TODAS las env vars al arranque. Si falla → crash inmediato con mensaje claro. +```typescript +import { z } from 'zod'; +import dotenv from 'dotenv'; +dotenv.config(); + +const configSchema = z.object({ + port: z.coerce.number().default(3001), + host: z.string().default('0.0.0.0'), + nodeEnv: z.enum(['development', 'production', 'test']).default('development'), + db: z.object({ + driver: z.enum(['sqlite', 'postgres']).default('sqlite'), + path: z.string().default('./data/abe.db'), + url: z.string().optional(), + }), + auth: z.object({ + secret: z.string().min(16).default('abe-dev-secret-change-in-prod'), + sessionMaxAge: z.coerce.number().default(86400), + }), + storage: z.object({ + driver: z.enum(['local', 's3']).default('local'), + path: z.string().default('./data/storage'), + }), + cors: z.object({ origin: z.string().default('http://localhost:5173') }), + log: z.object({ level: z.enum(['debug','info','warn','error']).default('info') }), + api: z.object({ + key: z.string().default('abe-dev-key-123'), + rateLimitWindowMs: z.coerce.number().default(900000), + rateLimitMax: z.coerce.number().default(100), + }), + ai: z.object({ + provider: z.enum(['claude','openai','ollama','none']).default('none'), + apiKey: z.string().default(''), + autoEnrich: z.coerce.boolean().default(false), + minSeverity: z.enum(['low','medium','high','critical']).default('high'), + }), + jobs: z.object({ + maxConcurrentSessions: z.coerce.number().default(3), + pollIntervalMs: z.coerce.number().default(1000), + }), + license: z.object({ key: z.string().default('') }), +}); + +export type AppConfig = z.infer; + +export function loadConfig(): AppConfig { + // Map env vars to schema shape, parse +} +``` + +## Logger.ts +```typescript +import pino from 'pino'; + +export function createLogger(config: { level: string; nodeEnv: string }): pino.Logger { + return pino({ + level: config.level, + transport: config.nodeEnv === 'development' + ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } } + : undefined, + }); +} +export type Logger = pino.Logger; +``` + +## DatabaseConnection.ts +```typescript +import { Kysely, SqliteDialect } from 'kysely'; +import SQLite from 'better-sqlite3'; + +// Define Database interface con todas las tablas +export interface Database { + sessions: SessionTable; + states: StateTable; + actions: ActionTable; + anomalies: AnomalyTable; + // ... más tablas se añaden en fases posteriores +} + +export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely { + if (config.driver === 'postgres') { + // Import dinámico de pg para no requerir en SQLite + const { Pool } = require('pg'); + const { PostgresDialect } = require('kysely'); + return new Kysely({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }), + }); + } + + // Crear directorio data/ si no existe + const path = require('path'); + const fs = require('fs'); + fs.mkdirSync(path.dirname(config.path), { recursive: true }); + + return new Kysely({ + dialect: new SqliteDialect({ database: new SQLite(config.path) }), + }); +} +``` + +## InProcessEventBus.ts +```typescript +import { EventEmitter } from 'events'; +// Implements EventBus interface from shared/application +// Logging de cada evento publicado +// Catch errors en handlers (log pero no crash) +// setMaxListeners(50) +``` + +## StorageProvider.ts +```typescript +export interface IStorageProvider { + save(relativePath: string, data: Buffer): Promise; + get(relativePath: string): Promise; + delete(relativePath: string): Promise; + exists(relativePath: string): Promise; +} + +// LocalStorageProvider: usa fs.promises, base path = config.storage.path +// Crea directorios automáticamente con mkdir recursive +``` + +## Migración 001 +Crea las tablas que ya existen en el schema actual (sessions, states, actions, anomalies, notifications). +Usar `CREATE TABLE IF NOT EXISTS` para idempotencia. +Los tipos de columna deben coincidir con lo que ya tiene better-sqlite3. + +## IMPORTANTE +- Config DEBE fallar rápido si hay env vars inválidas +- Logger NUNCA debe usar console.log +- Database factory NUNCA importa pg a menos que driver sea postgres +- EventBus handlers que fallan se loguean pero NO crashean el bus + diff --git a/.ralph/specs/phase-07-api-server.md b/.ralph/specs/phase-07-api-server.md new file mode 100644 index 0000000..2047317 --- /dev/null +++ b/.ralph/specs/phase-07-api-server.md @@ -0,0 +1,216 @@ +# Phase 7: API Server Refactor + Composition Root + +## Middleware stack (ORDEN IMPORTA) +```typescript +// server.ts +export function createServer(deps: ServerDependencies): Express { + const app = express(); + + // 1. Request ID (PRIMERO — todo log necesita esto) + app.use(requestIdMiddleware); + + // 2. Security headers + app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", "ws:", "wss:"], + scriptSrc: ["'self'", "'unsafe-inline'"], // para Scalar docs + }, + }, + })); + + // 3. CORS + app.use(cors({ + origin: deps.config.cors.origin, + credentials: true, + })); + + // 4. Rate limiting global + app.use(rateLimit({ + windowMs: deps.config.api.rateLimitWindowMs, + max: deps.config.api.rateLimitMax, + standardHeaders: true, + legacyHeaders: false, + })); + + // 5. Body parsing + app.use(express.json({ limit: '10mb' })); + + // 6. Health endpoints (SIN auth) + app.get('/health/live', (_, res) => res.json({ status: 'ok' })); + app.get('/health/ready', async (_, res) => { /* check DB */ }); + + // 7. Auth routes (SIN auth middleware general) + app.use('/api/auth', deps.authController.router); + + // 8. Auth middleware (TODOS los /api/ a partir de aquí) + app.use('/api', deps.authMiddleware); + + // 9. Module routes + app.use('/api', deps.crawlingController.router); + app.use('/api', deps.findingsController.router); + app.use('/api', deps.fuzzingController.router); + // ... más módulos + + // 10. 404 handler + app.use(notFoundMiddleware); + + // 11. Error handler (SIEMPRE último) + app.use(globalErrorHandler); + + return app; +} +``` + +## Error hierarchy +```typescript +export class AppError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code: string, + public readonly isOperational = true, + ) { super(message); } +} + +export class ValidationError extends AppError { + constructor(message: string, public readonly details?: unknown) { + super(message, 400, 'VALIDATION_ERROR'); + } +} +export class AuthenticationError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 401, 'AUTHENTICATION_ERROR'); + } +} +export class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); + } +} +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found`, 404, 'NOT_FOUND'); + } +} +export class ConflictError extends AppError { + constructor(message: string) { + super(message, 409, 'CONFLICT'); + } +} +export class RateLimitError extends AppError { + constructor() { + super('Too many requests', 429, 'RATE_LIMIT'); + } +} +``` + +## Global error handler +```typescript +export function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) { + const logger = req.log || console; // pino child logger + + if (err instanceof AppError && err.isOperational) { + logger.warn({ err, statusCode: err.statusCode }, err.message); + return res.status(err.statusCode).json({ + error: err.message, + code: err.code, + ...(err instanceof ValidationError && err.details ? { details: err.details } : {}), + }); + } + + // Programmer error — log full stack, return generic message + logger.error({ err }, 'Unhandled error'); + return res.status(500).json({ + error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message, + code: 'INTERNAL_ERROR', + }); +} +``` + +## Composition root (main.ts) +```typescript +async function bootstrap() { + // 1. Config + const config = loadConfig(); + + // 2. Logger + const logger = createLogger(config); + logger.info({ port: config.port }, 'Starting ABE...'); + + // 3. Database + migrations + const db = createDatabase(config.db); + await runMigrations(db, logger); + + // 4. Event bus + const eventBus = new InProcessEventBus(logger); + + // 5. Storage + const storage = new LocalStorageProvider(config.storage.path); + + // 6. Repositories + const sessionRepo = new KyselyCrawlSessionRepository(db); + const findingRepo = new KyselyFindingRepository(db); + // ... etc + + // 7. Use cases + const startCrawl = new StartCrawlCommand(sessionRepo, eventBus); + const listFindings = new ListFindingsQuery(findingRepo); + // ... etc + + // 8. Event handlers — subscribe to event bus + const onAnomalyDetected = new OnAnomalyDetected(findingRepo, eventBus); + eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected); + // ... etc + + // 9. Controllers + const crawlingController = new CrawlingController(startCrawl, ...); + const findingsController = new FindingsController(listFindings, ...); + // ... etc + + // 10. HTTP server + const app = createServer({ config, authMiddleware, crawlingController, findingsController, ... }); + const httpServer = createServer(app); + + // 11. Socket.io + const io = new Server(httpServer, { cors: { origin: config.cors.origin } }); + const gateway = new SocketGateway(io, eventBus); + + // 12. Job queue + const jobQueue = new SQLiteJobQueue(db, logger); + jobQueue.start(); + + // 13. Listen + httpServer.listen(config.port, config.host, () => { + logger.info({ port: config.port }, 'ABE server ready'); + }); + + // 14. Graceful shutdown + async function shutdown(signal: string) { + logger.info({ signal }, 'Shutting down...'); + httpServer.close(); + io.close(); + jobQueue.pause(); + await jobQueue.waitForActive(30000); + await db.destroy(); + logger.info('Shutdown complete'); + process.exit(0); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +bootstrap().catch((err) => { + console.error('Fatal: failed to start ABE', err); + process.exit(1); +}); +``` + +## IMPORTANTE +- El código existente en src/server/ debe DEJAR DE USARSE gradualmente +- Mantener los endpoints viejos funcionando durante la migración +- Cada controller es una clase con un `.router` getter que retorna Express.Router +- NUNCA meter lógica de negocio en controllers — solo parse request → call use case → format response + diff --git a/.ralph/specs/phase-08-job-queue.md b/.ralph/specs/phase-08-job-queue.md new file mode 100644 index 0000000..0cb552c --- /dev/null +++ b/.ralph/specs/phase-08-job-queue.md @@ -0,0 +1,66 @@ +# Phase 8: Job Queue System + +## Tabla jobs (SQLite) +```sql +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + payload TEXT NOT NULL, + result TEXT, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + priority INTEGER NOT NULL DEFAULT 0, + run_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_jobs_poll ON jobs(status, run_at, priority DESC); +``` + +## Interface +```typescript +export interface IJobQueue { + enqueue(type: string, payload: T, opts?: { runAt?: Date; priority?: number; maxAttempts?: number }): Promise; + start(): void; + pause(): void; + waitForActive(timeoutMs: number): Promise; +} +``` + +## Polling logic +``` +loop (cada pollIntervalMs): + SELECT id, type, payload FROM jobs + WHERE status = 'pending' AND run_at <= datetime('now') + ORDER BY priority DESC, created_at ASC + LIMIT 1 + + if found: + UPDATE jobs SET status = 'running', started_at = now, attempts = attempts + 1 + WHERE id = ? AND status = 'pending' // optimistic lock + + if updated 0 rows → skip (otro worker lo tomó) + + try: + result = await executeJob(type, payload) + UPDATE jobs SET status = 'completed', result = ?, completed_at = now + catch: + if attempts >= max_attempts: + UPDATE jobs SET status = 'failed', error = ? + else: + backoff = min(1000 * 2^attempts, 60000) + UPDATE jobs SET status = 'pending', run_at = now + backoff, error = ? +``` + +## Job types +- `exploration:run` — payload: { sessionId, config } +- `report:generate` — payload: { reportId, format, filters } +- `cleanup:old-data` — payload: { retentionDays } + +## NO usar Redis +El job queue es SQLite-based para zero-dependency self-hosted. +Es simple, funciona para el volumen esperado (decenas de jobs, no miles). diff --git a/.ralph/specs/phase-09-auth-module.md b/.ralph/specs/phase-09-auth-module.md new file mode 100644 index 0000000..74b34f0 --- /dev/null +++ b/.ralph/specs/phase-09-auth-module.md @@ -0,0 +1,148 @@ +# Phase 9: Auth Module + +## Objetivo +Sistema completo de autenticación y autorización para ABE como plataforma. + +## Roles y permisos + +| Role | Sessions | Findings | Reports | Integrations | Org/Users | Settings | License | +|---------|----------|----------|---------|--------------|-----------|----------|---------| +| owner | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD | +| admin | CRUD | CRUD | CRUD | CRUD | CRU | CRUD | R | +| member | CR | CRU | CR | R | R | R | R | +| viewer | R | R | R | R | R | R | R | + +## Better Auth config +```typescript +import { betterAuth } from 'better-auth'; + +export const auth = betterAuth({ + database: { + // Usar Kysely adapter o direct SQLite + type: 'sqlite', + url: config.db.path, + }, + emailAndPassword: { enabled: true }, + session: { + maxAge: config.auth.sessionMaxAge, + updateAge: 60 * 60, // refresh cada hora + }, + // Organization plugin si disponible, sino implementar manual +}); +``` + +Si Better Auth no soporta organizaciones directamente, implementar manualmente: +- Tabla organizations (id, name, slug, created_at) +- Tabla org_members (id, org_id, user_id, role, invited_at, joined_at) + +## CASL AbilityFactory +```typescript +import { AbilityBuilder, createMongoAbility } from '@casl/ability'; + +export function defineAbilityFor(role: string) { + const { can, cannot, build } = new AbilityBuilder(createMongoAbility); + + switch (role) { + case 'owner': + can('manage', 'all'); + break; + case 'admin': + can('manage', 'all'); + cannot('delete', 'Organization'); + cannot('manage', 'License'); + can('read', 'License'); + break; + case 'member': + can('create', ['Session', 'Finding', 'Report']); + can('read', 'all'); + can('update', 'Finding'); + break; + case 'viewer': + can('read', 'all'); + break; + } + return build(); +} +``` + +## AuthMiddleware — orden de verificación +1. Check cookie de session (web UI) via Better Auth +2. Check header `Authorization: Bearer ` +3. Check header `X-ABE-API-Key: ` (API keys para CI/CD) +4. Si ninguno → 401 + +## API Key system +- POST /api/auth/api-keys — crear key (retorna key UNA vez, después solo hash) +- GET /api/auth/api-keys — listar (sin mostrar key, solo nombre + último uso) +- DELETE /api/auth/api-keys/:id — revocar +- Keys hasheadas con SHA-256 en DB +- Cada key tiene: name, permissions (array de roles), expiresAt, lastUsedAt + +## First-run flow +1. Backend: si tabla users tiene 0 rows → flag `setupRequired = true` +2. GET /api/auth/setup-required → `{ required: boolean }` +3. Si required, POST /api/auth/setup con { email, password, name, orgName } +4. Crea user con role owner + organization default +5. Después de setup, requiere login normal + +## Migraciones +```sql +-- users (Better Auth maneja su propia tabla, pero añadir campos custom) +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS org_members ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id), + user_id TEXT NOT NULL REFERENCES users(id), + role TEXT NOT NULL DEFAULT 'member', + joined_at INTEGER NOT NULL, + UNIQUE(org_id, user_id) +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + org_id TEXT NOT NULL REFERENCES organizations(id), + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, -- primeros 8 chars para identificar + permissions TEXT NOT NULL DEFAULT '["member"]', + expires_at INTEGER, + last_used_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + token TEXT UNIQUE NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +``` + +## NOTA sobre Better Auth +Si Better Auth resulta demasiado complejo de integrar con Express puro +o tiene incompatibilidades, implementar auth manualmente: +- argon2 para hash passwords +- crypto.randomUUID() para session tokens +- Cookie httpOnly + secure + sameSite para sessions +- Middleware custom que lee cookie → busca en auth_sessions → adjunta user a req + +Esto es PERFECTAMENTE VÁLIDO. No over-engineer la auth. +La prioridad es que funcione, sea seguro, y tenga RBAC. diff --git a/.ralph/specs/phase-10-frontend-shell.md b/.ralph/specs/phase-10-frontend-shell.md new file mode 100644 index 0000000..6ed0c64 --- /dev/null +++ b/.ralph/specs/phase-10-frontend-shell.md @@ -0,0 +1,129 @@ +# Phase 10: Frontend Shell — shadcn/ui + +## Setup shadcn/ui +```bash +cd frontend +npx shadcn@latest init +# Responder: Vite, Zinc, CSS variables, YES to tailwind +``` + +## Layout principal +``` +┌────────────────────────────────────────────────┐ +│ TopBar: [☰] [ABE logo] ···· [⌘K Search] [🌙] [👤]│ +├────────┬───────────────────────────────────────┤ +│ │ │ +│ Side- │ Content Area │ +│ bar │ (React Router Outlet) │ +│ │ │ +│ 📊 Dashboard │ +│ 🔍 Explorations │ +│ 🐛 Findings │ +│ 📄 Reports │ +│ ───────── │ +│ ⚙️ Settings │ +│ │ │ +└────────┴───────────────────────────────────────┘ +``` + +## Dark mode (DEFAULT) +- Usar estrategia class-based: `` +- CSS variables de shadcn ya soportan dark mode +- Toggle en TopBar: sol/luna +- Persistir en localStorage key 'abe-theme' + +## Auth flow en frontend +``` +App monta → + GET /api/auth/setup-required + → si required: mostrar /setup + → si no: + GET /api/auth/me + → si 401: redirect /login + → si ok: render AppLayout con user data +``` + +## API client (lib/api.ts) +```typescript +const API_URL = import.meta.env.VITE_API_URL || ''; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_URL}${path}`, { + ...init, + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }); + + if (res.status === 401) { + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || `HTTP ${res.status}`); + } + + return res.json(); +} +``` + +## Routing +```typescript + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +``` + +## Command Palette (⌘K) +Powered by shadcn Command (cmdk): +- Buscar por: sessions, findings, settings sections +- Acciones: "New Exploration", "Generate Report" +- Keyboard: ⌘K abre, Esc cierra + +## File structure +``` +frontend/src/ +├── components/ +│ ├── ui/ # shadcn generados (NO tocar) +│ ├── layout/ +│ │ ├── AppLayout.tsx +│ │ ├── AppSidebar.tsx +│ │ ├── TopBar.tsx +│ │ ├── CommandPalette.tsx +│ │ ├── ProtectedRoute.tsx +│ │ └── ThemeProvider.tsx +│ └── common/ +│ └── SeverityBadge.tsx +├── hooks/ +│ ├── useAuth.ts +│ └── useSocket.ts +├── lib/ +│ ├── api.ts +│ ├── queryClient.ts +│ └── utils.ts # cn() de shadcn +├── stores/ +│ └── uiStore.ts +├── pages/ +│ ├── Dashboard.tsx (placeholder "Coming in Phase 11") +│ ├── Login.tsx +│ └── Setup.tsx +├── App.tsx +└── main.tsx +``` + +## IMPORTANTE +- El Dashboard en esta fase puede ser un placeholder que diga "Dashboard — Coming soon" +- Lo importante es que el shell funcione: login → sidebar → routing → theme +- NO intentar hacer todo el dashboard aquí — eso es Phase 11 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f065e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md — Contexto para Claude Code + +## Qué es ABE +ABE (Autonomous Bug Explorer) es una plataforma enterprise self-hosted de +descubrimiento autónomo de bugs en aplicaciones web. + +## Estado actual +Fases 1-11 originales implementadas. Ahora refactorizando hacia arquitectura +modular hexagonal enterprise. Ver `.ralph/PROMPT.md` para detalles completos. + +## Arquitectura +Modular monolith hexagonal con bounded contexts. + +**Regla #1**: Domain NUNCA importa infrastructure. +**Regla #2**: Cross-module communication SOLO via EventBus. +**Regla #3**: Controllers son thin — delegan a use cases. +**Regla #4**: Use cases retornan Result, nunca throw. + +## Comandos principales +```bash +npm run build # build backend +cd frontend && npm run build # build frontend +npm run test # vitest tests +npm run lint # eslint +npm run db:migrate # kysely migrations +docker compose up -d --build # todo con Docker +``` + +## Verificación obligatoria después de cambios +```bash +npm run build && cd frontend && npm run build && cd .. && npm run test +``` + +## Commit después de cada tarea +```bash +git add -A && git commit -m "fase(X.Y): descripción" +``` + +## Stack +- Backend: Node 20, TypeScript strict, Express, socket.io, Kysely, better-sqlite3, Pino, Zod, Better Auth, CASL, Playwright +- Frontend: React 18, Vite, shadcn/ui, Tailwind, Tremor, Recharts, TanStack Query/Table, Zustand + +## Estructura +``` +src/shared/ → building blocks compartidos +src/modules/ → bounded contexts +src/api/ → Express server + middleware global +src/realtime/ → socket.io gateway +src/jobs/ → job queue SQLite-backed +src/cli/ → CLI +src/main.ts → composition root +frontend/ → React app +``` + +## Para desarrollo con Ralph +```bash +cat .ralph/fix_plan.md | grep -E "^\- \[" | head -30 +ralph --monitor +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a3638b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# ---- Build stage ---- +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm run build + +# ---- Production stage ---- +FROM node:20-alpine + +WORKDIR /app + +# System dependencies required by Playwright / Chromium and healthcheck +RUN apk add --no-cache \ + chromium \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + curl + +# Tell Playwright to use the system Chromium instead of downloading its own +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist + +# Runtime directories for reports and logs +RUN mkdir -p reports logs + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3001/health || exit 1 + +CMD ["node", "dist/server/index.js"] diff --git a/README.md b/README.md index bea4513..868452d 100644 --- a/README.md +++ b/README.md @@ -1 +1,126 @@ -# abe +# ABE — Autonomous Bug Explorer + +An open-source framework that autonomously explores web applications, provokes failures, and generates reproducible bug reports for developers and AI coding assistants. + +## Quick Start + +```bash +# Install dependencies +npm install + +# Install Playwright browser +npx playwright install chromium + +# Run ABE against your app +npm run explore -- --url http://localhost:3000 --output ./reports + +# Replay a discovered bug +npm run replay -- --report reports//report.json +``` + +## What ABE Does + +1. Launches a headless browser and navigates to the target URL +2. Discovers interactive elements (links, buttons, inputs) +3. Executes actions deterministically using a seed +4. Observes HTTP responses, JS exceptions, and console errors +5. Detects anomalies using heuristic rules +6. Captures screenshots and DOM snapshots at anomaly moments +7. Generates a JSON + Markdown bug report with exact reproduction steps + +## Project Structure + +``` +src/ +├── core/ # Interfaces, StateGraph, ExplorationEngine, AnomalyDetector +└── plugins/ + ├── agents/ # PlaywrightAgent + ├── collectors/ # Screenshot, Network, DOMSnapshot + ├── exporters/ # JSON, Markdown + └── reproducers/ # PlaywrightReproducer + +tests/ # Unit and integration tests (mirrors src/) +reports/ # Generated bug reports (runtime) +logs/ # Session logs in .jsonl format (runtime) +``` + +## CLI Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--url` | `http://localhost:3000` | Target URL | +| `--output` | `./reports` | Output directory | +| `--seed` | `42` | Random seed for determinism | +| `--max-steps` | `100` | Maximum exploration steps | + +## Web UI (API Server + Dashboard) + +ABE also ships a web dashboard for launching explorations and watching results in real time. + +```bash +# Start both the API server (port 3001) and the React frontend (port 5173) +npm run dev:all +``` + +Then open `http://localhost:5173` in your browser. + +### API Server only + +```bash +npm run server +``` + +REST endpoints available at `http://localhost:3001/api/`: + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/sessions` | Start a new exploration | +| `GET` | `/sessions` | List all sessions | +| `GET` | `/sessions/:id` | Session detail | +| `DELETE` | `/sessions/:id` | Stop a running session | +| `GET` | `/anomalies` | List all anomalies | +| `GET` | `/anomalies/:id` | Anomaly detail | +| `GET` | `/anomalies/:id/screenshot` | Bug screenshot (PNG) | +| `POST` | `/anomalies/:id/replay` | Trigger anomaly replay | + +WebSocket events are emitted via socket.io (connect to `http://localhost:3001`). + +## Docker + +Run the full stack (backend + frontend) with a single command: + +```bash +docker-compose up --build +``` + +| Service | Host port | Description | +|---------|-----------|-------------| +| Backend | `3001` | Express API + socket.io | +| Frontend | `5173` | React dashboard (nginx) | + +Then open `http://localhost:5173` in your browser. + +Reports and logs are persisted via Docker volumes (`./reports`, `./logs`). + +## Development + +```bash +npm run build # Compile TypeScript +npm test # Run all tests +npm run typecheck # Type-check without compiling +``` + +## Architecture + +``` +frontend/ (React + Vite, port 5173) + ↕ HTTP REST + WebSocket +src/server/ (Express + socket.io, port 3001) + ↕ imports +src/core/ + src/plugins/ (ABE engine) +``` + +Core principles: +- **Deterministic**: all random choices are seeded and logged +- **Plugin-oriented**: core engine never imports concrete plugin classes +- **Reproducible**: every anomaly includes an exact action trace and replay script diff --git a/data/abe.db b/data/abe.db new file mode 100644 index 0000000000000000000000000000000000000000..159e90adb13561c0365c22e76084c658383a62f1 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYFvV#S79dK(-m98b?E5 nGz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E0qLtr!nC=3Arqf-Z% literal 0 HcmV?d00001 diff --git a/data/abe.db-shm b/data/abe.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..13924485f22e5f6efc9bc69e5f16dcd68435e8f1 GIT binary patch literal 32768 zcmeI*IZ8x95C-5kZsRsO?&ERer@2BIF5U44T(?f+oO@W-3D+Fo^kV0(m2V5U44TcVGyCngV(Aix8+O;J%}p|GQJCz!nAqWd(N1p4`C+j1UkgE3jAg W literal 0 HcmV?d00001 diff --git a/data/abe.db-wal b/data/abe.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..eaa6382507798403dd07960fccfc9caff279e8a2 GIT binary patch literal 115392 zcmeI5eT*Ds9mj9Cce~enFElQtIXgj;Ez+~y)P|r!xn7sja6RhX*0zykGTnW)J9T$v zJJ0O(&RR(Bju(prt=6Et7!B$lyh(@(#urowmKUS^LnS1HCTN5Z0)G&q#`w(4{^sVn z-Pv950u6WHP1>D#o|$KMKF_>7Gxz=eeplQan`s+5cTrp0K%4WKId#XsfA-d&{`(i} z^y%9^b>K}=6bY8!^Y<6G6u$f1nTaos>NZpIrd85xWvf^_8g08qQN*%Q@o$y0PW9ug z-%c~)+ifo}UvDzn_L)2X)!Y9;TePpgt*^iT*?J413; zCosD$5>E^cMsB^z)(#aJXPoP%!Rb3XGM*mJq?OF@j?uJ2rOL)G#r^0xC6hjoQTC4S z-ZMOYP`N&Ra8tFaVimo*u^TeV*uK$Gs^GR}SGZiK>`Y%fyl*t4B&~{J=*Hoszcgbx zW%pPny(>MgENrTzO{q?EjMpsUx43PK-;}$gI;nH<09@OszlOhpW|$?ds57qS&5F^= z8sf)TqHR??S%>}Ryl&{cAT2{bFl!ol{jhq3n?@L(<0q0X-^(^fV~LT$NSkit*c3ln z6uYEq729;@Rq7};MN`gmeP{LPa-Q@3_XW4#qeN4e{PMa2u})i zWjuXtdOSTglAiEY^BeWtWhC_uRma=9U1YNQo7TyhC6yamnHNl3Eo*kcU5sj7S-YAQ zLiJkhd9AcPr}L~i$t+2S?(6G4iTUO2vBdUup%QaXO`r1kb-+j9cRhI-mc5U_eWoY9 zz&j5gyW@=c!tL-8G`a+#4g^2|1V8`;KmY_l00ck)1V8`;mJI>tZ~qwt;iY#lJmaJLViWP5pQn8aRp5}R@ z@b7GjWh+g>t#fv?!i+3aO)JN&nyBoa(tRivOAN0MQ@U4KtEuWl96kbH+72Hl3w69+AD9K+SvIO_z2F&EeP8L0T2KI5C8!X009sH0T2KI5CDOXoPhJU z|2VxDxUO&dliM$Q{m1YTeB}Lr4T1m&fB*=900@8p2!H?xfB*=9!1*8``3QDCuz%!< zn}$Ez#7D3?@IxIB!J>TxotRp6ZMx(i=jx>D1jqolcXh;UiGsBalxpw{IO#9l$k+lO!J>QwozBgc^a7V&{hfE8 zJU;w9d<07#f}jBq009sH0T2KI5C8!X009sH0TB2&5ODtXznb0){BGxi|GfQgXWoX7 z;N#GB_>CX{0w4eaAOHd&00JNY0w4eaAh2Wtl8@kxw|9R2Yj@c{XyPO24s6xQbGT?9 zL09}_jgKIHvh%(``r`DZkV>7*Rz@%6kVF>lEj=+ zRV#{4PdGi@Fx0s(t`0^!&lWVpU`1K-=?gp?%0bJ_$jNe0p(EF9ice0BFw3fjO!vg# zS9Zk`dy-*daB0uglr-B7A3>%@vI`%9ZR$pjO{xCz4IhEz%OJHkosh^!kiIrOo*o-X zPbj3C4_*?9SIwzr>vl0DHAG`O&ZN$DTg{pk!@oNEjVmOtM?Qk|uJpLig^pJE2o~id zaHRpH7kKFFul%+9u9x0}k6;B3L9xpq00JNY0w4eaAOHd&00JNY0xOY#^SA$QdN1(g zw?BC7w>!=}1s}mmJQT3cAOHd&00JNY0w4eaAOHd&00JwJfaD{%^WE>gc53LEH=6he zTsc9NRZM&@+DFh6i`4ZI#3J$iE3hxH%OC&(AaMQ(%uIBxRz7kgLx4BXlU_k7AO?Pb9tZ#^z`&F)|qD zxFZXU7;bo@Cca7xJl#A5;=9Kggpyw1ir>#w9-Do2FMI?J6?{Md1V8`;KmY_l00ck) z1V8`;Kw#Mua9#oQ(tCkJFTQ{J=zWu)fRA9=_d9kF1V8`;KmY_l00ck)1V8`;KmY_B z0+Ns5?v7m-J#x?4*PHkV`UB|VVtUa&g1+`rsE?q%)bUhcJD3Ck5Lk``X7={R6GKCh zxd~UZE@JOiw4!=QSCo7y;N%|qj^qknr?y*eWFuMh&A1>5JC-wwn z+xudPiJ>q7*+OP&YGFw~d<0=;0r&`Hl``@XR0X9~F=owmMG|>e1)HtPyeFBZ+trK2 z*bTxq43`jzPB|Sdo+dT&`kPR%}hk4u^d?YCXjk zt<*HY`p7^mu|L&BuO0yHE7k*LYv3b*kHC=xD@v%WFf<77{E>=$1mdjDq?OF@j?uJY zn6{qRvzqP1jc(=g=7m+Brt6dMeF;jG{m{!n89aOh@DXrBE6e^WIxlm~77_28*s64u z8J3y9ID7=V z(&PRW-E$gV-X2SAUl%rV^SFsX^)AJZ*=><{VsJ2Wt2gAP@b&Z^m4??;+R9xg^oH+j z7(RmYnU7$=dDJAmz>V*1>HNhruTH~9fCq38009sH0T2KI5C8!X009sH0T5Ub1f0M9 zuc7w>&uzXqb0m4qm*FE=5q*!H1OX5L0T2KI5C8!X009sH0T2KI$w%;$(QoXyA zCO(3-0c@+q^rCzO9Zn;zTA^)i&*Q!4y1RSkI?r~Uji2niFBXaKck&T9K7#1J=)S=A zF!^yMkiH<6IJh-Tx>ROX-n2@Zk!5O$*+N&y)l{9r3*jTEiHc96O85v`(oDPRxEjSY zK@8WnAs+$f*#gT|M7ZZ3SvyF@t%wH=t;FO=!%fk{M=-0p@>t0qAkB119U@h=(kbu} zXhogzPKly#oM?q0@(~2`w9pw1AHji)vUhy0RI6GQl&Opz!a?B#j;{v7oP%M@h zULQ7G)UvKcN=;QK;3H7BQ2*p~o)tNfEXjDYfG&Ilj{k-XoZEbM-sz(Xudyx;;+jL# zU&CL)zelKfvtqQehS-GFpqR1P&E^KmY_l00ck)1V8`;KmY_l z00cl_*%P?X*+Y6S@W+=QZ5w*&#RuUdSoZyn9RvXo009sH0T2KI5C8!X009sH0f&I& z30O<-1?;xFA9(HQ|7_~*|3C-{^38JvrHpDnJ5C$?^l+d|Rr%5!s<7?+A(Ea@SUHWUDV}?=j;NV{598_0@Du{e3hAB z?Fq=X_r($uLtz54YC;amksS{0+7#Lr?{ zh(OTmmQ+tHk=_v2Ek0Wz>?7X2GXtyRiDWVYA3^#+#-n&;S1hq78AefZP^c+swtMzO z+&!#s+wC6Kz6R8vfR8|6_Ae%MC90P#hw7a14e}AVNkFQS_N5F!B}YJ2-5c}-)E}5i zYq=rhgX_RYAU!g8O7Rb~lfs`5bh{X0>1cc!xw3Zgj8GBDq;`*G(!u-!{^-G%w!=p- aCQJf!fhuWJYPHWp(ZNT+ounSVkKliN(f;TF literal 0 HcmV?d00001 diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..d7a4932 --- /dev/null +++ b/dist/cli.js @@ -0,0 +1,252 @@ +"use strict"; +/** + * ABE CLI — command-line interface for running explorations. + * Usage: abe run --url http://localhost:3000 + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const commander_1 = require("commander"); +const ExplorationEngine_1 = require("./core/ExplorationEngine"); +const StateGraph_1 = require("./core/StateGraph"); +const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent"); +const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector"); +const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector"); +const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector"); +const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter"); +const JSONExporter_1 = require("./plugins/exporters/JSONExporter"); +const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer"); +const ExplorationConfig_1 = require("./core/ExplorationConfig"); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const program = new commander_1.Command(); +program + .name('abe') + .description('Autonomous Bug Explorer — explore web apps and find bugs') + .version('0.1.0'); +program + .command('run') + .description('Run an exploration session against a target URL') + .requiredOption('--url ', 'Target URL to explore') + .option('--seed ', 'Deterministic seed', parseInt, 42) + .option('--max-states ', 'Max states to explore', parseInt, 50) + .option('--max-depth ', 'Max click depth', parseInt, 5) + .option('--allowed-domains ', 'Comma-separated allowed domains') + .option('--excluded-paths ', 'Comma-separated excluded paths') + .option('--action-delay ', 'Delay between actions in ms', parseInt, 500) + .option('--session-timeout ', 'Session timeout in ms', parseInt, 300000) + // Auth options + .option('--auth-type ', 'Auth type: cookies | headers | login_flow') + .option('--login-url ', 'Login page URL (for login_flow)') + .option('--username ', 'Username (for login_flow)') + .option('--password ', 'Password (for login_flow)') + .option('--username-selector ', 'Username field selector (for login_flow)') + .option('--password-selector ', 'Password field selector (for login_flow)') + .option('--submit-selector ', 'Submit button selector (for login_flow)') + // Output + .option('--output ', 'Output format: human | json | junit', 'human') + .option('--reports-dir ', 'Output directory for reports', './reports') + // CI flags + .option('--fail-on-anomaly', 'Exit 1 if any anomaly found') + .option('--fail-on-severity ', 'Exit 1 if anomaly at or above severity found') + // Remote server + .option('--server ', 'Connect to remote ABE server instead of running inline') + .option('--api-key ', 'API key for remote server') + .action(async (opts) => { + const startMs = Date.now(); + // Build auth config + let auth = null; + if (opts.authType === 'login_flow') { + auth = { + type: 'login_flow', + loginUrl: opts.loginUrl ?? '', + usernameSelector: opts.usernameSelector ?? 'input[type="email"]', + passwordSelector: opts.passwordSelector ?? 'input[type="password"]', + submitSelector: opts.submitSelector ?? 'button[type="submit"]', + username: opts.username ?? '', + password: opts.password ?? '', + }; + } + else if (opts.authType === 'headers') { + auth = { type: 'headers', headers: {} }; + } + else if (opts.authType === 'cookies') { + auth = { type: 'cookies', cookies: [] }; + } + const config = { + ...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG, + maxStates: opts.maxStates, + maxDepth: opts.maxDepth, + actionDelayMs: opts.actionDelay, + sessionTimeoutMs: opts.sessionTimeout, + allowedDomains: opts.allowedDomains + ? opts.allowedDomains.split(',').map((d) => d.trim()) + : [new URL(opts.url).hostname], + excludedPaths: opts.excludedPaths + ? opts.excludedPaths.split(',').map((p) => p.trim()) + : [], + auth, + }; + // If remote server mode + if (opts.server) { + await runRemote(opts, config); + return; + } + const anomalies = []; + let exitCode = 0; + let explorationError; + try { + const graph = new StateGraph_1.StateGraph(); + const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: opts.seed, explorationConfig: config }); + const engine = new ExplorationEngine_1.ExplorationEngine({ + graph, + agent, + seed: opts.seed, + url: opts.url, + maxSteps: opts.maxStates, + outputDir: opts.reportsDir, + explorationConfig: config, + collectors: [ + new ScreenshotCollector_1.ScreenshotCollector(opts.reportsDir), + new NetworkCollector_1.NetworkCollector(), + new DOMSnapshotCollector_1.DOMSnapshotCollector(opts.reportsDir), + ], + exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()], + reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(), + events: { + onAnomalyDetected: (_, anomaly) => { + anomalies.push(anomaly); + }, + onSessionError: (_, error) => { + explorationError = error; + }, + }, + }); + await engine.run(); + } + catch (err) { + explorationError = err instanceof Error ? err.message : String(err); + exitCode = 2; + } + if (explorationError && exitCode === 0) + exitCode = 2; + // Determine exit code from flags + if (exitCode === 0 && opts.failOnAnomaly && anomalies.length > 0) { + exitCode = 1; + } + if (exitCode === 0 && opts.failOnSeverity) { + const severityRank = { low: 0, medium: 1, high: 2, critical: 3 }; + const threshold = severityRank[opts.failOnSeverity] ?? 0; + const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold); + if (failing.length > 0) + exitCode = 1; + } + const durationMs = Date.now() - startMs; + const summary = { + url: opts.url, + duration_ms: durationMs, + anomalies: anomalies.map((a) => ({ + id: a.id, + type: a.type, + severity: a.severity, + description: a.description, + report_path: path.join(opts.reportsDir, a.id, 'report.json'), + })), + exit_code: exitCode, + }; + if (opts.output === 'json') { + process.stdout.write(JSON.stringify(summary, null, 2) + '\n'); + } + else if (opts.output === 'junit') { + const xml = buildJunit(summary, opts.url); + const outPath = path.join(process.cwd(), 'abe-results.xml'); + fs.writeFileSync(outPath, xml, 'utf8'); + if (opts.output !== 'json') { + console.log(`JUnit results written to ${outPath}`); + } + } + else { + if (anomalies.length === 0) { + console.log(`✓ ABE finished. No anomalies found. (${durationMs}ms)`); + } + else { + console.log(`⚠ ABE finished. ${anomalies.length} anomaly(ies) found:`); + for (const a of anomalies) { + console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`); + } + } + } + process.exit(exitCode); +}); +async function runRemote(opts, _config) { + const serverUrl = opts['server']; + const apiKey = opts['apiKey']; + const url = opts['url']; + const headers = { 'Content-Type': 'application/json' }; + if (apiKey) + headers['x-abe-api-key'] = apiKey; + const res = await fetch(`${serverUrl}/api/sessions`, { + method: 'POST', + headers, + body: JSON.stringify({ url }), + }); + if (!res.ok) { + console.error(`Server error: ${res.status} ${await res.text()}`); + process.exit(2); + return; + } + const session = await res.json(); + console.log(`Session started: ${session.sessionId}`); + process.exit(0); +} +function buildJunit(summary, url) { + const anomalyCount = summary.anomalies.length; + const cases = summary.anomalies + .map((a) => ` \n` + + ` ${escapeXml(a.id)}\n` + + ` `) + .join('\n'); + return `\n` + + `\n` + + cases + '\n' + + `\n`; +} +function escapeXml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} +program.parse(process.argv); diff --git a/dist/core/AnomalyDetector.js b/dist/core/AnomalyDetector.js new file mode 100644 index 0000000..690d8ff --- /dev/null +++ b/dist/core/AnomalyDetector.js @@ -0,0 +1,137 @@ +"use strict"; +/** + * AnomalyDetector — heuristic rules to detect anomalies from observations. + * Each rule is independent and testable in isolation. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AnomalyDetector = void 0; +let anomalyCounter = 0; +function makeId() { + anomalyCounter += 1; + return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`; +} +class AnomalyDetector { + detect(observation, actionTrace) { + const anomalies = []; + const httpAnomaly = this.checkHttpErrors(observation, actionTrace); + if (httpAnomaly) + anomalies.push(httpAnomaly); + const jsAnomaly = this.checkJsExceptions(observation, actionTrace); + if (jsAnomaly) + anomalies.push(jsAnomaly); + const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace); + if (consoleAnomaly) + anomalies.push(consoleAnomaly); + return anomalies; + } + /** Rule: HTTP 4xx or 5xx responses */ + checkHttpErrors(observation, actionTrace) { + const errorResponses = observation.httpResponses.filter((r) => r.status >= 400); + if (errorResponses.length === 0) + return null; + const hasServerError = errorResponses.some((r) => r.status >= 500); + const severity = hasServerError ? 'high' : 'medium'; + const statusCodes = errorResponses.map((r) => r.status).join(', '); + return this.buildAnomaly({ + type: 'http_error', + severity, + observationId: observation.id, + actionTrace, + description: `HTTP error responses detected: ${statusCodes}`, + evidence: { + httpLog: errorResponses, + rawErrors: errorResponses.map((r) => `${r.method} ${r.url} → ${r.status} (${r.durationMs}ms)`), + }, + }); + } + /** Rule: uncaught JS exceptions */ + checkJsExceptions(observation, actionTrace) { + if (observation.jsExceptions.length === 0) + return null; + return this.buildAnomaly({ + type: 'js_exception', + severity: 'high', + observationId: observation.id, + actionTrace, + description: `Uncaught JS exception: ${observation.jsExceptions[0]}`, + evidence: { + rawErrors: observation.jsExceptions, + }, + }); + } + /** Rule: console.error messages */ + checkConsoleErrors(observation, actionTrace) { + if (observation.consoleErrors.length === 0) + return null; + return this.buildAnomaly({ + type: 'console_error', + severity: 'low', + observationId: observation.id, + actionTrace, + description: `Console error detected: ${observation.consoleErrors[0]}`, + evidence: { + rawErrors: observation.consoleErrors, + }, + }); + } + /** + * Rule: server accepted clearly invalid/empty fuzz input (got 2xx). + * fuzzedValue is the value that was submitted; responseStatus is the HTTP response. + */ + checkValidationBypass(observation, actionTrace, fuzzedValue) { + const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300); + if (!has2xx) + return null; + return this.buildAnomaly({ + type: 'validation_bypass', + severity: 'high', + observationId: observation.id, + actionTrace, + description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`, + evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] }, + }); + } + /** Rule: server returned 500 on a fuzzed input */ + checkServerErrorOnFuzz(observation, actionTrace) { + const has5xx = observation.httpResponses.some((r) => r.status >= 500); + if (!has5xx) + return null; + return this.buildAnomaly({ + type: 'server_error_on_fuzz', + severity: 'high', + observationId: observation.id, + actionTrace, + description: 'Server returned 5xx on fuzzed input', + evidence: { + httpLog: observation.httpResponses.filter((r) => r.status >= 500), + rawErrors: observation.jsExceptions, + }, + }); + } + /** Rule: fuzzed script tag appears in response body (XSS reflection) */ + checkXssReflection(observation, actionTrace, domSnapshot) { + if (!domSnapshot.includes('')) + return null; + return this.buildAnomaly({ + type: 'xss_reflection', + severity: 'critical', + observationId: observation.id, + actionTrace, + description: 'XSS reflection detected: fuzzed script tag appeared in DOM', + evidence: { rawErrors: ['XSS payload reflected in DOM'] }, + }); + } + buildAnomaly(params) { + return { + id: makeId(), + type: params.type, + severity: params.severity, + observationId: params.observationId, + actionTrace: params.actionTrace, + description: params.description, + evidence: params.evidence, + timestamp: Date.now(), + }; + } +} +exports.AnomalyDetector = AnomalyDetector; diff --git a/dist/core/ExplorationConfig.js b/dist/core/ExplorationConfig.js new file mode 100644 index 0000000..0a5479a --- /dev/null +++ b/dist/core/ExplorationConfig.js @@ -0,0 +1,53 @@ +"use strict"; +/** + * ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y, + * performance, visual regression, and network chaos settings for a session. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0; +exports.NETWORK_PROFILES = { + 'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false }, + 'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false }, + '2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false }, + 'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true }, + 'none': null, +}; +exports.DEFAULT_EXPLORATION_CONFIG = { + allowedDomains: [], + maxStates: 50, + maxDepth: 5, + actionDelayMs: 500, + sessionTimeoutMs: 300000, + excludedPaths: [], + excludedSelectors: [], + auth: null, + fuzzingEnabled: true, + fuzzingIntensity: 'medium', + browsers: ['chromium'], + mobileDevice: 'none', + viewport: null, + accessibility: { + enabled: true, + minImpact: 'serious', + wcagLevel: 'AA', + }, + performance: { + enabled: true, + lcpThresholdMs: 4000, + clsThreshold: 0.25, + inpThresholdMs: 500, + ttfbThresholdMs: 1800, + }, + visualRegression: { + enabled: false, + threshold: 0.001, + screenshotFullPage: false, + ignoreSelectors: [], + }, + networkChaos: { + enabled: false, + profile: 'none', + blockedEndpoints: [], + slowEndpoints: [], + }, +}; diff --git a/dist/core/ExplorationEngine.js b/dist/core/ExplorationEngine.js new file mode 100644 index 0000000..551f473 --- /dev/null +++ b/dist/core/ExplorationEngine.js @@ -0,0 +1,197 @@ +"use strict"; +/** + * ExplorationEngine — the core loop of ABE. + * Selects states, executes actions, records observations, and detects anomalies. + * Depends only on core interfaces — never imports concrete plugins. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ExplorationEngine = void 0; +const AnomalyDetector_1 = require("./AnomalyDetector"); +const Logger_1 = require("./Logger"); +class ExplorationEngine { + constructor(config) { + /** Accumulated action trace for the current session */ + this.actionTrace = []; + /** Set to true to abort the running loop */ + this.aborted = false; + this.graph = config.graph; + this.agent = config.agent; + this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector(); + this.collectors = config.collectors ?? []; + this.exporters = config.exporters ?? []; + this.reproducer = config.reproducer; + this.logger = config.logger ?? new Logger_1.NullLogger(); + this.seed = config.seed; + this.url = config.url; + this.maxSteps = config.maxSteps ?? 100; + this.outputDir = config.outputDir ?? './reports'; + this.events = config.events ?? {}; + this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`; + this.explorationConfig = config.explorationConfig ?? {}; + this.fuzzingPlugin = config.fuzzingPlugin; + this.stateHooks = config.stateHooks ?? []; + } + /** Signals the engine to stop after the current step completes. */ + stop() { + this.aborted = true; + } + async run() { + const anomalies = []; + let stepsExecuted = 0; + let depth = 0; + const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0; + const maxDepth = this.explorationConfig.maxDepth ?? Infinity; + const sessionStart = Date.now(); + this.logger.log({ + event: 'session_start', + timestamp: sessionStart, + seed: this.seed, + target: this.url, + }); + this.events.onSessionStarted?.(this.sessionId, this.url); + const isTimedOut = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs; + try { + await this.agent.launch(this.url); + // Capture initial state + const initialState = await this.agent.captureState(); + this.graph.addState(initialState); + this.logger.log({ + event: 'state_discovered', + timestamp: Date.now(), + stateId: initialState.id, + url: initialState.url, + title: initialState.title, + }); + this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title); + while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) { + const currentState = this.graph.getNextToExplore(); + if (!currentState) + break; + // Mark state as being explored + this.graph.incrementVisit(currentState.id); + // Discover available actions in this state + const actions = await this.agent.discoverActions(currentState); + if (actions.length === 0) + continue; + // Select action deterministically using seed + step count + const actionIndex = (this.seed + stepsExecuted) % actions.length; + const action = actions[actionIndex]; + this.logger.log({ + event: 'action_executed', + timestamp: Date.now(), + actionId: action.id, + type: action.type, + selector: action.selector, + value: action.value, + url: action.url, + }); + this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now()); + // Execute action and capture observation + const observation = await this.agent.executeAction(action); + this.actionTrace.push(action); + // Record new state if discovered + if (!this.graph.hasState(observation.newStateId)) { + const newState = await this.agent.captureState(); + this.graph.addState(newState); + depth += 1; + this.logger.log({ + event: 'state_discovered', + timestamp: Date.now(), + stateId: newState.id, + url: newState.url, + title: newState.title, + }); + this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title); + // Run per-state hooks (visual regression, accessibility, performance) + for (const hook of this.stateHooks) { + const hookAnomalies = await hook(newState, this.agent, this.sessionId, [...this.actionTrace]).catch(() => []); + for (const anomaly of hookAnomalies) { + anomalies.push(anomaly); + this.logger.log({ + event: 'anomaly_detected', + timestamp: Date.now(), + anomalyId: anomaly.id, + type: anomaly.type, + severity: anomaly.severity, + }); + this.events.onAnomalyDetected?.(this.sessionId, anomaly); + for (const exporter of this.exporters) { + await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`); + } + } + } + } + this.graph.recordTransition(currentState.id, action, observation.newStateId); + this.logger.log({ + event: 'exploration_step', + timestamp: Date.now(), + stateId: currentState.id, + actionId: action.id, + }); + // Detect anomalies + const detected = this.detector.detect(observation, [...this.actionTrace]); + for (const anomaly of detected) { + for (const collector of this.collectors) { + const evidence = await collector.collect(anomaly, this.agent); + Object.assign(anomaly.evidence, evidence); + } + anomalies.push(anomaly); + this.logger.log({ + event: 'anomaly_detected', + timestamp: Date.now(), + anomalyId: anomaly.id, + type: anomaly.type, + severity: anomaly.severity, + }); + this.events.onAnomalyDetected?.(this.sessionId, anomaly); + for (const exporter of this.exporters) { + const reportDir = `${this.outputDir}/${anomaly.id}`; + await exporter.export(anomaly, reportDir); + } + } + stepsExecuted += 1; + // Run fuzzing if enabled and plugin provided + if (this.fuzzingPlugin && + this.explorationConfig.fuzzingEnabled !== false && + currentState.domSnapshot) { + const fuzzActions = this.fuzzingPlugin.generateFuzzActions(currentState.domSnapshot, currentState); + for (const fuzzAction of fuzzActions) { + if (this.aborted || isTimedOut()) + break; + const fuzzObs = await this.agent.executeAction(fuzzAction); + this.actionTrace.push(fuzzAction); + const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]); + for (const anomaly of fuzzAnomalies) { + for (const collector of this.collectors) { + const evidence = await collector.collect(anomaly, this.agent); + Object.assign(anomaly.evidence, evidence); + } + anomalies.push(anomaly); + this.events.onAnomalyDetected?.(this.sessionId, anomaly); + for (const exporter of this.exporters) { + await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`); + } + } + } + } + } + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.events.onSessionError?.(this.sessionId, msg); + await this.agent.close().catch(() => undefined); + throw err; + } + await this.agent.close(); + const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length; + this.logger.log({ + event: 'session_end', + timestamp: Date.now(), + statesVisited, + anomaliesFound: anomalies.length, + }); + this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length); + return { statesVisited, anomaliesFound: anomalies.length, anomalies }; + } +} +exports.ExplorationEngine = ExplorationEngine; diff --git a/dist/core/Logger.js b/dist/core/Logger.js new file mode 100644 index 0000000..79a9cfc --- /dev/null +++ b/dist/core/Logger.js @@ -0,0 +1,66 @@ +"use strict"; +/** + * Logger — writes structured JSON log events to a .jsonl file. + * One JSON object per line. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NullLogger = exports.FileLogger = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class FileLogger { + constructor(logDir, sessionId) { + const logPath = path.join(logDir, `session_${sessionId}.jsonl`); + fs.mkdirSync(logDir, { recursive: true }); + this.stream = fs.createWriteStream(logPath, { flags: 'a' }); + } + log(event) { + this.stream.write(JSON.stringify(event) + '\n'); + } + close() { + this.stream.end(); + } +} +exports.FileLogger = FileLogger; +/** No-op logger for testing */ +class NullLogger { + constructor() { + this.events = []; + } + log(event) { + this.events.push(event); + } +} +exports.NullLogger = NullLogger; diff --git a/dist/core/StateGraph.js b/dist/core/StateGraph.js new file mode 100644 index 0000000..7592ae6 --- /dev/null +++ b/dist/core/StateGraph.js @@ -0,0 +1,83 @@ +"use strict"; +/** + * StateGraph — manages known states and transitions between them. + * Uses BFS ordering by default for exploration scheduling. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.StateGraph = void 0; +class StateGraph { + constructor() { + this.states = new Map(); + this.transitions = []; + /** Insertion order for BFS */ + this.insertionOrder = []; + } + addState(state) { + if (!this.states.has(state.id)) { + this.states.set(state.id, state); + this.insertionOrder.push(state.id); + } + else { + // Update visit count on revisit + const existing = this.states.get(state.id); + this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 }); + } + } + hasState(stateId) { + return this.states.has(stateId); + } + getState(stateId) { + return this.states.get(stateId); + } + incrementVisit(stateId) { + const state = this.states.get(stateId); + if (state) { + this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 }); + } + } + recordTransition(fromId, action, toId) { + this.transitions.push({ + fromId, + action, + toId, + timestamp: Date.now(), + }); + } + /** Returns all states that have never been visited (visitCount === 0) */ + getUnvisited() { + return this.insertionOrder + .map((id) => this.states.get(id)) + .filter((s) => s.visitCount === 0); + } + /** BFS heuristic: returns the oldest unvisited state, or null if none */ + getNextToExplore() { + const unvisited = this.getUnvisited(); + return unvisited.length > 0 ? unvisited[0] : null; + } + getAllStates() { + return this.insertionOrder.map((id) => this.states.get(id)); + } + getTransitions() { + return [...this.transitions]; + } + toJSON() { + return { + stateCount: this.states.size, + transitionCount: this.transitions.length, + states: this.getAllStates().map((s) => ({ + id: s.id, + url: s.url, + title: s.title, + visitCount: s.visitCount, + })), + transitions: this.transitions.map((t) => ({ + fromId: t.fromId, + toId: t.toId, + actionId: t.action.id, + actionType: t.action.type, + timestamp: t.timestamp, + })), + }; + } +} +exports.StateGraph = StateGraph; diff --git a/dist/core/interfaces.js b/dist/core/interfaces.js new file mode 100644 index 0000000..77ffd62 --- /dev/null +++ b/dist/core/interfaces.js @@ -0,0 +1,6 @@ +"use strict"; +/** + * ABE Core Interfaces + * Core data types only. Must NOT import from src/plugins/. + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/db/AnomalyRepository.js b/dist/db/AnomalyRepository.js new file mode 100644 index 0000000..b6bc1fd --- /dev/null +++ b/dist/db/AnomalyRepository.js @@ -0,0 +1,76 @@ +"use strict"; +/** + * AnomalyRepository — CRUD for anomalies table with filters. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AnomalyRepository = void 0; +function rowToAnomaly(row) { + return { + id: row.id, + sessionId: row.session_id, + type: row.type, + severity: row.severity, + description: row.description, + actionTrace: JSON.parse(row.action_trace_json), + evidence: { + ...JSON.parse(row.evidence_json), + screenshotPath: row.screenshot_path ?? undefined, + domSnapshotPath: row.dom_snapshot_path ?? undefined, + }, + observationId: '', + timestamp: row.detected_at, + }; +} +class AnomalyRepository { + constructor(db) { + this.db = db; + } + create(anomaly, sessionId) { + this.db + .prepare(`INSERT INTO anomalies + (id, session_id, type, severity, description, action_trace_json, evidence_json, screenshot_path, dom_snapshot_path, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(anomaly.id, sessionId, anomaly.type, anomaly.severity, anomaly.description, JSON.stringify(anomaly.actionTrace), JSON.stringify({ httpLog: anomaly.evidence.httpLog, rawErrors: anomaly.evidence.rawErrors }), anomaly.evidence.screenshotPath ?? null, anomaly.evidence.domSnapshotPath ?? null, anomaly.timestamp); + } + findById(id) { + const row = this.db + .prepare('SELECT * FROM anomalies WHERE id = ?') + .get(id); + return row ? rowToAnomaly(row) : undefined; + } + findAll(filters) { + const conditions = []; + const values = []; + if (filters?.sessionId) { + conditions.push('session_id = ?'); + values.push(filters.sessionId); + } + if (filters?.severity) { + conditions.push('severity = ?'); + values.push(filters.severity); + } + if (filters?.type) { + conditions.push('type = ?'); + values.push(filters.type); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const rows = this.db + .prepare(`SELECT * FROM anomalies ${where} ORDER BY detected_at DESC`) + .all(...values); + return rows.map(rowToAnomaly); + } + countBySeverity(severities) { + if (severities.length === 0) + return 0; + const placeholders = severities.map(() => '?').join(', '); + const result = this.db + .prepare(`SELECT COUNT(*) as cnt FROM anomalies WHERE severity IN (${placeholders})`) + .get(...severities); + return result.cnt; + } + count() { + const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get(); + return result.cnt; + } +} +exports.AnomalyRepository = AnomalyRepository; diff --git a/dist/db/ScheduleRepository.js b/dist/db/ScheduleRepository.js new file mode 100644 index 0000000..24b7540 --- /dev/null +++ b/dist/db/ScheduleRepository.js @@ -0,0 +1,82 @@ +"use strict"; +/** + * ScheduleRepository — CRUD for schedules table. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScheduleRepository = void 0; +function rowToRecord(row) { + return { + id: row.id, + name: row.name, + url: row.url, + configJson: row.config_json, + cronExpression: row.cron_expression, + enabled: row.enabled === 1, + lastRunAt: row.last_run_at, + nextRunAt: row.next_run_at, + createdAt: row.created_at, + }; +} +class ScheduleRepository { + constructor(db) { + this.db = db; + } + create(params) { + this.db + .prepare(`INSERT INTO schedules (id, name, url, config_json, cron_expression, enabled, next_run_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(params.id, params.name, params.url, params.configJson, params.cronExpression, params.enabled !== false ? 1 : 0, params.nextRunAt ?? null, Date.now()); + } + findById(id) { + const row = this.db + .prepare('SELECT * FROM schedules WHERE id = ?') + .get(id); + return row ? rowToRecord(row) : undefined; + } + findAll(enabledOnly = false) { + const rows = enabledOnly + ? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all() + : this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all(); + return rows.map(rowToRecord); + } + update(id, fields) { + const sets = []; + const values = []; + if (fields.name !== undefined) { + sets.push('name = ?'); + values.push(fields.name); + } + if (fields.url !== undefined) { + sets.push('url = ?'); + values.push(fields.url); + } + if (fields.configJson !== undefined) { + sets.push('config_json = ?'); + values.push(fields.configJson); + } + if (fields.cronExpression !== undefined) { + sets.push('cron_expression = ?'); + values.push(fields.cronExpression); + } + if (fields.enabled !== undefined) { + sets.push('enabled = ?'); + values.push(fields.enabled ? 1 : 0); + } + if (fields.lastRunAt !== undefined) { + sets.push('last_run_at = ?'); + values.push(fields.lastRunAt); + } + if (fields.nextRunAt !== undefined) { + sets.push('next_run_at = ?'); + values.push(fields.nextRunAt); + } + if (sets.length === 0) + return; + values.push(id); + this.db.prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values); + } + delete(id) { + this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id); + } +} +exports.ScheduleRepository = ScheduleRepository; diff --git a/dist/db/SessionRepository.js b/dist/db/SessionRepository.js new file mode 100644 index 0000000..fd0dc68 --- /dev/null +++ b/dist/db/SessionRepository.js @@ -0,0 +1,53 @@ +"use strict"; +/** + * SessionRepository — CRUD for sessions table. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SessionRepository = void 0; +class SessionRepository { + constructor(db) { + this.db = db; + } + create(params) { + this.db + .prepare(`INSERT INTO sessions (id, url, status, seed, max_states, started_at, config_json) + VALUES (?, ?, 'running', ?, ?, ?, ?)`) + .run(params.id, params.url, params.seed, params.maxStates, params.startedAt, params.configJson ?? '{}'); + } + findById(id) { + return this.db + .prepare('SELECT * FROM sessions WHERE id = ?') + .get(id); + } + findAll() { + return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all(); + } + update(id, fields) { + const sets = []; + const values = []; + if (fields.status !== undefined) { + sets.push('status = ?'); + values.push(fields.status); + } + if (fields.statesVisited !== undefined) { + sets.push('states_visited = ?'); + values.push(fields.statesVisited); + } + if (fields.anomaliesFound !== undefined) { + sets.push('anomalies_found = ?'); + values.push(fields.anomaliesFound); + } + if (fields.finishedAt !== undefined) { + sets.push('finished_at = ?'); + values.push(fields.finishedAt); + } + if (sets.length === 0) + return; + values.push(id); + this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values); + } + delete(id) { + this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id); + } +} +exports.SessionRepository = SessionRepository; diff --git a/dist/db/VisualBaselineRepository.js b/dist/db/VisualBaselineRepository.js new file mode 100644 index 0000000..d15abb2 --- /dev/null +++ b/dist/db/VisualBaselineRepository.js @@ -0,0 +1,77 @@ +"use strict"; +/** + * VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.VisualBaselineRepository = void 0; +class VisualBaselineRepository { + constructor(db) { + this.db = db; + } + // ─── Baselines ──────────────────────────────────────────────────────────── + createBaseline(params) { + this.db.prepare(` + INSERT OR REPLACE INTO visual_baselines (id, state_id, url, screenshot_path, approved_at, approved_by, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(params.id, params.stateId, params.url, params.screenshotPath, Date.now(), params.approvedBy ?? 'user', params.width, params.height); + } + findBaselineByStateId(stateId) { + return this.db + .prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1') + .get(stateId); + } + findBaselineById(id) { + return this.db + .prepare('SELECT * FROM visual_baselines WHERE id = ?') + .get(id); + } + // ─── Comparisons ────────────────────────────────────────────────────────── + createComparison(params) { + this.db.prepare(` + INSERT INTO visual_comparisons + (id, session_id, state_id, baseline_id, current_screenshot_path, diff_screenshot_path, diff_pixels, diff_percent, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(params.id, params.sessionId, params.stateId, params.baselineId ?? null, params.currentScreenshotPath, params.diffScreenshotPath ?? null, params.diffPixels ?? null, params.diffPercent ?? null, params.status, Date.now()); + } + findComparisonById(id) { + return this.db + .prepare('SELECT * FROM visual_comparisons WHERE id = ?') + .get(id); + } + findComparisons(filters) { + const conditions = []; + const values = []; + if (filters?.sessionId) { + conditions.push('session_id = ?'); + values.push(filters.sessionId); + } + if (filters?.status) { + conditions.push('status = ?'); + values.push(filters.status); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + return this.db + .prepare(`SELECT * FROM visual_comparisons ${where} ORDER BY created_at DESC`) + .all(...values); + } + updateComparisonStatus(id, status) { + this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id); + } + promoteToBaseline(comparisonId) { + const comparison = this.findComparisonById(comparisonId); + if (!comparison) + return null; + const baselineId = `baseline_${Date.now()}`; + this.createBaseline({ + id: baselineId, + stateId: comparison.state_id, + url: comparison.session_id, + screenshotPath: comparison.current_screenshot_path, + width: 1280, + height: 720, + }); + this.updateComparisonStatus(comparisonId, 'passed'); + return baselineId; + } +} +exports.VisualBaselineRepository = VisualBaselineRepository; diff --git a/dist/db/connection.js b/dist/db/connection.js new file mode 100644 index 0000000..ea4768f --- /dev/null +++ b/dist/db/connection.js @@ -0,0 +1,43 @@ +"use strict"; +/** + * ABE Database Connection + * Singleton SQLite connection using better-sqlite3. + * Runs migrations on first access. + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getDb = getDb; +exports.setDb = setDb; +exports.closeDb = closeDb; +const better_sqlite3_1 = __importDefault(require("better-sqlite3")); +const path_1 = __importDefault(require("path")); +const fs_1 = __importDefault(require("fs")); +const migrations_1 = require("./migrations"); +let _db = null; +function getDb() { + if (_db) + return _db; + const dbPath = process.env['ABE_DB_PATH'] ?? path_1.default.join(process.cwd(), 'data', 'abe.db'); + const dir = path_1.default.dirname(dbPath); + if (!fs_1.default.existsSync(dir)) { + fs_1.default.mkdirSync(dir, { recursive: true }); + } + _db = new better_sqlite3_1.default(dbPath); + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + (0, migrations_1.runMigrations)(_db); + return _db; +} +/** For testing — inject a custom (in-memory) database instance. */ +function setDb(db) { + _db = db; +} +/** Close and reset. Used in tests. */ +function closeDb() { + if (_db) { + _db.close(); + _db = null; + } +} diff --git a/dist/db/migrations.js b/dist/db/migrations.js new file mode 100644 index 0000000..ba6718d --- /dev/null +++ b/dist/db/migrations.js @@ -0,0 +1,126 @@ +"use strict"; +/** + * ABE Database Migrations + * Creates all tables if they do not exist. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runMigrations = runMigrations; +function runMigrations(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'running', + seed INTEGER NOT NULL, + max_states INTEGER NOT NULL DEFAULT 50, + states_visited INTEGER NOT NULL DEFAULT 0, + anomalies_found INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + finished_at INTEGER, + config_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE IF NOT EXISTS states ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + url TEXT NOT NULL, + title TEXT NOT NULL, + dom_snapshot_path TEXT, + visit_count INTEGER NOT NULL DEFAULT 0, + discovered_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + state_id TEXT NOT NULL REFERENCES states(id), + type TEXT NOT NULL, + selector TEXT, + value TEXT, + url TEXT, + seed INTEGER NOT NULL, + executed_at INTEGER NOT NULL, + sequence_order INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS anomalies ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + type TEXT NOT NULL, + severity TEXT NOT NULL, + description TEXT NOT NULL, + action_trace_json TEXT NOT NULL, + evidence_json TEXT NOT NULL, + screenshot_path TEXT, + dom_snapshot_path TEXT, + detected_at INTEGER NOT NULL, + ai_enrichment_json TEXT, + ai_enriched_at INTEGER, + browser TEXT, + browser_version TEXT + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + anomaly_id TEXT NOT NULL REFERENCES anomalies(id), + channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + sent_at INTEGER, + error TEXT + ); + + CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + config_json TEXT NOT NULL, + cron_expression TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + last_run_at INTEGER, + next_run_at INTEGER, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS visual_baselines ( + id TEXT PRIMARY KEY, + state_id TEXT NOT NULL, + url TEXT NOT NULL, + screenshot_path TEXT NOT NULL, + approved_at INTEGER NOT NULL, + approved_by TEXT DEFAULT 'user', + width INTEGER NOT NULL, + height INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS visual_comparisons ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + state_id TEXT NOT NULL, + baseline_id TEXT, + current_screenshot_path TEXT NOT NULL, + diff_screenshot_path TEXT, + diff_pixels INTEGER, + diff_percent REAL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS performance_metrics ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + state_id TEXT NOT NULL, + url TEXT NOT NULL, + ttfb INTEGER, + dom_content_loaded INTEGER, + load_complete INTEGER, + lcp INTEGER, + cls REAL, + fid INTEGER, + inp INTEGER, + total_requests INTEGER, + failed_requests INTEGER, + total_transfer_size INTEGER, + captured_at INTEGER NOT NULL + ); + `); +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..bb64c2f --- /dev/null +++ b/dist/index.js @@ -0,0 +1,86 @@ +"use strict"; +/** + * ABE — Autonomous Bug Explorer + * Entry point: wires all components together and starts exploration. + * + * Usage: + * npm run explore -- --url http://localhost:3000 --output ./reports + * ts-node src/index.ts http://localhost:3000 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const ExplorationEngine_1 = require("./core/ExplorationEngine"); +const StateGraph_1 = require("./core/StateGraph"); +const Logger_1 = require("./core/Logger"); +const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent"); +const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector"); +const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector"); +const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector"); +const JSONExporter_1 = require("./plugins/exporters/JSONExporter"); +const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter"); +const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer"); +// ─── Parse CLI arguments ───────────────────────────────────────────────────── +function parseArgs() { + const args = process.argv.slice(2); + let url = 'http://localhost:3000'; + let outputDir = './reports'; + let seed = 42; + let maxSteps = 100; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--url' && args[i + 1]) + url = args[++i]; + else if (args[i] === '--output' && args[i + 1]) + outputDir = args[++i]; + else if (args[i] === '--seed' && args[i + 1]) + seed = parseInt(args[++i], 10); + else if (args[i] === '--max-steps' && args[i + 1]) + maxSteps = parseInt(args[++i], 10); + else if (!args[i].startsWith('--')) + url = args[i]; + } + return { url, outputDir, seed, maxSteps }; +} +// ─── Main ───────────────────────────────────────────────────────────────────── +async function main() { + const { url, outputDir, seed, maxSteps } = parseArgs(); + const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`; + const logger = new Logger_1.FileLogger('./logs', sessionId); + const graph = new StateGraph_1.StateGraph(); + const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, headless: true, logger }); + const collectors = [ + new ScreenshotCollector_1.ScreenshotCollector(outputDir), + new NetworkCollector_1.NetworkCollector(), + new DOMSnapshotCollector_1.DOMSnapshotCollector(outputDir), + ]; + const exporters = [ + new JSONExporter_1.JSONExporter(url), + new MarkdownExporter_1.MarkdownExporter(), + ]; + const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer(); + const engine = new ExplorationEngine_1.ExplorationEngine({ + graph, + agent, + collectors, + exporters, + reproducer, + logger, + seed, + url, + maxSteps, + outputDir, + }); + console.log(`[ABE] Starting exploration of ${url} (seed=${seed}, maxSteps=${maxSteps})`); + try { + const result = await engine.run(); + console.log(`[ABE] Exploration complete.`); + console.log(` States visited : ${result.statesVisited}`); + console.log(` Anomalies found: ${result.anomaliesFound}`); + if (result.anomaliesFound > 0) { + console.log(` Reports saved to: ${outputDir}/`); + } + } + catch (err) { + console.error('[ABE] Fatal error:', err); + process.exit(1); + } +} +main(); diff --git a/dist/plugins/agents/PlaywrightAgent.js b/dist/plugins/agents/PlaywrightAgent.js new file mode 100644 index 0000000..5cc2aa7 --- /dev/null +++ b/dist/plugins/agents/PlaywrightAgent.js @@ -0,0 +1,501 @@ +"use strict"; +/** + * PlaywrightAgent — implements IInteractionAgent using Playwright. + * All random choices use a deterministic seed and are logged. + * Supports scope enforcement, auth injection, and action delay. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PlaywrightAgent = void 0; +const crypto = __importStar(require("crypto")); +const playwright_1 = require("playwright"); +const Logger_1 = require("../../core/Logger"); +const ExplorationConfig_1 = require("../../core/ExplorationConfig"); +/** Simple deterministic pseudo-random number generator (LCG) */ +class SeededRandom { + constructor(seed) { + this.state = seed; + } + /** Returns a float in [0, 1) */ + next() { + this.state = (this.state * 1664525 + 1013904223) & 0xffffffff; + return (this.state >>> 0) / 0x100000000; + } + /** Returns an integer in [0, max) */ + nextInt(max) { + return Math.floor(this.next() * max); + } +} +function generateId() { + return crypto.randomUUID(); +} +function domHash(url, domSnapshot) { + return crypto + .createHash('sha1') + .update(url + domSnapshot) + .digest('hex') + .substring(0, 16); +} +class PlaywrightAgent { + constructor(config = {}) { + /** Captured HTTP responses for the current action */ + this.pendingResponses = []; + this.pendingConsoleErrors = []; + this.pendingJsExceptions = []; + this.seed = config.seed ?? 42; + this.rng = new SeededRandom(this.seed); + this.headless = config.headless ?? true; + this.timeoutMs = config.timeoutMs ?? 30000; + this.logger = config.logger ?? new Logger_1.NullLogger(); + this.explorationConfig = config.explorationConfig ?? {}; + } + async launch(url) { + // Select browser type + const browserType = this.explorationConfig.browsers?.[0] ?? 'chromium'; + const launcher = browserType === 'firefox' ? playwright_1.firefox : browserType === 'webkit' ? playwright_1.webkit : playwright_1.chromium; + this.browser = await launcher.launch({ headless: this.headless }); + // Apply auth headers if configured + const auth = this.explorationConfig.auth; + let contextOptions = {}; + // Mobile device emulation + const mobileDevice = this.explorationConfig.mobileDevice; + if (mobileDevice && mobileDevice !== 'none') { + const device = playwright_1.devices[mobileDevice]; + if (device) { + contextOptions = { ...device, ...contextOptions }; + } + } + // Custom viewport + if (this.explorationConfig.viewport) { + contextOptions.viewport = this.explorationConfig.viewport; + } + if (auth?.type === 'headers') { + contextOptions.extraHTTPHeaders = auth.headers; + } + this.context = await this.browser.newContext(contextOptions); + // Apply auth cookies if configured + if (auth?.type === 'cookies') { + await this.context.addCookies(auth.cookies); + } + this.page = await this.context.newPage(); + this.setupListeners(this.page); + // Apply network chaos conditions + await this.applyNetworkChaos(this.page); + // Login flow auth + if (auth?.type === 'login_flow') { + await this.performLoginFlow(auth); + } + await this.page.goto(url, { timeout: this.timeoutMs }); + } + async close() { + await this.browser?.close(); + this.browser = undefined; + this.context = undefined; + this.page = undefined; + } + async captureState() { + const page = this.requirePage(); + const url = page.url(); + const title = await page.title(); + const domSnapshot = await page.evaluate(() => document.body.outerHTML); + const stateId = domHash(url, domSnapshot); + return { + id: stateId, + url, + title, + timestamp: Date.now(), + domSnapshot, + visitCount: 0, + }; + } + async discoverActions(state) { + const page = this.requirePage(); + const actions = []; + const now = Date.now(); + const currentUrl = page.url(); + if (this.isExcludedPath(currentUrl)) { + return []; + } + // Discover clickable elements + const clickableSelectors = [ + 'a[href]', + 'button', + '[role="button"]', + 'input[type="submit"]', + 'input[type="button"]', + ]; + for (const selector of clickableSelectors) { + if (this.isExcludedSelector(selector)) + continue; + const elements = await page.locator(selector).all(); + for (const el of elements) { + const isVisible = await el.isVisible().catch(() => false); + if (!isVisible) + continue; + // Check element against excluded selectors + const elSel = await this.buildSelector(el, selector); + if (this.isExcludedSelector(elSel)) + continue; + // For links, check domain is allowed + if (selector === 'a[href]') { + const href = await el.getAttribute('href').catch(() => null); + if (href && this.isExternalLink(href, currentUrl)) + continue; + } + const actionSeed = this.rng.nextInt(0x7fffffff); + actions.push({ + id: generateId(), + type: 'click', + selector: elSel, + timestamp: now, + seed: actionSeed, + stateId: state.id, + }); + } + } + // Discover fillable inputs + const inputSelectors = [ + 'input[type="text"]', + 'input[type="email"]', + 'input[type="password"]', + 'textarea', + ]; + for (const selector of inputSelectors) { + if (this.isExcludedSelector(selector)) + continue; + const elements = await page.locator(selector).all(); + for (const el of elements) { + const isVisible = await el.isVisible().catch(() => false); + if (!isVisible) + continue; + const elSel = await this.buildSelector(el, selector); + if (this.isExcludedSelector(elSel)) + continue; + const actionSeed = this.rng.nextInt(0x7fffffff); + actions.push({ + id: generateId(), + type: 'fill', + selector: elSel, + value: '', + timestamp: now, + seed: actionSeed, + stateId: state.id, + }); + } + } + return actions; + } + async executeAction(action) { + const page = this.requirePage(); + this.resetPending(); + const observationId = generateId(); + const actionDelayMs = this.explorationConfig.actionDelayMs ?? 0; + // Skip actions targeting excluded paths + if (action.url && this.isExcludedPath(action.url)) { + const state = await this.captureState(); + return this.buildObservation(observationId, action.id, state.id); + } + // Enforce allowed domains for navigate actions + if (action.type === 'navigate' && action.url) { + if (!this.isAllowedUrl(action.url)) { + const state = await this.captureState(); + return this.buildObservation(observationId, action.id, state.id); + } + } + try { + switch (action.type) { + case 'click': + await page.locator(action.selector).first().click({ timeout: this.timeoutMs }); + break; + case 'fill': + await page.locator(action.selector).first().fill(action.value ?? '', { timeout: this.timeoutMs }); + break; + case 'navigate': + await page.goto(action.url, { timeout: this.timeoutMs }); + break; + case 'submit': + await page.locator(action.selector).first().dispatchEvent('submit'); + break; + case 'select': + await page.locator(action.selector).first().selectOption(action.value ?? ''); + break; + } + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.pendingJsExceptions.push(`Action ${action.type} failed: ${msg}`); + } + // Wait for async effects to settle + configured delay + await page.waitForTimeout(200 + actionDelayMs); + const newState = await this.captureState(); + return this.buildObservation(observationId, action.id, newState.id); + } + // ─── Private helpers ────────────────────────────────────────────────────── + getPage() { + return this.requirePage(); + } + requirePage() { + if (!this.page) + throw new Error('PlaywrightAgent: not launched. Call launch() first.'); + return this.page; + } + resetPending() { + this.pendingResponses = []; + this.pendingConsoleErrors = []; + this.pendingJsExceptions = []; + } + buildObservation(observationId, actionId, newStateId) { + return { + id: observationId, + actionId, + newStateId, + httpResponses: [...this.pendingResponses], + consoleErrors: [...this.pendingConsoleErrors], + jsExceptions: [...this.pendingJsExceptions], + timestamp: Date.now(), + }; + } + setupListeners(page) { + const requestTimestamps = new Map(); + page.on('request', (req) => { + requestTimestamps.set(req.url(), Date.now()); + }); + page.on('response', (res) => { + const start = requestTimestamps.get(res.url()) ?? Date.now(); + const durationMs = Date.now() - start; + this.pendingResponses.push({ + url: res.url(), + status: res.status(), + method: res.request().method(), + durationMs, + }); + }); + page.on('console', (msg) => { + if (msg.type() === 'error') { + this.pendingConsoleErrors.push(msg.text()); + } + }); + page.on('pageerror', (err) => { + this.pendingJsExceptions.push(err.message); + }); + } + async buildSelector(el, fallback) { + try { + const id = await el.getAttribute('id').catch(() => null); + if (id) + return `#${id}`; + const name = await el.getAttribute('name').catch(() => null); + if (name) + return `[name="${name}"]`; + } + catch { + // ignore + } + return fallback; + } + isExcludedPath(urlOrPath) { + const excludedPaths = this.explorationConfig.excludedPaths ?? []; + if (excludedPaths.length === 0) + return false; + try { + const parsed = new URL(urlOrPath, 'http://placeholder'); + return excludedPaths.some((p) => parsed.pathname.startsWith(p)); + } + catch { + return false; + } + } + isExcludedSelector(selector) { + const excludedSelectors = this.explorationConfig.excludedSelectors ?? []; + return excludedSelectors.includes(selector); + } + isExternalLink(href, currentUrl) { + const allowedDomains = this.explorationConfig.allowedDomains ?? []; + if (allowedDomains.length === 0) + return false; + try { + const base = new URL(currentUrl); + const target = new URL(href, base.origin); + return !allowedDomains.includes(target.hostname); + } + catch { + return false; + } + } + isAllowedUrl(url) { + const allowedDomains = this.explorationConfig.allowedDomains ?? []; + if (allowedDomains.length === 0) + return true; + try { + const parsed = new URL(url); + return allowedDomains.includes(parsed.hostname); + } + catch { + return false; + } + } + async performLoginFlow(auth) { + const page = this.requirePage(); + await page.goto(auth.loginUrl, { timeout: this.timeoutMs }); + await page.locator(auth.usernameSelector).first().fill(auth.username); + await page.locator(auth.passwordSelector).first().fill(auth.password); + await page.locator(auth.submitSelector).first().click(); + await page.waitForNavigation({ timeout: this.timeoutMs }).catch(() => undefined); + const currentUrl = page.url(); + if (currentUrl === auth.loginUrl || currentUrl.includes(new URL(auth.loginUrl).pathname)) { + throw new Error(`Login failed: still on login page ${currentUrl}`); + } + } + async applyNetworkChaos(page) { + const chaos = this.explorationConfig.networkChaos; + if (!chaos?.enabled) + return; + // Apply network condition via CDP (Chromium only) + const profile = chaos.profile ?? 'none'; + const condition = ExplorationConfig_1.NETWORK_PROFILES[profile]; + if (condition) { + try { + const client = await this.context.newCDPSession(page); + await client.send('Network.emulateNetworkConditions', { + offline: condition.offline, + downloadThroughput: condition.offline ? -1 : (condition.downloadKbps * 1024) / 8, + uploadThroughput: condition.offline ? -1 : (condition.uploadKbps * 1024) / 8, + latency: condition.latencyMs, + }); + } + catch { + // CDP not available (e.g. non-Chromium browser) — ignore + } + } + // Block specified endpoints + if (chaos.blockedEndpoints && chaos.blockedEndpoints.length > 0) { + await page.route('**/*', (route) => { + const url = route.request().url(); + const isBlocked = chaos.blockedEndpoints.some((pattern) => this.matchGlob(url, pattern)); + if (isBlocked) { + route.fulfill({ status: 503, body: 'Service Unavailable (ABE Network Chaos)' }); + } + else { + route.continue(); + } + }); + } + // Slow down specified endpoints + if (chaos.slowEndpoints && chaos.slowEndpoints.length > 0) { + for (const slowEp of chaos.slowEndpoints) { + await page.route(slowEp.pattern, async (route) => { + await new Promise((r) => setTimeout(r, slowEp.delayMs)); + route.continue(); + }); + } + } + } + matchGlob(url, pattern) { + // Convert glob pattern to regex + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + try { + return new RegExp(escaped).test(url); + } + catch { + return false; + } + } + /** + * Detects mobile layout issues on the current page: + * - Touch targets smaller than 44x44px (WCAG 2.5.5 / Apple HIG) + * - Horizontal content overflow beyond viewport width + */ + async detectMobileLayoutIssues(stateId, sessionId, actionTrace) { + const page = this.requirePage(); + const anomalies = []; + try { + const issues = await page.evaluate(() => { + const findings = []; + // Check for horizontal overflow + const docWidth = document.documentElement.scrollWidth; + const viewportWidth = window.innerWidth; + if (docWidth > viewportWidth) { + findings.push(`horizontal_overflow: ${docWidth}px > ${viewportWidth}px viewport`); + } + // Check for small touch targets (< 44x44 px) + const interactiveSelectors = ['a', 'button', '[role="button"]', 'input', 'select', 'textarea']; + const seen = new Set(); + for (const sel of interactiveSelectors) { + for (const el of document.querySelectorAll(sel)) { + if (seen.has(el)) + continue; + seen.add(el); + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) + continue; + if (rect.width < 44 || rect.height < 44) { + const label = el.getAttribute('aria-label') || + el.textContent?.trim().slice(0, 30) || + el.tagName.toLowerCase(); + findings.push(`small_touch_target: "${label}" (${Math.round(rect.width)}x${Math.round(rect.height)}px)`); + if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5) + break; + } + } + if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5) + break; + } + return findings; + }).catch(() => []); + if (issues.length === 0) + return anomalies; + const hasOverflow = issues.some((i) => i.startsWith('horizontal_overflow')); + const smallTargetCount = issues.filter((i) => i.startsWith('small_touch_target')).length; + const severity = hasOverflow ? 'high' : smallTargetCount >= 3 ? 'medium' : 'low'; + anomalies.push({ + id: generateId(), + type: 'mobile_layout_issue', + severity, + observationId: stateId, + actionTrace, + description: `Mobile layout issues detected: ${issues.slice(0, 3).join('; ')}`, + evidence: { rawErrors: issues }, + timestamp: Date.now(), + }); + } + catch { + // Page may be in invalid state — ignore + } + return anomalies; + } +} +exports.PlaywrightAgent = PlaywrightAgent; diff --git a/dist/plugins/collectors/AccessibilityCollector.js b/dist/plugins/collectors/AccessibilityCollector.js new file mode 100644 index 0000000..3385d4b --- /dev/null +++ b/dist/plugins/collectors/AccessibilityCollector.js @@ -0,0 +1,124 @@ +"use strict"; +/** + * AccessibilityCollector — runs axe-core after state changes to detect WCAG violations. + * Converts axe violations to IAnomaly with severity mapped from impact level. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AccessibilityCollector = exports.DEFAULT_A11Y_CONFIG = void 0; +const crypto = __importStar(require("crypto")); +exports.DEFAULT_A11Y_CONFIG = { + enabled: true, + minImpact: 'serious', + wcagLevel: 'AA', +}; +const IMPACT_TO_SEVERITY = { + minor: 'low', + moderate: 'medium', + serious: 'high', + critical: 'critical', +}; +const IMPACT_RANK = { + minor: 0, + moderate: 1, + serious: 2, + critical: 3, +}; +class AccessibilityCollector { + constructor(config = {}) { + this.config = { ...exports.DEFAULT_A11Y_CONFIG, ...config }; + } + async collect(page, stateId, sessionId, actionTrace) { + if (!this.config.enabled) + return []; + try { + const violations = await this.runAxe(page); + const minRank = IMPACT_RANK[this.config.minImpact] ?? 2; + const anomalies = []; + for (const violation of violations) { + const impact = violation.impact ?? 'minor'; + if ((IMPACT_RANK[impact] ?? 0) < minRank) + continue; + const severity = IMPACT_TO_SEVERITY[impact] ?? 'medium'; + anomalies.push({ + id: crypto.randomUUID(), + type: 'accessibility_violation', + severity, + observationId: stateId, + actionTrace, + description: `[axe] ${violation.description}`, + evidence: { + rawErrors: [ + `Rule: ${violation.id}`, + `Impact: ${impact}`, + `Affected nodes: ${violation.nodes.length}`, + `Help: ${violation.helpUrl}`, + ], + }, + timestamp: Date.now(), + }); + } + return anomalies; + } + catch { + // axe might fail if page is not in a valid state + return []; + } + } + async runAxe(page) { + try { + const { AxeBuilder } = await Promise.resolve().then(() => __importStar(require('@axe-core/playwright'))); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + return results.violations; + } + catch { + // Fallback: try via page.evaluate if AxeBuilder fails + const results = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = window; + if (typeof win.axe === 'undefined') + return []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const r = await win.axe.run(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return r.violations; + }).catch(() => []); + return Array.isArray(results) ? results : []; + } + } +} +exports.AccessibilityCollector = AccessibilityCollector; diff --git a/dist/plugins/collectors/DOMSnapshotCollector.js b/dist/plugins/collectors/DOMSnapshotCollector.js new file mode 100644 index 0000000..a1504c8 --- /dev/null +++ b/dist/plugins/collectors/DOMSnapshotCollector.js @@ -0,0 +1,56 @@ +"use strict"; +/** + * DOMSnapshotCollector — writes the DOM snapshot at anomaly moment to disk. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DOMSnapshotCollector = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class DOMSnapshotCollector { + constructor(outputDir = './reports') { + this.outputDir = outputDir; + this.name = 'DOMSnapshotCollector'; + } + async collect(anomaly, agent) { + const state = await agent.captureState(); + const dir = path.join(this.outputDir, anomaly.id); + fs.mkdirSync(dir, { recursive: true }); + const domPath = path.join(dir, 'dom.html'); + fs.writeFileSync(domPath, state.domSnapshot, 'utf8'); + return { domSnapshotPath: path.relative(this.outputDir, domPath) }; + } +} +exports.DOMSnapshotCollector = DOMSnapshotCollector; diff --git a/dist/plugins/collectors/NetworkCollector.js b/dist/plugins/collectors/NetworkCollector.js new file mode 100644 index 0000000..ad80fd0 --- /dev/null +++ b/dist/plugins/collectors/NetworkCollector.js @@ -0,0 +1,18 @@ +"use strict"; +/** + * NetworkCollector — logs all HTTP responses from the current observation. + * The data is already captured in the observation; this collector formats it. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NetworkCollector = void 0; +class NetworkCollector { + constructor() { + this.name = 'NetworkCollector'; + } + async collect(anomaly, _agent) { + // HTTP responses are captured in the observation → anomaly evidence + const httpLog = anomaly.evidence.httpLog ?? []; + return { httpLog }; + } +} +exports.NetworkCollector = NetworkCollector; diff --git a/dist/plugins/collectors/PerformanceCollector.js b/dist/plugins/collectors/PerformanceCollector.js new file mode 100644 index 0000000..eb015b8 --- /dev/null +++ b/dist/plugins/collectors/PerformanceCollector.js @@ -0,0 +1,177 @@ +"use strict"; +/** + * PerformanceCollector — captures Navigation Timing and Core Web Vitals after each navigation. + * Detects performance_degradation anomalies based on configurable thresholds. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PerformanceCollector = exports.DEFAULT_PERF_CONFIG = void 0; +const crypto = __importStar(require("crypto")); +exports.DEFAULT_PERF_CONFIG = { + enabled: true, + lcpThresholdMs: 4000, + clsThreshold: 0.25, + inpThresholdMs: 500, + ttfbThresholdMs: 1800, +}; +class PerformanceCollector { + constructor(config = {}) { + this.metricsStore = []; + this.config = { ...exports.DEFAULT_PERF_CONFIG, ...config }; + } + async collect(page, stateId, sessionId, actionTrace) { + if (!this.config.enabled) { + const empty = { + id: crypto.randomUUID(), sessionId, stateId, url: page.url(), + ttfb: 0, domContentLoaded: 0, loadComplete: 0, + lcp: null, cls: null, fid: null, inp: null, + totalRequests: 0, failedRequests: 0, capturedAt: Date.now(), + }; + return { metrics: empty, anomalies: [] }; + } + // Capture Navigation Timing + const timing = await page.evaluate(() => { + const t = performance.timing; + return { + ttfb: t.responseStart - t.requestStart, + domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart, + loadComplete: t.loadEventEnd - t.navigationStart, + }; + }).catch(() => ({ ttfb: 0, domContentLoaded: 0, loadComplete: 0 })); + // Capture Core Web Vitals via PerformanceObserver + const vitals = await page.evaluate(() => { + return new Promise((resolve) => { + const result = { + lcp: null, cls: null, inp: null, + }; + try { + // Try to observe LCP + if ('PerformanceObserver' in window) { + try { + const lcpObs = new PerformanceObserver((list) => { + const entries = list.getEntries(); + if (entries.length > 0) { + result.lcp = entries[entries.length - 1].startTime; + } + }); + lcpObs.observe({ type: 'largest-contentful-paint', buffered: true }); + } + catch { /* not supported */ } + try { + const clsObs = new PerformanceObserver((list) => { + let clsScore = 0; + for (const entry of list.getEntries()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + clsScore += entry.value ?? 0; + } + result.cls = clsScore; + }); + clsObs.observe({ type: 'layout-shift', buffered: true }); + } + catch { /* not supported */ } + } + } + catch { /* ignore */ } + // Resolve after short wait + setTimeout(() => resolve(result), 500); + }); + }).catch(() => ({ lcp: null, cls: null, inp: null })); + const metrics = { + id: crypto.randomUUID(), + sessionId, + stateId, + url: page.url(), + ttfb: timing.ttfb, + domContentLoaded: timing.domContentLoaded, + loadComplete: timing.loadComplete, + lcp: vitals.lcp, + cls: vitals.cls, + fid: null, + inp: vitals.inp, + totalRequests: 0, + failedRequests: 0, + capturedAt: Date.now(), + }; + this.metricsStore.push(metrics); + const anomalies = this.detectAnomalies(metrics, stateId, actionTrace); + return { metrics, anomalies }; + } + getMetrics() { + return this.metricsStore; + } + detectAnomalies(metrics, stateId, actionTrace) { + const anomalies = []; + const issues = []; + let severityRank = 0; // 0=low,1=medium,2=high,3=critical + if (metrics.lcp !== null && metrics.lcp > this.config.lcpThresholdMs) { + issues.push(`LCP: ${metrics.lcp}ms (threshold: ${this.config.lcpThresholdMs}ms)`); + if (severityRank < 2) + severityRank = 2; // high + } + if (metrics.cls !== null && metrics.cls > this.config.clsThreshold) { + issues.push(`CLS: ${metrics.cls.toFixed(3)} (threshold: ${this.config.clsThreshold})`); + if (severityRank < 1) + severityRank = 1; // medium + } + if (metrics.inp !== null && metrics.inp > this.config.inpThresholdMs) { + issues.push(`INP: ${metrics.inp}ms (threshold: ${this.config.inpThresholdMs}ms)`); + if (severityRank < 2) + severityRank = 2; // high + } + if (metrics.ttfb > this.config.ttfbThresholdMs) { + issues.push(`TTFB: ${metrics.ttfb}ms (threshold: ${this.config.ttfbThresholdMs}ms)`); + if (severityRank < 1) + severityRank = 1; // medium + } + const RANK_TO_SEVERITY = ['low', 'medium', 'high', 'critical']; + const maxSeverity = RANK_TO_SEVERITY[severityRank] ?? 'low'; + if (issues.length === 0) + return anomalies; + anomalies.push({ + id: crypto.randomUUID(), + type: 'performance_degradation', + severity: maxSeverity, + observationId: stateId, + actionTrace, + description: `Performance degradation at ${metrics.url}: ${issues[0]}`, + evidence: { + rawErrors: issues, + }, + timestamp: Date.now(), + }); + return anomalies; + } +} +exports.PerformanceCollector = PerformanceCollector; diff --git a/dist/plugins/collectors/ScreenshotCollector.js b/dist/plugins/collectors/ScreenshotCollector.js new file mode 100644 index 0000000..be59ded --- /dev/null +++ b/dist/plugins/collectors/ScreenshotCollector.js @@ -0,0 +1,63 @@ +"use strict"; +/** + * ScreenshotCollector — captures a PNG screenshot at anomaly moment. + * Requires the agent to be a PlaywrightAgent (duck-typing check). + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ScreenshotCollector = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +function isPlaywrightAgent(agent) { + return typeof agent.getPage === 'function'; +} +class ScreenshotCollector { + constructor(outputDir = './reports') { + this.outputDir = outputDir; + this.name = 'ScreenshotCollector'; + } + async collect(anomaly, agent) { + if (!isPlaywrightAgent(agent)) { + return {}; + } + const page = agent.getPage(); + const dir = path.join(this.outputDir, anomaly.id); + fs.mkdirSync(dir, { recursive: true }); + const screenshotPath = path.join(dir, 'screenshot.png'); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return { screenshotPath: path.relative(this.outputDir, screenshotPath) }; + } +} +exports.ScreenshotCollector = ScreenshotCollector; diff --git a/dist/plugins/collectors/VisualRegressionCollector.js b/dist/plugins/collectors/VisualRegressionCollector.js new file mode 100644 index 0000000..f3a8755 --- /dev/null +++ b/dist/plugins/collectors/VisualRegressionCollector.js @@ -0,0 +1,155 @@ +"use strict"; +/** + * VisualRegressionCollector — captures screenshots and compares against baselines. + * Uses pixelmatch for pixel-level comparison and sharp for image normalization. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.VisualRegressionCollector = exports.DEFAULT_VISUAL_CONFIG = void 0; +exports.compareScreenshots = compareScreenshots; +const crypto = __importStar(require("crypto")); +const path = __importStar(require("path")); +const fs = __importStar(require("fs")); +exports.DEFAULT_VISUAL_CONFIG = { + enabled: true, + threshold: 0.001, + screenshotFullPage: false, + ignoreSelectors: [], +}; +async function compareScreenshots(baselinePath, currentPath, diffOutputPath, threshold = 0.1) { + // Dynamic imports to avoid loading heavy deps at startup + const sharp = (await Promise.resolve().then(() => __importStar(require('sharp')))).default; + const pixelmatch = (await Promise.resolve().then(() => __importStar(require('pixelmatch')))).default; + const [baselineRaw, currentRaw] = await Promise.all([ + sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }), + sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }), + ]); + const { width, height } = baselineRaw.info; + const diffBuffer = Buffer.alloc(width * height * 4); + const diffPixels = pixelmatch(baselineRaw.data, currentRaw.data, diffBuffer, width, height, { threshold }); + const totalPixels = width * height; + const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0; + // Write diff image + await sharp(diffBuffer, { raw: { width, height, channels: 4 } }) + .png() + .toFile(diffOutputPath); + return { diffPixels, diffPercent, hasDiff: diffPixels > 0 }; +} +class VisualRegressionCollector { + constructor(outputDir, repo, config = {}) { + this.outputDir = outputDir; + this.repo = repo; + this.config = { ...exports.DEFAULT_VISUAL_CONFIG, ...config }; + } + /** + * Process a screenshot for visual regression. + * Returns an anomaly if a regression is detected, otherwise null. + */ + async processScreenshot(screenshotPath, state, sessionId, actionTrace) { + if (!this.config.enabled) + return null; + const comparisonId = crypto.randomUUID(); + const baseline = this.repo.findBaselineByStateId(state.id); + if (!baseline) { + // No baseline: create a new_state comparison record + this.repo.createComparison({ + id: comparisonId, + sessionId, + stateId: state.id, + currentScreenshotPath: screenshotPath, + status: 'new_state', + }); + return null; + } + // Compare against baseline + const diffDir = path.join(this.outputDir, 'visual', comparisonId); + if (!fs.existsSync(diffDir)) { + fs.mkdirSync(diffDir, { recursive: true }); + } + const diffPath = path.join(diffDir, 'diff.png'); + let diffPixels = 0; + let diffPercent = 0; + try { + const result = await compareScreenshots(baseline.screenshot_path, screenshotPath, diffPath, this.config.threshold); + diffPixels = result.diffPixels; + diffPercent = result.diffPercent; + } + catch { + // If comparison fails (e.g. image format issues), skip + return null; + } + const thresholdPct = this.config.threshold; + const status = diffPercent > thresholdPct ? 'failed' : 'passed'; + this.repo.createComparison({ + id: comparisonId, + sessionId, + stateId: state.id, + baselineId: baseline.id, + currentScreenshotPath: screenshotPath, + diffScreenshotPath: status === 'failed' ? diffPath : undefined, + diffPixels, + diffPercent, + status, + }); + if (status !== 'failed') + return null; + // Determine severity from diff percent + const pct = diffPercent * 100; + let severity; + if (pct > 15) + severity = 'critical'; + else if (pct > 5) + severity = 'high'; + else if (pct > 1) + severity = 'medium'; + else + severity = 'low'; + const anomaly = { + id: crypto.randomUUID(), + type: 'visual_regression', + severity, + observationId: state.id, + actionTrace, + description: `Visual regression detected: ${(pct).toFixed(2)}% of pixels changed`, + evidence: { + screenshotPath: diffPath, + rawErrors: [`Diff: ${diffPixels} pixels (${(pct).toFixed(2)}%)`], + }, + timestamp: Date.now(), + }; + return anomaly; + } +} +exports.VisualRegressionCollector = VisualRegressionCollector; diff --git a/dist/plugins/exporters/JSONExporter.js b/dist/plugins/exporters/JSONExporter.js new file mode 100644 index 0000000..bfc21e8 --- /dev/null +++ b/dist/plugins/exporters/JSONExporter.js @@ -0,0 +1,97 @@ +"use strict"; +/** + * JSONExporter — produces a structured JSON report for AI debugging workflows. + * Output: reports/{anomaly-id}/report.json + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JSONExporter = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const os = __importStar(require("os")); +class JSONExporter { + constructor(targetUrl = '', abeVersion = '0.1.0') { + this.targetUrl = targetUrl; + this.abeVersion = abeVersion; + this.format = 'json'; + } + async export(anomaly, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + const report = { + version: '1.0', + generated_at: new Date(anomaly.timestamp).toISOString(), + environment: { + target_url: this.targetUrl, + abe_version: this.abeVersion, + os: os.platform(), + node_version: process.version, + }, + anomaly: { + id: anomaly.id, + type: anomaly.type, + severity: anomaly.severity, + description: anomaly.description, + timestamp: anomaly.timestamp, + }, + reproduction: { + seed: anomaly.actionTrace[0]?.seed ?? null, + steps: anomaly.actionTrace.map((action, index) => ({ + step: index + 1, + action_type: action.type, + selector: action.selector, + value: action.value, + url: action.url, + timestamp: action.timestamp, + })), + }, + evidence: { + screenshot: anomaly.evidence.screenshotPath ?? null, + dom_snapshot: anomaly.evidence.domSnapshotPath ?? null, + http_log: (anomaly.evidence.httpLog ?? []).map((r) => ({ + url: r.url, + method: r.method, + status: r.status, + duration_ms: r.durationMs, + })), + console_errors: anomaly.evidence.rawErrors?.filter((e) => e.startsWith('console:')) ?? [], + js_exceptions: anomaly.evidence.rawErrors?.filter((e) => !e.startsWith('console:')) ?? [], + }, + }; + const filePath = path.join(outputDir, 'report.json'); + fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf8'); + return filePath; + } +} +exports.JSONExporter = JSONExporter; diff --git a/dist/plugins/exporters/MarkdownExporter.js b/dist/plugins/exporters/MarkdownExporter.js new file mode 100644 index 0000000..37224a1 --- /dev/null +++ b/dist/plugins/exporters/MarkdownExporter.js @@ -0,0 +1,113 @@ +"use strict"; +/** + * MarkdownExporter — produces a human-readable bug report. + * Output: reports/{anomaly-id}/report.md + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MarkdownExporter = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class MarkdownExporter { + constructor() { + this.format = 'markdown'; + } + async export(anomaly, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + const date = new Date(anomaly.timestamp).toISOString().split('T')[0]; + const seed = anomaly.actionTrace[0]?.seed ?? 'N/A'; + const replayCmd = `npm run replay -- --report ${outputDir}/report.json`; + const steps = anomaly.actionTrace + .map((action, i) => { + switch (action.type) { + case 'navigate': + return `${i + 1}. Navigate to \`${action.url}\``; + case 'click': + return `${i + 1}. Click element \`${action.selector}\``; + case 'fill': + return `${i + 1}. Fill \`${action.selector}\` with \`${JSON.stringify(action.value ?? '')}\``; + case 'select': + return `${i + 1}. Select \`${action.value}\` in \`${action.selector}\``; + case 'submit': + return `${i + 1}. Submit form \`${action.selector}\``; + default: + return `${i + 1}. ${action.type}`; + } + }) + .join('\n'); + const httpTable = (anomaly.evidence.httpLog ?? []).length > 0 + ? [ + '| Method | URL | Status | Duration |', + '|--------|-----|--------|----------|', + ...(anomaly.evidence.httpLog ?? []).map((r) => `| ${r.method} | ${r.url} | ${r.status} | ${r.durationMs}ms |`), + ].join('\n') + : '_No HTTP log available._'; + const rawErrors = (anomaly.evidence.rawErrors ?? []).length > 0 + ? '```\n' + anomaly.evidence.rawErrors.join('\n') + '\n```' + : '_No raw errors recorded._'; + const md = `# Bug Report — ${anomaly.type} — ${date} + +## Summary +${anomaly.description} + +## Severity +**${anomaly.severity}** — detected by ABE heuristic rule \`${anomaly.type}\` + +## Reproduction Steps + +${steps.length > 0 ? steps : '_No steps recorded._'} + +**Seed used**: \`${seed}\` +**Replay command**: \`${replayCmd}\` + +## Observed Behavior +${anomaly.description} + +## Evidence +- Screenshot: \`${anomaly.evidence.screenshotPath ?? 'N/A'}\` +- DOM Snapshot: \`${anomaly.evidence.domSnapshotPath ?? 'N/A'}\` +- HTTP Log: + +${httpTable} + +## Raw Errors +${rawErrors} +`; + const filePath = path.join(outputDir, 'report.md'); + fs.writeFileSync(filePath, md, 'utf8'); + return filePath; + } +} +exports.MarkdownExporter = MarkdownExporter; diff --git a/dist/plugins/fuzzers/FuzzingEngine.js b/dist/plugins/fuzzers/FuzzingEngine.js new file mode 100644 index 0000000..55db7c2 --- /dev/null +++ b/dist/plugins/fuzzers/FuzzingEngine.js @@ -0,0 +1,139 @@ +"use strict"; +/** + * FuzzingEngine — orchestrates fuzzing strategies for form inputs. + * Implements IFuzzingPlugin so ExplorationEngine doesn't need to import it directly. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FuzzingEngine = void 0; +const crypto = __importStar(require("crypto")); +const InputTypeDetector_1 = require("./InputTypeDetector"); +const EmptyValueStrategy_1 = require("./strategies/EmptyValueStrategy"); +const OversizedStringStrategy_1 = require("./strategies/OversizedStringStrategy"); +const SpecialCharsStrategy_1 = require("./strategies/SpecialCharsStrategy"); +const TypeMismatchStrategy_1 = require("./strategies/TypeMismatchStrategy"); +const BoundaryValueStrategy_1 = require("./strategies/BoundaryValueStrategy"); +/** Regex to match basic input elements in an HTML string */ +const INPUT_RE = /<(input|textarea|select)[^>]*>/gi; +const ATTR_RE = (name) => new RegExp(`${name}="([^"]*)"`, 'i'); +function extractFields(domSnapshot) { + const fields = []; + let match; + while ((match = INPUT_RE.exec(domSnapshot)) !== null) { + const tag = match[0] ?? ''; + const tagName = match[1] ?? 'input'; + const idMatch = ATTR_RE('id').exec(tag); + const nameMatch = ATTR_RE('name').exec(tag); + const typeMatch = ATTR_RE('type').exec(tag); + const placeholderMatch = ATTR_RE('placeholder').exec(tag); + const ariaMatch = ATTR_RE('aria-label').exec(tag); + const selector = idMatch?.[1] + ? `#${idMatch[1]}` + : nameMatch?.[1] + ? `[name="${nameMatch[1]}"]` + : tagName; + fields.push({ + selector, + tagName, + inputType: typeMatch?.[1], + name: nameMatch?.[1], + placeholder: placeholderMatch?.[1], + ariaLabel: ariaMatch?.[1], + }); + } + return fields; +} +class FuzzingEngine { + constructor(config) { + this.intensity = config.intensity; + this.seed = config.seed; + } + /** IFuzzingPlugin implementation — parses fields from DOM snapshot */ + generateFuzzActions(domSnapshot, state) { + const fields = extractFields(domSnapshot); + return this.generateFuzzActionsForFields(fields, state); + } + /** Generate fuzz actions from explicit field descriptors */ + generateFuzzActionsForFields(fields, state) { + const actions = []; + const now = Date.now(); + const strategies = this.selectStrategies(); + for (const field of fields) { + const detectedType = (0, InputTypeDetector_1.detectInputType)({ + tagName: field.tagName, + inputType: field.inputType, + name: field.name, + placeholder: field.placeholder, + ariaLabel: field.ariaLabel, + }); + for (const strategy of strategies) { + const values = this.getValuesFromStrategy(strategy, detectedType); + for (const value of values) { + actions.push({ + id: crypto.randomUUID(), + type: 'fill', + selector: field.selector, + value, + timestamp: now, + seed: this.seed, + stateId: state.id, + }); + } + } + } + return actions; + } + selectStrategies() { + const empty = new EmptyValueStrategy_1.EmptyValueStrategy(); + const typeMismatch = new TypeMismatchStrategy_1.TypeMismatchStrategy(); + const oversized = new OversizedStringStrategy_1.OversizedStringStrategy(this.intensity); + const boundary = new BoundaryValueStrategy_1.BoundaryValueStrategy(); + const special = new SpecialCharsStrategy_1.SpecialCharsStrategy(); + switch (this.intensity) { + case 'low': + return [empty, typeMismatch]; + case 'medium': + return [empty, typeMismatch, oversized, boundary]; + case 'high': + return [empty, typeMismatch, oversized, boundary, special]; + } + } + getValuesFromStrategy(strategy, type) { + if (!strategy.appliesTo(type)) + return []; + return strategy.values(type); + } +} +exports.FuzzingEngine = FuzzingEngine; diff --git a/dist/plugins/fuzzers/InputTypeDetector.js b/dist/plugins/fuzzers/InputTypeDetector.js new file mode 100644 index 0000000..62e6fb9 --- /dev/null +++ b/dist/plugins/fuzzers/InputTypeDetector.js @@ -0,0 +1,52 @@ +"use strict"; +/** + * InputTypeDetector — detects field type from DOM attributes. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.detectInputType = detectInputType; +/** Detect type from input[type], name, placeholder, aria-label */ +function detectInputType(attrs) { + const tag = (attrs.tagName ?? '').toLowerCase(); + if (tag === 'textarea') + return 'textarea'; + if (tag === 'select') + return 'select'; + const inputType = (attrs.inputType ?? '').toLowerCase(); + if (inputType === 'email') + return 'email'; + if (inputType === 'password') + return 'password'; + if (inputType === 'number') + return 'number'; + if (inputType === 'date') + return 'date'; + if (inputType === 'tel') + return 'phone'; + if (inputType === 'url') + return 'url'; + if (inputType === 'search') + return 'search'; + if (inputType === 'file') + return 'file'; + // Infer from name/placeholder/aria-label + const hints = [ + (attrs.name ?? '').toLowerCase(), + (attrs.placeholder ?? '').toLowerCase(), + (attrs.ariaLabel ?? '').toLowerCase(), + ].join(' '); + if (/email/.test(hints)) + return 'email'; + if (/password|pass/.test(hints)) + return 'password'; + if (/phone|tel|mobile/.test(hints)) + return 'phone'; + if (/date|birth|dob/.test(hints)) + return 'date'; + if (/number|qty|quantity|age/.test(hints)) + return 'number'; + if (/search/.test(hints)) + return 'search'; + if (/url|website|link/.test(hints)) + return 'url'; + return 'text'; +} diff --git a/dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js b/dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js new file mode 100644 index 0000000..70f8a1f --- /dev/null +++ b/dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js @@ -0,0 +1,26 @@ +"use strict"; +/** + * BoundaryValueStrategy — tests values at the edges of expected ranges. + * Applies to: number, date. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BoundaryValueStrategy = void 0; +class BoundaryValueStrategy { + constructor() { + this.name = 'BoundaryValueStrategy'; + } + appliesTo(type) { + return type === 'number' || type === 'date'; + } + values(type) { + switch (type) { + case 'number': + return ['0', '-1', '2147483647', '2147483648', '-2147483648']; + case 'date': + return ['1900-01-01', '2099-12-31', '1970-01-01']; + default: + return []; + } + } +} +exports.BoundaryValueStrategy = BoundaryValueStrategy; diff --git a/dist/plugins/fuzzers/strategies/EmptyValueStrategy.js b/dist/plugins/fuzzers/strategies/EmptyValueStrategy.js new file mode 100644 index 0000000..91b1c7e --- /dev/null +++ b/dist/plugins/fuzzers/strategies/EmptyValueStrategy.js @@ -0,0 +1,19 @@ +"use strict"; +/** + * EmptyValueStrategy — submits empty/whitespace values to catch missing server-side validation. + * Applies to: all input types. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EmptyValueStrategy = void 0; +class EmptyValueStrategy { + constructor() { + this.name = 'EmptyValueStrategy'; + } + appliesTo(_type) { + return true; + } + values() { + return ['', ' ', '\t']; + } +} +exports.EmptyValueStrategy = EmptyValueStrategy; diff --git a/dist/plugins/fuzzers/strategies/OversizedStringStrategy.js b/dist/plugins/fuzzers/strategies/OversizedStringStrategy.js new file mode 100644 index 0000000..c0b732c --- /dev/null +++ b/dist/plugins/fuzzers/strategies/OversizedStringStrategy.js @@ -0,0 +1,28 @@ +"use strict"; +/** + * OversizedStringStrategy — submits strings far beyond expected length. + * Applies to: text, email, password, textarea. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OversizedStringStrategy = void 0; +const APPLICABLE_TYPES = ['text', 'email', 'password', 'textarea']; +class OversizedStringStrategy { + constructor(intensity) { + this.intensity = intensity; + this.name = 'OversizedStringStrategy'; + } + appliesTo(type) { + return APPLICABLE_TYPES.includes(type); + } + values() { + switch (this.intensity) { + case 'low': + return ['A'.repeat(256)]; + case 'medium': + return ['A'.repeat(1024)]; + case 'high': + return ['A'.repeat(10000) + '日本語テスト𠮷野家']; + } + } +} +exports.OversizedStringStrategy = OversizedStringStrategy; diff --git a/dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js b/dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js new file mode 100644 index 0000000..f8a2886 --- /dev/null +++ b/dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js @@ -0,0 +1,26 @@ +"use strict"; +/** + * SpecialCharsStrategy — injects characters that break SQL, HTML, and shell contexts. + * Applies to: text, email, search, textarea. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SpecialCharsStrategy = void 0; +const APPLICABLE_TYPES = ['text', 'email', 'search', 'textarea']; +class SpecialCharsStrategy { + constructor() { + this.name = 'SpecialCharsStrategy'; + } + appliesTo(type) { + return APPLICABLE_TYPES.includes(type); + } + values() { + return [ + "' OR 1=1 --", + '', + '../../etc/passwd', + '${7*7}', + '\x00\x01\x02', + ]; + } +} +exports.SpecialCharsStrategy = SpecialCharsStrategy; diff --git a/dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js b/dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js new file mode 100644 index 0000000..4f16390 --- /dev/null +++ b/dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js @@ -0,0 +1,31 @@ +"use strict"; +/** + * TypeMismatchStrategy — submits wrong data types for the detected field type. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TypeMismatchStrategy = void 0; +class TypeMismatchStrategy { + constructor() { + this.name = 'TypeMismatchStrategy'; + } + appliesTo(type) { + return ['email', 'number', 'date', 'url', 'phone'].includes(type); + } + values(type) { + switch (type) { + case 'email': + return ['not-an-email', '12345', '@@@']; + case 'number': + return ['abc', '-999999', '9.9.9', 'NaN']; + case 'date': + return ['yesterday', '32/13/2025', '0000-00-00']; + case 'url': + return ['javascript:alert(1)', 'not a url']; + case 'phone': + return ['000', '++++', 'abcdefghij']; + default: + return []; + } + } +} +exports.TypeMismatchStrategy = TypeMismatchStrategy; diff --git a/dist/plugins/interfaces.js b/dist/plugins/interfaces.js new file mode 100644 index 0000000..83e4cf9 --- /dev/null +++ b/dist/plugins/interfaces.js @@ -0,0 +1,7 @@ +"use strict"; +/** + * Plugin interfaces re-exported from core for plugin implementations to use. + * All interface definitions live in src/core/interfaces.ts so that core + * code can depend on them without creating a circular dependency. + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/plugins/reproducers/PlaywrightReproducer.js b/dist/plugins/reproducers/PlaywrightReproducer.js new file mode 100644 index 0000000..26753b0 --- /dev/null +++ b/dist/plugins/reproducers/PlaywrightReproducer.js @@ -0,0 +1,59 @@ +"use strict"; +/** + * PlaywrightReproducer — serializes an action trace and generates a + * deterministic Playwright script for replay. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PlaywrightReproducer = void 0; +class PlaywrightReproducer { + serialize(trace) { + return JSON.stringify(trace, null, 2); + } + deserialize(raw) { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error('PlaywrightReproducer.deserialize: expected a JSON array'); + } + return parsed; + } + generateScript(trace) { + const lines = [ + '// Auto-generated replay script by ABE (Autonomous Bug Explorer)', + `// Generated at: ${new Date().toISOString()}`, + `// Steps: ${trace.length}`, + '', + "const { chromium } = require('playwright');", + '', + '(async () => {', + ' const browser = await chromium.launch({ headless: true });', + ' const context = await browser.newContext();', + ' const page = await context.newPage();', + '', + ]; + for (let i = 0; i < trace.length; i++) { + const action = trace[i]; + lines.push(` // Step ${i + 1}: ${action.type} (seed=${action.seed})`); + switch (action.type) { + case 'navigate': + lines.push(` await page.goto(${JSON.stringify(action.url)});`); + break; + case 'click': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().click();`); + break; + case 'fill': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().fill(${JSON.stringify(action.value ?? '')});`); + break; + case 'select': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().selectOption(${JSON.stringify(action.value ?? '')});`); + break; + case 'submit': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().dispatchEvent('submit');`); + break; + } + lines.push(''); + } + lines.push(" console.log('Replay complete');", ' await browser.close();', '})();'); + return lines.join('\n'); + } +} +exports.PlaywrightReproducer = PlaywrightReproducer; diff --git a/dist/replay.js b/dist/replay.js new file mode 100644 index 0000000..872eaa3 --- /dev/null +++ b/dist/replay.js @@ -0,0 +1,84 @@ +"use strict"; +/** + * ABE Replay Script Runner + * Loads a report.json and executes the generated Playwright replay script. + * + * Usage: + * npm run replay -- --report reports/anom_xyz/report.json + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer"); +function parseArgs() { + const args = process.argv.slice(2); + let reportPath = ''; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--report' && args[i + 1]) + reportPath = args[++i]; + } + if (!reportPath) { + console.error('Usage: npm run replay -- --report '); + process.exit(1); + } + return { reportPath }; +} +async function main() { + const { reportPath } = parseArgs(); + if (!fs.existsSync(reportPath)) { + console.error(`Report not found: ${reportPath}`); + process.exit(1); + } + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + // Reconstruct action trace from report steps + const trace = report.reproduction.steps.map((step) => ({ + id: `replay_step_${step.step}`, + type: step.action_type, + selector: step.selector, + value: step.value, + url: step.url, + timestamp: step.timestamp, + seed: report.reproduction.seed ?? 42, + stateId: 'replay', + })); + const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer(); + const script = reproducer.generateScript(trace); + const scriptPath = path.join(path.dirname(reportPath), 'replay.js'); + fs.writeFileSync(scriptPath, script, 'utf8'); + console.log(`[ABE Replay] Script written to: ${scriptPath}`); + console.log(`[ABE Replay] Run with: node ${scriptPath}`); +} +main(); diff --git a/dist/server/SessionStore.js b/dist/server/SessionStore.js new file mode 100644 index 0000000..81c9e1f --- /dev/null +++ b/dist/server/SessionStore.js @@ -0,0 +1,398 @@ +"use strict"; +/** + * SessionStore — manages active sessions and persists to SQLite. + * In-memory map for running engines; DB for durable storage. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SessionStore = void 0; +const path_1 = __importDefault(require("path")); +const ExplorationEngine_1 = require("../core/ExplorationEngine"); +const StateGraph_1 = require("../core/StateGraph"); +const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent"); +const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector"); +const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector"); +const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector"); +const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter"); +const JSONExporter_1 = require("../plugins/exporters/JSONExporter"); +const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer"); +const FuzzingEngine_1 = require("../plugins/fuzzers/FuzzingEngine"); +const VisualRegressionCollector_1 = require("../plugins/collectors/VisualRegressionCollector"); +const AccessibilityCollector_1 = require("../plugins/collectors/AccessibilityCollector"); +const PerformanceCollector_1 = require("../plugins/collectors/PerformanceCollector"); +class SessionStore { + constructor(outputDir = './reports', sessionRepo, anomalyRepo, maxConcurrentSessions = 3, notificationService, visualRepo) { + this.sessions = new Map(); + this.emitter = () => undefined; + /** In-memory performance metrics keyed by sessionId */ + this.performanceMetrics = new Map(); + this.outputDir = outputDir; + this.sessionRepo = sessionRepo ?? null; + this.anomalyRepo = anomalyRepo ?? null; + this.maxConcurrentSessions = maxConcurrentSessions; + this.notificationService = notificationService ?? null; + this.visualRepo = visualRepo ?? null; + } + getPerformanceMetrics(sessionId) { + return this.performanceMetrics.get(sessionId) ?? []; + } + getMaxConcurrent() { + return this.maxConcurrentSessions; + } + setEmitter(emitter) { + this.emitter = emitter; + } + getAllSessions() { + if (this.sessionRepo) { + const rows = this.sessionRepo.findAll(); + return rows.map((r) => { + const live = this.sessions.get(r.id); + return { + sessionId: r.id, + url: r.url, + seed: r.seed, + maxStates: r.max_states, + status: r.status, + startedAt: new Date(r.started_at).toISOString(), + finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined, + statesVisited: r.states_visited, + anomaliesFound: r.anomalies_found, + anomalies: live?.anomalies ?? [], + engine: live?.engine, + }; + }); + } + return Array.from(this.sessions.values()); + } + getSession(sessionId) { + if (this.sessionRepo) { + const r = this.sessionRepo.findById(sessionId); + if (!r) + return undefined; + const live = this.sessions.get(sessionId); + return { + sessionId: r.id, + url: r.url, + seed: r.seed, + maxStates: r.max_states, + status: r.status, + startedAt: new Date(r.started_at).toISOString(), + finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined, + statesVisited: r.states_visited, + anomaliesFound: r.anomalies_found, + anomalies: live?.anomalies ?? [], + engine: live?.engine, + }; + } + return this.sessions.get(sessionId); + } + getAllAnomalies(sessionId, severity) { + if (this.anomalyRepo) { + return this.anomalyRepo.findAll({ sessionId, severity }); + } + const all = Array.from(this.sessions.values()).flatMap((s) => s.anomalies); + return all + .filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId) + .filter((a) => !severity || a.severity === severity); + } + getAnomaly(anomalyId) { + if (this.anomalyRepo) { + return this.anomalyRepo.findById(anomalyId) ?? undefined; + } + for (const session of this.sessions.values()) { + const found = session.anomalies.find((a) => a.id === anomalyId); + if (found) + return found; + } + return undefined; + } + findSessionForAnomaly(anomalyId) { + if (this.anomalyRepo) { + const a = this.anomalyRepo.findById(anomalyId); + return a?.sessionId; + } + for (const session of this.sessions.values()) { + if (session.anomalies.some((a) => a.id === anomalyId)) + return session.sessionId; + } + return undefined; + } + screenshotPath(anomalyId) { + const anomaly = this.getAnomaly(anomalyId); + if (!anomaly?.evidence.screenshotPath) + return undefined; + const sessionId = this.findSessionForAnomaly(anomalyId); + if (!sessionId) + return undefined; + return path_1.default.resolve(this.outputDir, anomalyId, anomaly.evidence.screenshotPath); + } + stopSession(sessionId) { + const record = this.sessions.get(sessionId); + if (!record || record.status !== 'running') + return false; + record.engine?.stop(); + record.status = 'stopped'; + record.finishedAt = new Date().toISOString(); + this.sessionRepo?.update(sessionId, { status: 'stopped', finishedAt: Date.now() }); + return true; + } + getStats() { + if (this.sessionRepo && this.anomalyRepo) { + const rows = this.sessionRepo.findAll(); + return { + totalSessions: rows.length, + totalAnomalies: this.anomalyRepo.count(), + criticalHighCount: this.anomalyRepo.countBySeverity(['high', 'critical']), + runningSessions: rows.filter((r) => r.status === 'running').length, + }; + } + const sessions = Array.from(this.sessions.values()); + const anomalies = sessions.flatMap((s) => s.anomalies); + return { + totalSessions: sessions.length, + totalAnomalies: anomalies.length, + criticalHighCount: anomalies.filter((a) => a.severity === 'high' || a.severity === 'critical').length, + runningSessions: sessions.filter((s) => s.status === 'running').length, + }; + } + async startSession(params) { + const sessionId = `sess_${Date.now()}_${params.seed}`; + const startedAt = new Date().toISOString(); + const startedAtMs = Date.now(); + const record = { + sessionId, + url: params.url, + seed: params.seed, + maxStates: params.maxStates, + status: 'running', + startedAt, + statesVisited: 0, + anomaliesFound: 0, + anomalies: [], + }; + this.sessions.set(sessionId, record); + this.sessionRepo?.create({ + id: sessionId, + url: params.url, + seed: params.seed, + maxStates: params.maxStates, + startedAt: startedAtMs, + configJson: params.explorationConfig ? JSON.stringify(params.explorationConfig) : '{}', + }); + this.emitter('session:started', { sessionId, url: params.url }); + const graph = new StateGraph_1.StateGraph(); + const agent = new PlaywrightAgent_1.PlaywrightAgent({ + seed: params.seed, + explorationConfig: params.explorationConfig, + }); + const fuzzingEnabled = params.explorationConfig?.fuzzingEnabled !== false; + const fuzzingIntensity = params.explorationConfig?.fuzzingIntensity ?? 'medium'; + const fuzzingPlugin = fuzzingEnabled + ? new FuzzingEngine_1.FuzzingEngine({ intensity: fuzzingIntensity, seed: params.seed }) + : undefined; + // Build state hooks for visual regression, accessibility, and performance + const stateHooks = []; + // Visual regression hook + if (params.explorationConfig?.visualRegression?.enabled && this.visualRepo) { + const visualCollector = new VisualRegressionCollector_1.VisualRegressionCollector(this.outputDir, this.visualRepo, params.explorationConfig.visualRegression); + stateHooks.push(async (state, agentInstance, sid, actionTrace) => { + const pw = agentInstance; + if (!pw.getPage) + return []; + // Take screenshot for visual comparison + const screenshotPath = path_1.default.join(this.outputDir, sid, `visual_${state.id}.png`); + try { + const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises'))); + await fs_mod.mkdir(path_1.default.dirname(screenshotPath), { recursive: true }); + await pw.getPage().screenshot({ path: screenshotPath }); + const anomaly = await visualCollector.processScreenshot(screenshotPath, state, sid, actionTrace); + return anomaly ? [anomaly] : []; + } + catch { + return []; + } + }); + } + // Accessibility hook + if (params.explorationConfig?.accessibility?.enabled !== false) { + const a11yCollector = new AccessibilityCollector_1.AccessibilityCollector(params.explorationConfig?.accessibility); + stateHooks.push(async (state, agentInstance, sid, actionTrace) => { + const pw = agentInstance; + if (!pw.getPage) + return []; + return a11yCollector.collect(pw.getPage(), state.id, sid, actionTrace); + }); + } + // Performance hook + if (params.explorationConfig?.performance?.enabled !== false) { + const perfCollector = new PerformanceCollector_1.PerformanceCollector(params.explorationConfig?.performance); + this.performanceMetrics.set(sessionId, []); + stateHooks.push(async (state, agentInstance, sid, actionTrace) => { + const pw = agentInstance; + if (!pw.getPage) + return []; + const { metrics, anomalies } = await perfCollector.collect(pw.getPage(), state.id, sid, actionTrace); + const existing = this.performanceMetrics.get(sid) ?? []; + existing.push(metrics); + this.performanceMetrics.set(sid, existing); + return anomalies; + }); + } + // Mobile layout hook (only when a mobile device is emulated) + if (params.explorationConfig?.mobileDevice && params.explorationConfig.mobileDevice !== 'none') { + stateHooks.push(async (state, agentInstance, sid, actionTrace) => { + const pw = agentInstance; + if (!pw.detectMobileLayoutIssues) + return []; + return pw.detectMobileLayoutIssues(state.id, sid, actionTrace); + }); + } + const engineConfig = { + graph, + agent, + seed: params.seed, + url: params.url, + maxSteps: params.maxStates, + explorationConfig: params.explorationConfig, + outputDir: this.outputDir, + sessionId, + fuzzingPlugin, + stateHooks, + collectors: [ + new ScreenshotCollector_1.ScreenshotCollector(this.outputDir), + new NetworkCollector_1.NetworkCollector(), + new DOMSnapshotCollector_1.DOMSnapshotCollector(this.outputDir), + ], + exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()], + reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(), + events: { + onSessionStarted: (sid, url) => { + this.emitter('session:started', { sessionId: sid, url }); + }, + onStateDiscovered: (sid, stateId, url, title) => { + record.statesVisited += 1; + this.sessionRepo?.update(sid, { statesVisited: record.statesVisited }); + this.emitter('state:discovered', { sessionId: sid, stateId, url, title }); + }, + onActionExecuted: (sid, actionType, selector, timestamp) => { + this.emitter('action:executed', { sessionId: sid, actionType, selector, timestamp }); + }, + onAnomalyDetected: (sid, anomaly) => { + record.anomalies.push(anomaly); + record.anomaliesFound = record.anomalies.length; + this.sessionRepo?.update(sid, { anomaliesFound: record.anomaliesFound }); + this.anomalyRepo?.create(anomaly, sid); + if (this.notificationService) { + void this.notificationService.notify(anomaly, sid, params.url); + } + this.emitter('anomaly:detected', { + sessionId: sid, + anomalyId: anomaly.id, + type: anomaly.type, + severity: anomaly.severity, + description: anomaly.description, + }); + }, + onSessionCompleted: (sid, statesVisited, anomaliesFound) => { + if (record.status === 'running') { + record.status = 'completed'; + } + record.finishedAt = new Date().toISOString(); + record.statesVisited = statesVisited; + record.anomaliesFound = anomaliesFound; + this.sessionRepo?.update(sid, { + status: record.status, + statesVisited, + anomaliesFound, + finishedAt: Date.now(), + }); + this.emitter('session:completed', { sessionId: sid, statesVisited, anomaliesFound }); + }, + onSessionError: (sid, error) => { + record.status = 'error'; + record.finishedAt = new Date().toISOString(); + this.sessionRepo?.update(sid, { status: 'error', finishedAt: Date.now() }); + this.emitter('session:error', { sessionId: sid, error }); + }, + }, + }; + const engine = new ExplorationEngine_1.ExplorationEngine(engineConfig); + record.engine = engine; + // Run in background — do not await + engine.run().catch((err) => { + if (record.status === 'running') { + record.status = 'error'; + record.finishedAt = new Date().toISOString(); + const msg = err instanceof Error ? err.message : String(err); + this.sessionRepo?.update(sessionId, { status: 'error', finishedAt: Date.now() }); + this.emitter('session:error', { sessionId, error: msg }); + } + }); + return record; + } + async replayAnomaly(anomalyId) { + const replayId = `replay_${Date.now()}`; + const anomaly = this.getAnomaly(anomalyId); + if (!anomaly) + throw new Error(`Anomaly ${anomalyId} not found`); + const sessionId = this.findSessionForAnomaly(anomalyId); + const session = this.getSession(sessionId); + const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer(); + const script = reproducer.generateScript(anomaly.actionTrace); + const replayDir = path_1.default.join(this.outputDir, anomalyId, 'replays'); + setImmediate(async () => { + try { + const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises'))); + await fs_mod.mkdir(replayDir, { recursive: true }); + const scriptPath = path_1.default.join(replayDir, `${replayId}.ts`); + await fs_mod.writeFile(scriptPath, script, 'utf8'); + const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: session.seed }); + await agent.launch(session.url); + for (const action of anomaly.actionTrace) { + await agent.executeAction(action).catch(() => undefined); + } + await agent.close(); + } + catch { + // replay errors are non-fatal + } + }); + return replayId; + } +} +exports.SessionStore = SessionStore; diff --git a/dist/server/enrichment/AIEnrichmentService.js b/dist/server/enrichment/AIEnrichmentService.js new file mode 100644 index 0000000..a938612 --- /dev/null +++ b/dist/server/enrichment/AIEnrichmentService.js @@ -0,0 +1,71 @@ +"use strict"; +/** + * AIEnrichmentService — selects AI provider and runs enrichment asynchronously. + * Triggered manually (POST /api/anomalies/:id/enrich) or automatically for high/critical. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AIEnrichmentService = void 0; +const ClaudeProvider_1 = require("./ClaudeProvider"); +const OpenAIProvider_1 = require("./OpenAIProvider"); +const OllamaProvider_1 = require("./OllamaProvider"); +const logger_1 = require("../logger"); +class AIEnrichmentService { + constructor(emitter) { + this.emitter = emitter; + this.autoEnrich = process.env['ABE_AI_AUTO_ENRICH'] === 'true'; + const minSev = process.env['ABE_AI_MIN_SEVERITY'] ?? 'high'; + this.minSeverityRank = AIEnrichmentService.SEVERITY_RANK[minSev] ?? 2; + this.provider = this.createProvider(); + } + createProvider() { + const providerName = process.env['ABE_AI_PROVIDER'] ?? 'none'; + const model = process.env['ABE_AI_MODEL']; + if (providerName === 'claude') { + const key = process.env['ABE_AI_API_KEY']; + if (!key) + return null; + return new ClaudeProvider_1.ClaudeProvider(key, model); + } + if (providerName === 'openai') { + const key = process.env['ABE_OPENAI_API_KEY']; + if (!key) + return null; + return new OpenAIProvider_1.OpenAIProvider(key, model); + } + if (providerName === 'ollama') { + const url = process.env['ABE_OLLAMA_URL'] ?? 'http://localhost:11434'; + return new OllamaProvider_1.OllamaProvider(url, model); + } + return null; + } + /** Check if auto-enrichment should run for this anomaly. */ + shouldAutoEnrich(anomaly) { + if (!this.autoEnrich || !this.provider) + return false; + const rank = AIEnrichmentService.SEVERITY_RANK[anomaly.severity] ?? 0; + return rank >= this.minSeverityRank; + } + /** Enrich an anomaly asynchronously and emit WebSocket event when done. */ + async enrich(anomaly, context) { + if (!this.provider) { + logger_1.log.warn({ anomalyId: anomaly.id }, 'No AI provider configured'); + return; + } + try { + const enrichment = await this.provider.enrich(anomaly, context); + anomaly.aiEnrichment = enrichment; + this.emitter('anomaly:enriched', { anomalyId: anomaly.id, enrichment }); + logger_1.log.info({ anomalyId: anomaly.id, provider: this.provider.name }, 'Anomaly enriched'); + } + catch (err) { + logger_1.log.error({ anomalyId: anomaly.id, err: err instanceof Error ? err.message : String(err) }, 'AI enrichment failed'); + } + } + hasProvider() { + return this.provider !== null; + } +} +exports.AIEnrichmentService = AIEnrichmentService; +AIEnrichmentService.SEVERITY_RANK = { + low: 0, medium: 1, high: 2, critical: 3, +}; diff --git a/dist/server/enrichment/ClaudeProvider.js b/dist/server/enrichment/ClaudeProvider.js new file mode 100644 index 0000000..a8a2d58 --- /dev/null +++ b/dist/server/enrichment/ClaudeProvider.js @@ -0,0 +1,88 @@ +"use strict"; +/** + * ClaudeProvider — AI enrichment using Anthropic API. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ClaudeProvider = void 0; +const DEFAULT_MODEL = 'claude-haiku-4-5-20251001'; +class ClaudeProvider { + constructor(apiKey, model = DEFAULT_MODEL) { + this.name = 'claude'; + this.apiKey = apiKey; + this.model = model; + } + async enrich(anomaly, context) { + const prompt = buildPrompt(anomaly, context); + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!res.ok) { + throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + const text = data.content.find((c) => c.type === 'text')?.text ?? ''; + return parseEnrichment(text, this.name, this.model); + } +} +exports.ClaudeProvider = ClaudeProvider; +function buildPrompt(anomaly, context) { + return `You are a senior software engineer analyzing a bug report from an automated web testing tool. + +Bug Report: +- Type: ${anomaly.type} +- Severity: ${anomaly.severity} +- Description: ${anomaly.description} +- URL: ${context.url} +- Page Title: ${context.pageTitle} +- Action Trace: ${JSON.stringify(anomaly.actionTrace.slice(-5), null, 2)} +${context.httpLog.length > 0 ? `- HTTP Log: ${JSON.stringify(context.httpLog.slice(-3), null, 2)}` : ''} +${context.consoleErrors.length > 0 ? `- Console Errors: ${context.consoleErrors.slice(-3).join('\n')}` : ''} + +Please provide a concise analysis in exactly this JSON format: +{ + "rootCause": "One sentence explaining the likely root cause", + "userImpact": "One sentence describing the impact on users", + "suggestedFix": "One to two sentences with a concrete fix suggestion", + "confidence": "low|medium|high" +}`; +} +function parseEnrichment(text, provider, model) { + const debugPrompt = `Bug analysis:\n${text}`; + try { + const match = text.match(/\{[\s\S]*\}/); + if (match) { + const parsed = JSON.parse(match[0]); + return { + rootCause: parsed.rootCause ?? 'Unknown root cause', + userImpact: parsed.userImpact ?? 'Unknown impact', + suggestedFix: parsed.suggestedFix ?? 'No fix suggested', + debugPrompt, + confidence: parsed.confidence ?? 'medium', + generatedAt: Date.now(), + provider, + model, + }; + } + } + catch { /* fallback below */ } + return { + rootCause: text.slice(0, 200) || 'Could not parse root cause', + userImpact: 'See full response', + suggestedFix: 'See full response', + debugPrompt, + confidence: 'low', + generatedAt: Date.now(), + provider, + model, + }; +} diff --git a/dist/server/enrichment/OllamaProvider.js b/dist/server/enrichment/OllamaProvider.js new file mode 100644 index 0000000..044bd1d --- /dev/null +++ b/dist/server/enrichment/OllamaProvider.js @@ -0,0 +1,63 @@ +"use strict"; +/** + * OllamaProvider — AI enrichment using local Ollama API. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OllamaProvider = void 0; +const DEFAULT_MODEL = 'llama3.2'; +const DEFAULT_URL = 'http://localhost:11434'; +function buildPrompt(anomaly, context) { + return `Analyze this bug and respond ONLY with JSON {"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}. + +Bug: ${anomaly.type} (${anomaly.severity}) at ${context.url} +Description: ${anomaly.description} +Last actions: ${anomaly.actionTrace.slice(-3).map((a) => `${a.type} ${a.selector ?? a.url ?? ''}`).join(' → ')}`; +} +class OllamaProvider { + constructor(baseUrl = DEFAULT_URL, model = DEFAULT_MODEL) { + this.name = 'ollama'; + this.baseUrl = baseUrl; + this.model = model; + } + async enrich(anomaly, context) { + const prompt = buildPrompt(anomaly, context); + const res = await fetch(`${this.baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.model, prompt, stream: false }), + }); + if (!res.ok) { + throw new Error(`Ollama API error: ${res.status}`); + } + const data = await res.json(); + const text = data.response ?? ''; + try { + const match = text.match(/\{[\s\S]*\}/); + if (match) { + const p = JSON.parse(match[0]); + return { + rootCause: p['rootCause'] ?? 'Unknown', + userImpact: p['userImpact'] ?? 'Unknown', + suggestedFix: p['suggestedFix'] ?? 'None', + debugPrompt: text, + confidence: p['confidence'] ?? 'low', + generatedAt: Date.now(), + provider: 'ollama', + model: this.model, + }; + } + } + catch { /* fallback */ } + return { + rootCause: text.slice(0, 200), + userImpact: 'See response', + suggestedFix: 'See response', + debugPrompt: text, + confidence: 'low', + generatedAt: Date.now(), + provider: 'ollama', + model: this.model, + }; + } +} +exports.OllamaProvider = OllamaProvider; diff --git a/dist/server/enrichment/OpenAIProvider.js b/dist/server/enrichment/OpenAIProvider.js new file mode 100644 index 0000000..aaa74a0 --- /dev/null +++ b/dist/server/enrichment/OpenAIProvider.js @@ -0,0 +1,81 @@ +"use strict"; +/** + * OpenAIProvider — AI enrichment using OpenAI API. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OpenAIProvider = void 0; +const DEFAULT_MODEL = 'gpt-4o-mini'; +function buildPrompt(anomaly, context) { + return `You are a senior software engineer analyzing a bug report. + +Bug: +- Type: ${anomaly.type} +- Severity: ${anomaly.severity} +- Description: ${anomaly.description} +- URL: ${context.url} +- Actions: ${JSON.stringify(anomaly.actionTrace.slice(-5))} +${context.httpLog.length > 0 ? `- HTTP: ${JSON.stringify(context.httpLog.slice(-3))}` : ''} +${context.consoleErrors.length > 0 ? `- Errors: ${context.consoleErrors.slice(-3).join('; ')}` : ''} + +Respond ONLY with JSON: +{"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}`; +} +function parseResponse(text, model) { + try { + const match = text.match(/\{[\s\S]*\}/); + if (match) { + const p = JSON.parse(match[0]); + return { + rootCause: p['rootCause'] ?? 'Unknown', + userImpact: p['userImpact'] ?? 'Unknown', + suggestedFix: p['suggestedFix'] ?? 'None', + debugPrompt: text, + confidence: p['confidence'] ?? 'medium', + generatedAt: Date.now(), + provider: 'openai', + model, + }; + } + } + catch { /* fallback */ } + return { + rootCause: text.slice(0, 200), + userImpact: 'See response', + suggestedFix: 'See response', + debugPrompt: text, + confidence: 'low', + generatedAt: Date.now(), + provider: 'openai', + model, + }; +} +class OpenAIProvider { + constructor(apiKey, model = DEFAULT_MODEL) { + this.name = 'openai'; + this.apiKey = apiKey; + this.model = model; + } + async enrich(anomaly, context) { + const prompt = buildPrompt(anomaly, context); + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages: [{ role: 'user', content: prompt }], + max_tokens: 512, + response_format: { type: 'json_object' }, + }), + }); + if (!res.ok) { + throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + const text = data.choices[0]?.message?.content ?? ''; + return parseResponse(text, this.model); + } +} +exports.OpenAIProvider = OpenAIProvider; diff --git a/dist/server/index.js b/dist/server/index.js new file mode 100644 index 0000000..ba14b0e --- /dev/null +++ b/dist/server/index.js @@ -0,0 +1,199 @@ +"use strict"; +/** + * ABE API Server + * Express + socket.io on port 3001. + * Manages exploration sessions and serves REST + WebSocket API. + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createApp = createApp; +exports.createServer = createServer; +const express_1 = __importDefault(require("express")); +const cors_1 = __importDefault(require("cors")); +const http_1 = __importDefault(require("http")); +const express_rate_limit_1 = __importDefault(require("express-rate-limit")); +const socket_io_1 = require("socket.io"); +const sessions_1 = require("./routes/sessions"); +const anomalies_1 = require("./routes/anomalies"); +const config_1 = require("./routes/config"); +const schedules_1 = require("./routes/schedules"); +const visual_1 = require("./routes/visual"); +const SessionStore_1 = require("./SessionStore"); +const auth_1 = require("./middleware/auth"); +const logger_1 = require("./logger"); +const AIEnrichmentService_1 = require("./enrichment/AIEnrichmentService"); +const PORT = process.env['ABE_PORT'] + ? parseInt(process.env['ABE_PORT'], 10) + : process.env['PORT'] + ? parseInt(process.env['PORT'], 10) + : 3001; +function createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService) { + const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173'; + const app = (0, express_1.default)(); + app.use((0, cors_1.default)({ origin: corsOrigin })); + app.use(express_1.default.json()); + // Health endpoints — no auth required + app.get('/health', (_req, res) => { + const uptime = Math.floor(process.uptime()); + res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime }); + }); + app.get('/ready', (_req, res) => { + const stats = store.getStats(); + if (dbCheck && !dbCheck()) { + res.status(503).json({ status: 'not_ready', db: 'disconnected', active_sessions: stats.runningSessions }); + return; + } + res.json({ status: 'ready', db: 'connected', active_sessions: stats.runningSessions }); + }); + // Apply API key auth to all /api/* routes + app.use('/api', auth_1.apiKeyAuth); + // Global rate limit: 200 req/min + const globalLimiter = (0, express_rate_limit_1.default)({ + windowMs: 60 * 1000, + max: 200, + standardHeaders: true, + legacyHeaders: false, + }); + app.use('/api', globalLimiter); + // POST /api/sessions rate limit: 20/hour + const sessionCreateLimiter = (0, express_rate_limit_1.default)({ + windowMs: 60 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + }); + app.post('/api/sessions', sessionCreateLimiter); + app.get('/api/stats', (_req, res) => { + res.json(store.getStats()); + }); + app.use('/api/sessions', (0, sessions_1.createSessionRouter)(store)); + app.use('/api/anomalies', (0, anomalies_1.createAnomalyRouter)(store, enrichmentService)); + app.use('/api/config', (0, config_1.createConfigRouter)()); + if (scheduleRepo && scheduler) { + app.use('/api/schedules', (0, schedules_1.createScheduleRouter)(scheduleRepo, scheduler)); + } + if (visualRepo) { + app.use('/api/visual', (0, visual_1.createVisualRouter)(visualRepo)); + } + // Global error handler + app.use((err, _req, res, _next) => { + const isDev = process.env['NODE_ENV'] !== 'production'; + const message = isDev && err instanceof Error ? err.message : 'Internal server error'; + res.status(500).json({ + error: message, + code: 'INTERNAL_ERROR', + timestamp: Date.now(), + }); + }); + return app; +} +function createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo) { + const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173'; + // Deferred emitter: AIEnrichmentService is created before io, using a closure + let ioEmit = () => undefined; + const enrichmentService = new AIEnrichmentService_1.AIEnrichmentService((event, payload) => ioEmit(event, payload)); + const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService); + const httpServer = http_1.default.createServer(app); + const io = new socket_io_1.Server(httpServer, { + cors: { origin: corsOrigin }, + }); + // Now wire the real io emitter + ioEmit = (event, payload) => io.emit(event, payload); + io.on('connection', (socket) => { + socket.on('session:stop', (data) => { + store.stopSession(data.sessionId); + }); + }); + store.setEmitter((event, payload) => { + io.emit(event, payload); + // Auto-enrich high/critical anomalies + if (event === 'anomaly:detected') { + const p = payload; + if (p?.anomalyId) { + const anomaly = store.getAnomaly(p.anomalyId); + if (anomaly && enrichmentService.shouldAutoEnrich(anomaly)) { + const context = { + domSnapshot: '', + httpLog: anomaly.evidence.httpLog ?? [], + consoleErrors: anomaly.evidence.rawErrors ?? [], + actionTrace: anomaly.actionTrace, + pageTitle: '', + url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '', + }; + void enrichmentService.enrich(anomaly, context); + } + } + } + }); + return httpServer; +} +if (require.main === module) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getDb } = require('../db/connection'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { SessionRepository } = require('../db/SessionRepository'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AnomalyRepository } = require('../db/AnomalyRepository'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { NotificationService } = require('./notifications/NotificationService'); + const db = getDb(); + const sessionRepo = new SessionRepository(db); + const anomalyRepo = new AnomalyRepository(db); + const notificationService = new NotificationService({ + persister: (record) => { + db.prepare(`INSERT OR REPLACE INTO notifications (id, anomaly_id, channel, status, sent_at, error) + VALUES (?, ?, ?, ?, ?, ?)`).run(record.id, record.anomalyId, record.channel, record.status, record.sentAt ?? null, record.error ?? null); + }, + }); + const outputDir = process.env['ABE_REPORTS_DIR'] ?? './reports'; + const maxConcurrent = process.env['ABE_MAX_CONCURRENT_SESSIONS'] + ? parseInt(process.env['ABE_MAX_CONCURRENT_SESSIONS'], 10) + : 3; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { VisualBaselineRepository: VisualRepo } = require('../db/VisualBaselineRepository'); + const visualRepo = new VisualRepo(db); + const store = new SessionStore_1.SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo); + const dbCheck = () => { try { + db.prepare('SELECT 1').run(); + return true; + } + catch { + return false; + } }; + // Scheduler + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ScheduleRepository: SchedRepo } = require('../db/ScheduleRepository'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService'); + const scheduleRepo = new SchedRepo(db); + const scheduler = new SchedSvc(scheduleRepo, store); + scheduler.start(); + const server = createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo); + // Graceful shutdown + let shuttingDown = false; + function shutdown(signal) { + if (shuttingDown) + return; + shuttingDown = true; + logger_1.log.info({ signal }, 'Graceful shutdown initiated'); + scheduler.stop(); + server.close(() => { + try { + db.close(); + } + catch { /* ignore */ } + process.exit(0); + }); + setTimeout(() => { + logger_1.log.error('Forced shutdown after 30s'); + process.exit(1); + }, 30000); + } + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + server.listen(PORT, () => { + logger_1.log.info({ port: PORT }, 'ABE API server listening'); + }); +} diff --git a/dist/server/logger.js b/dist/server/logger.js new file mode 100644 index 0000000..891a5e6 --- /dev/null +++ b/dist/server/logger.js @@ -0,0 +1,13 @@ +"use strict"; +/** + * Structured logger using pino. + * Log level configurable via ABE_LOG_LEVEL env var (default: 'info'). + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.log = void 0; +const pino_1 = __importDefault(require("pino")); +const level = process.env['ABE_LOG_LEVEL'] ?? 'info'; +exports.log = (0, pino_1.default)({ level }); diff --git a/dist/server/middleware/auth.js b/dist/server/middleware/auth.js new file mode 100644 index 0000000..d107e60 --- /dev/null +++ b/dist/server/middleware/auth.js @@ -0,0 +1,21 @@ +"use strict"; +/** + * API Key authentication middleware. + * Reads ABE_API_KEY env var; if not set, dev mode (no auth). + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.apiKeyAuth = apiKeyAuth; +function apiKeyAuth(req, res, next) { + const apiKey = process.env['ABE_API_KEY']; + if (!apiKey) { + // Dev mode: no auth required + next(); + return; + } + const provided = req.headers['x-abe-api-key']; + if (!provided || provided !== apiKey) { + res.status(401).json({ error: 'Invalid or missing API key' }); + return; + } + next(); +} diff --git a/dist/server/notifications/NotificationService.js b/dist/server/notifications/NotificationService.js new file mode 100644 index 0000000..996b1db --- /dev/null +++ b/dist/server/notifications/NotificationService.js @@ -0,0 +1,121 @@ +"use strict"; +/** + * NotificationService — orchestrates notifiers. + * Called after every anomaly:detected event. + * Persists notification attempts to the notifications table. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NotificationService = void 0; +const crypto = __importStar(require("crypto")); +const SlackNotifier_1 = require("./SlackNotifier"); +const WebhookNotifier_1 = require("./WebhookNotifier"); +const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 }; +class NotificationService { + constructor(config) { + const slackUrl = config?.slackWebhookUrl ?? process.env['ABE_SLACK_WEBHOOK_URL']; + const webhookUrl = config?.webhookUrl ?? process.env['ABE_WEBHOOK_URL']; + const minSeverity = config?.minSeverity ?? process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high'; + const frontendBase = config?.frontendBaseUrl ?? process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173'; + if (slackUrl) + this.slack = new SlackNotifier_1.SlackNotifier(slackUrl, frontendBase); + if (webhookUrl) + this.webhook = new WebhookNotifier_1.WebhookNotifier(webhookUrl); + this.minSeverityRank = SEVERITY_RANK[minSeverity] ?? 2; + this.persister = config?.persister; + } + async notify(anomaly, sessionId, targetUrl) { + const anomalySeverityRank = SEVERITY_RANK[anomaly.severity] ?? 0; + if (anomalySeverityRank < this.minSeverityRank) + return; + const sends = []; + if (this.slack) { + sends.push(this.sendWithRetry('slack', anomaly, sessionId, targetUrl)); + } + if (this.webhook) { + sends.push(this.sendWithRetry('webhook', anomaly, sessionId, targetUrl)); + } + await Promise.allSettled(sends); + } + async sendWithRetry(channel, anomaly, sessionId, targetUrl) { + const record = { + id: crypto.randomUUID(), + anomalyId: anomaly.id, + channel, + status: 'pending', + }; + try { + await this.doSend(channel, anomaly, sessionId, targetUrl); + record.status = 'success'; + record.sentAt = Date.now(); + this.persister?.(record); + } + catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + // Retry once after 60s + setTimeout(async () => { + const retryRecord = { + id: crypto.randomUUID(), + anomalyId: anomaly.id, + channel, + status: 'pending', + }; + try { + await this.doSend(channel, anomaly, sessionId, targetUrl); + retryRecord.status = 'success'; + retryRecord.sentAt = Date.now(); + this.persister?.(retryRecord); + } + catch (retryErr) { + retryRecord.status = 'failed'; + retryRecord.error = retryErr instanceof Error ? retryErr.message : String(retryErr); + this.persister?.(retryRecord); + } + }, 60000); + record.status = 'failed'; + record.error = errMsg; + this.persister?.(record); + } + } + async doSend(channel, anomaly, sessionId, targetUrl) { + if (channel === 'slack' && this.slack) { + await this.slack.send(anomaly, sessionId, targetUrl); + } + else if (channel === 'webhook' && this.webhook) { + await this.webhook.send(anomaly); + } + } +} +exports.NotificationService = NotificationService; diff --git a/dist/server/notifications/SlackNotifier.js b/dist/server/notifications/SlackNotifier.js new file mode 100644 index 0000000..1c48560 --- /dev/null +++ b/dist/server/notifications/SlackNotifier.js @@ -0,0 +1,57 @@ +"use strict"; +/** + * SlackNotifier — sends anomaly notifications to a Slack webhook. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SlackNotifier = void 0; +const SEVERITY_EMOJI = { + low: ':blue_circle:', + medium: ':yellow_circle:', + high: ':red_circle:', + critical: ':rotating_light:', +}; +class SlackNotifier { + constructor(webhookUrl, frontendBaseUrl = 'http://localhost:5173') { + this.webhookUrl = webhookUrl; + this.frontendBaseUrl = frontendBaseUrl; + } + async send(anomaly, sessionId, targetUrl) { + const emoji = SEVERITY_EMOJI[anomaly.severity] ?? ':warning:'; + const payload = { + text: '🐛 ABE found a bug!', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*ABE Bug Report*\n` + + `*Severity:* ${emoji} ${anomaly.severity.toUpperCase()}\n` + + `*Type:* ${anomaly.type}\n` + + `*Description:* ${anomaly.description}\n` + + `*Session:* ${sessionId}\n` + + `*Target:* ${targetUrl}`, + }, + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Report' }, + url: `${this.frontendBaseUrl}/anomalies/${anomaly.id}`, + }, + ], + }, + ], + }; + const res = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`); + } + } +} +exports.SlackNotifier = SlackNotifier; diff --git a/dist/server/notifications/WebhookNotifier.js b/dist/server/notifications/WebhookNotifier.js new file mode 100644 index 0000000..50571ee --- /dev/null +++ b/dist/server/notifications/WebhookNotifier.js @@ -0,0 +1,25 @@ +"use strict"; +/** + * WebhookNotifier — posts full anomaly JSON to a generic webhook URL. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WebhookNotifier = void 0; +class WebhookNotifier { + constructor(webhookUrl) { + this.webhookUrl = webhookUrl; + } + async send(anomaly) { + const res = await fetch(this.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-ABE-Event': 'anomaly.detected', + }, + body: JSON.stringify(anomaly), + }); + if (!res.ok) { + throw new Error(`Webhook returned ${res.status}: ${await res.text()}`); + } + } +} +exports.WebhookNotifier = WebhookNotifier; diff --git a/dist/server/routes/anomalies.js b/dist/server/routes/anomalies.js new file mode 100644 index 0000000..6d4742b --- /dev/null +++ b/dist/server/routes/anomalies.js @@ -0,0 +1,93 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAnomalyRouter = createAnomalyRouter; +const express_1 = require("express"); +const fs_1 = __importDefault(require("fs")); +function createAnomalyRouter(store, enrichmentService) { + const router = (0, express_1.Router)(); + // GET /api/anomalies — list all anomalies (optionally filtered) + router.get('/', (req, res) => { + const sessionId = req.query['sessionId']; + const severity = req.query['severity']; + const anomalies = store.getAllAnomalies(sessionId, severity); + const mapped = anomalies.map((a) => ({ + id: a.id, + sessionId: store.findSessionForAnomaly(a.id), + type: a.type, + severity: a.severity, + description: a.description, + timestamp: a.timestamp, + screenshotUrl: a.evidence.screenshotPath + ? `/api/anomalies/${a.id}/screenshot` + : undefined, + })); + res.json(mapped); + }); + // GET /api/anomalies/:anomalyId — full anomaly detail + router.get('/:anomalyId', (req, res) => { + const anomalyId = req.params['anomalyId']; + const anomaly = store.getAnomaly(anomalyId); + if (!anomaly) { + res.status(404).json({ error: 'Anomaly not found' }); + return; + } + res.json({ + ...anomaly, + sessionId: store.findSessionForAnomaly(anomaly.id), + screenshotUrl: anomaly.evidence.screenshotPath + ? `/api/anomalies/${anomaly.id}/screenshot` + : undefined, + }); + }); + // GET /api/anomalies/:anomalyId/screenshot — serve PNG + router.get('/:anomalyId/screenshot', (req, res) => { + const anomalyId = req.params['anomalyId']; + const filePath = store.screenshotPath(anomalyId); + if (!filePath || !fs_1.default.existsSync(filePath)) { + res.status(404).json({ error: 'Screenshot not found' }); + return; + } + res.setHeader('Content-Type', 'image/png'); + fs_1.default.createReadStream(filePath).pipe(res); + }); + // POST /api/anomalies/:anomalyId/replay — trigger replay + router.post('/:anomalyId/replay', async (req, res) => { + const anomalyId = req.params['anomalyId']; + try { + const replayId = await store.replayAnomaly(anomalyId); + res.json({ replayId, status: 'running' }); + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err); + res.status(404).json({ error: msg }); + } + }); + // POST /api/anomalies/:anomalyId/enrich — AI enrichment + router.post('/:anomalyId/enrich', async (req, res) => { + const anomalyId = req.params['anomalyId']; + const anomaly = store.getAnomaly(anomalyId); + if (!anomaly) { + res.status(404).json({ error: 'Anomaly not found' }); + return; + } + if (!enrichmentService?.hasProvider()) { + res.status(503).json({ error: 'No AI provider configured (set ABE_AI_PROVIDER)' }); + return; + } + const context = { + domSnapshot: '', + httpLog: anomaly.evidence.httpLog ?? [], + consoleErrors: anomaly.evidence.rawErrors ?? [], + actionTrace: anomaly.actionTrace, + pageTitle: '', + url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '', + }; + // Run async — emit WS event when done + void enrichmentService.enrich(anomaly, context); + res.json({ status: 'enriching', anomalyId }); + }); + return router; +} diff --git a/dist/server/routes/config.js b/dist/server/routes/config.js new file mode 100644 index 0000000..926307c --- /dev/null +++ b/dist/server/routes/config.js @@ -0,0 +1,48 @@ +"use strict"; +/** + * Config routes — GET /api/config and PATCH /api/config + * Manages server-side configuration for notifications and defaults. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getServerConfig = getServerConfig; +exports.createConfigRouter = createConfigRouter; +const express_1 = require("express"); +const defaultConfig = { + slackWebhookUrl: process.env['ABE_SLACK_WEBHOOK_URL'] ?? null, + notifyMinSeverity: process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high', + defaultMaxStates: 50, + defaultMaxDepth: 5, + defaultActionDelayMs: 500, + defaultExcludedPaths: [], +}; +let serverConfig = { ...defaultConfig }; +function getServerConfig() { + return { ...serverConfig }; +} +function createConfigRouter() { + const router = (0, express_1.Router)(); + // GET /api/config — returns current config (without API key) + router.get('/', (_req, res) => { + res.json(serverConfig); + }); + // PATCH /api/config — updates config fields + router.patch('/', (req, res) => { + const body = req.body; + const validKeys = [ + 'slackWebhookUrl', + 'notifyMinSeverity', + 'defaultMaxStates', + 'defaultMaxDepth', + 'defaultActionDelayMs', + 'defaultExcludedPaths', + ]; + for (const key of validKeys) { + if (key in body) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serverConfig[key] = body[key]; + } + } + res.json(serverConfig); + }); + return router; +} diff --git a/dist/server/routes/schedules.js b/dist/server/routes/schedules.js new file mode 100644 index 0000000..b089ace --- /dev/null +++ b/dist/server/routes/schedules.js @@ -0,0 +1,122 @@ +"use strict"; +/** + * Schedules routes — CRUD for /api/schedules + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createScheduleRouter = createScheduleRouter; +const express_1 = require("express"); +const crypto = __importStar(require("crypto")); +const cron = __importStar(require("node-cron")); +const SchedulerService_1 = require("../scheduler/SchedulerService"); +function createScheduleRouter(scheduleRepo, scheduler) { + const router = (0, express_1.Router)(); + // GET /api/schedules + router.get('/', (_req, res) => { + const schedules = scheduleRepo.findAll(); + res.json(schedules); + }); + // POST /api/schedules + router.post('/', (req, res) => { + const { name, url, config, cronExpression, enabled } = req.body; + if (!name || !url || !cronExpression) { + res.status(400).json({ error: 'name, url, and cronExpression are required' }); + return; + } + if (!cron.validate(cronExpression)) { + res.status(400).json({ error: 'Invalid cron expression' }); + return; + } + const id = crypto.randomUUID(); + const nextRunAt = SchedulerService_1.SchedulerService.computeNextRunAt(cronExpression); + scheduleRepo.create({ + id, + name, + url, + configJson: JSON.stringify(config ?? {}), + cronExpression, + enabled: enabled !== false, + nextRunAt: nextRunAt ?? undefined, + }); + const record = scheduleRepo.findById(id); + if (record.enabled) { + scheduler.register(record); + } + res.status(201).json(record); + }); + // PATCH /api/schedules/:id + router.patch('/:id', (req, res) => { + const id = String(req.params['id']); + const existing = scheduleRepo.findById(id); + if (!existing) { + res.status(404).json({ error: 'Schedule not found' }); + return; + } + const { name, url, config, cronExpression, enabled } = req.body; + if (cronExpression !== undefined && !cron.validate(cronExpression)) { + res.status(400).json({ error: 'Invalid cron expression' }); + return; + } + scheduleRepo.update(id, { + name, + url, + configJson: config !== undefined ? JSON.stringify(config) : undefined, + cronExpression, + enabled, + }); + const updated = scheduleRepo.findById(id); + // Re-register/unregister cron job + if (updated.enabled) { + scheduler.register(updated); + } + else { + scheduler.unregister(id); + } + res.json(updated); + }); + // DELETE /api/schedules/:id + router.delete('/:id', (req, res) => { + const id = String(req.params['id']); + const existing = scheduleRepo.findById(id); + if (!existing) { + res.status(404).json({ error: 'Schedule not found' }); + return; + } + scheduler.unregister(String(id)); + scheduleRepo.delete(String(id)); + res.status(204).send(); + }); + return router; +} diff --git a/dist/server/routes/sessions.js b/dist/server/routes/sessions.js new file mode 100644 index 0000000..8d54a54 --- /dev/null +++ b/dist/server/routes/sessions.js @@ -0,0 +1,104 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createSessionRouter = createSessionRouter; +const express_1 = require("express"); +const ExplorationConfig_1 = require("../../core/ExplorationConfig"); +function createSessionRouter(store) { + const router = (0, express_1.Router)(); + // POST /api/sessions — start a new exploration + router.post('/', async (req, res) => { + const body = req.body; + const { url, seed = 42 } = body; + if (!url || typeof url !== 'string') { + res.status(400).json({ error: 'url is required' }); + return; + } + // Enforce concurrent session limit + const stats = store.getStats(); + const limit = store.getMaxConcurrent(); + if (stats.runningSessions >= limit) { + res.status(429).json({ + error: 'Max concurrent sessions reached', + active: stats.runningSessions, + limit, + }); + return; + } + const config = { + ...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG, + ...(body.config ?? {}), + }; + // If allowedDomains not specified, derive from the target URL + if (config.allowedDomains.length === 0) { + try { + const hostname = new URL(url).hostname; + config.allowedDomains = [hostname]; + } + catch { + // leave empty + } + } + const record = await store.startSession({ + url, + seed, + maxStates: config.maxStates, + explorationConfig: config, + }); + res.status(201).json({ + sessionId: record.sessionId, + status: record.status, + startedAt: record.startedAt, + }); + }); + // GET /api/sessions — list all sessions + router.get('/', (_req, res) => { + const sessions = store.getAllSessions().map((s) => ({ + sessionId: s.sessionId, + url: s.url, + status: s.status, + startedAt: s.startedAt, + anomaliesFound: s.anomaliesFound, + statesVisited: s.statesVisited, + })); + res.json(sessions); + }); + // GET /api/sessions/:sessionId — session detail + router.get('/:sessionId', (req, res) => { + const record = store.getSession(req.params['sessionId']); + if (!record) { + res.status(404).json({ error: 'Session not found' }); + return; + } + res.json({ + sessionId: record.sessionId, + url: record.url, + status: record.status, + startedAt: record.startedAt, + finishedAt: record.finishedAt, + statesVisited: record.statesVisited, + anomaliesFound: record.anomaliesFound, + seed: record.seed, + }); + }); + // DELETE /api/sessions/:sessionId — stop an active session + router.delete('/:sessionId', (req, res) => { + const stopped = store.stopSession(req.params['sessionId']); + if (!stopped) { + res.status(404).json({ error: 'Session not found or not running' }); + return; + } + res.json({ stopped: true }); + }); + // GET /api/sessions/:sessionId/performance — performance metrics for session + router.get('/:sessionId/performance', (req, res) => { + const sessionId = req.params['sessionId']; + const record = store.getSession(sessionId); + if (!record) { + res.status(404).json({ error: 'Session not found' }); + return; + } + const metrics = store.getPerformanceMetrics(sessionId); + res.json(metrics); + }); + return router; +} diff --git a/dist/server/routes/visual.js b/dist/server/routes/visual.js new file mode 100644 index 0000000..6d88f8d --- /dev/null +++ b/dist/server/routes/visual.js @@ -0,0 +1,52 @@ +"use strict"; +/** + * Visual regression routes — /api/visual + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createVisualRouter = createVisualRouter; +const express_1 = require("express"); +function createVisualRouter(repo) { + const router = (0, express_1.Router)(); + // GET /api/visual/comparisons + router.get('/comparisons', (req, res) => { + const sessionId = req.query['sessionId']; + const status = req.query['status']; + const comparisons = repo.findComparisons({ sessionId, status }); + res.json(comparisons); + }); + // POST /api/visual/baselines/:comparisonId/approve + router.post('/baselines/:comparisonId/approve', (req, res) => { + const comparisonId = String(req.params['comparisonId']); + const comparison = repo.findComparisonById(comparisonId); + if (!comparison) { + res.status(404).json({ error: 'Comparison not found' }); + return; + } + const baselineId = repo.promoteToBaseline(comparisonId); + res.json({ baselineId, status: 'approved' }); + }); + // POST /api/visual/baselines/:comparisonId/reject + router.post('/baselines/:comparisonId/reject', (req, res) => { + const comparisonId = String(req.params['comparisonId']); + const comparison = repo.findComparisonById(comparisonId); + if (!comparison) { + res.status(404).json({ error: 'Comparison not found' }); + return; + } + repo.updateComparisonStatus(comparisonId, 'failed'); + res.json({ status: 'rejected' }); + }); + // POST /api/visual/baselines/approve-all + router.post('/baselines/approve-all', (req, res) => { + const { sessionId } = req.body; + const pending = repo.findComparisons({ sessionId, status: 'new_state' }); + const approved = []; + for (const comp of pending) { + const id = repo.promoteToBaseline(comp.id); + if (id) + approved.push(id); + } + res.json({ approved: approved.length }); + }); + return router; +} diff --git a/dist/server/scheduler/SchedulerService.js b/dist/server/scheduler/SchedulerService.js new file mode 100644 index 0000000..f48830a --- /dev/null +++ b/dist/server/scheduler/SchedulerService.js @@ -0,0 +1,140 @@ +"use strict"; +/** + * SchedulerService — manages cron-based scheduled explorations. + * Loads schedules from DB on startup, registers cron jobs, and triggers sessions. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SchedulerService = void 0; +const cron = __importStar(require("node-cron")); +const logger_1 = require("../logger"); +class SchedulerService { + constructor(scheduleRepo, sessionStore) { + this.scheduleRepo = scheduleRepo; + this.sessionStore = sessionStore; + this.jobs = new Map(); + } + /** Load all enabled schedules and start cron jobs. */ + start() { + const schedules = this.scheduleRepo.findAll(true); + for (const schedule of schedules) { + this.register(schedule); + } + logger_1.log.info({ count: schedules.length }, 'SchedulerService started'); + } + /** Stop all cron jobs. */ + stop() { + for (const [id, task] of this.jobs) { + task.stop(); + logger_1.log.info({ scheduleId: id }, 'Cron job stopped'); + } + this.jobs.clear(); + } + /** Register (or re-register) a cron job for a schedule. */ + register(schedule) { + this.unregister(schedule.id); + if (!schedule.enabled) + return; + if (!cron.validate(schedule.cronExpression)) { + logger_1.log.warn({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Invalid cron expression, skipping'); + return; + } + const task = cron.schedule(schedule.cronExpression, () => { + void this.fire(schedule.id); + }); + this.jobs.set(schedule.id, task); + logger_1.log.info({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Cron job registered'); + } + /** Unregister a cron job. */ + unregister(scheduleId) { + const existing = this.jobs.get(scheduleId); + if (existing) { + existing.stop(); + this.jobs.delete(scheduleId); + } + } + /** Fire a scheduled run. */ + async fire(scheduleId) { + const schedule = this.scheduleRepo.findById(scheduleId); + if (!schedule || !schedule.enabled) + return; + // Check if a session from this schedule is still running + const running = this.sessionStore.getAllSessions().filter((s) => s.status === 'running'); + if (running.length > 0) { + // Check if any running session was created from this schedule + const scheduleConfig = JSON.parse(schedule.configJson); + const alreadyRunning = running.some((s) => { + try { + const cfg = JSON.parse(s.config_json ?? '{}'); + return cfg.scheduleId === scheduleId; + } + catch { + return false; + } + }); + if (alreadyRunning) { + logger_1.log.warn({ scheduleId }, 'Previous session still running, skipping scheduled tick'); + return; + } + void scheduleConfig; // suppress unused warning + } + logger_1.log.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration'); + const now = Date.now(); + this.scheduleRepo.update(scheduleId, { lastRunAt: now }); + try { + const config = JSON.parse(schedule.configJson); + // Inject scheduleId into config for tracking + config.scheduleId = scheduleId; + await this.sessionStore.startSession({ + url: schedule.url, + seed: Math.floor(Math.random() * 0x7fffffff), + maxStates: config.maxStates ?? 50, + explorationConfig: config, + }); + } + catch (err) { + logger_1.log.error({ scheduleId, err: err instanceof Error ? err.message : String(err) }, 'Scheduled session failed to start'); + } + } + /** Compute approximate next run time for a cron expression. */ + static computeNextRunAt(cronExpression) { + if (!cron.validate(cronExpression)) + return null; + // Simple heuristic: use current time + 60s as a placeholder + // A proper implementation would parse the cron and compute the next trigger + return Date.now() + 60000; + } +} +exports.SchedulerService = SchedulerService; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e19d9ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "3001:3001" + env_file: + - .env + environment: + - PORT=3001 + volumes: + - ./reports:/app/reports + - ./logs:/app/logs + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + start_period: 5s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "5173:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + name: abe-network diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5595852 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# ---- Build stage ---- +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ---- Production stage ---- +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..072a57e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..dfeb07d --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + # Single-page application fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy REST API to the backend service + location /api/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Proxy socket.io with WebSocket upgrade support + location /socket.io/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9603e59 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5404 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", + "socket.io-client": "^4.8.3", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser": "^4.0.18", + "concurrently": "^9.2.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/browser": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.18.tgz", + "integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/mocker": "4.0.18", + "@vitest/utils": "4.0.18", + "magic-string": "^0.30.21", + "pixelmatch": "7.1.0", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.18.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..08dd720 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", + "socket.io-client": "^4.8.3", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser": "^4.0.18", + "concurrently": "^9.2.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^28.1.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..856ceb2 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,22 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { Dashboard } from './pages/Dashboard' +import { SessionDetail } from './pages/SessionDetail' +import { AnomalyDetail } from './pages/AnomalyDetail' +import { Settings } from './pages/Settings' +import { VisualReview } from './pages/VisualReview' + +function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App diff --git a/frontend/src/__tests__/AnomalyList.test.tsx b/frontend/src/__tests__/AnomalyList.test.tsx new file mode 100644 index 0000000..d5652fa --- /dev/null +++ b/frontend/src/__tests__/AnomalyList.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { AnomalyList } from '../components/AnomalyList'; +import type { AnomalySummary } from '../types'; + +function makeAnomaly(overrides: Partial = {}): AnomalySummary { + return { + id: 'anom_1', + type: 'http_error', + severity: 'high', + description: 'HTTP 500 on form submit', + timestamp: 1000000, + ...overrides, + }; +} + +function renderList(anomalies: AnomalySummary[]) { + return render( + + + + ); +} + +describe('AnomalyList', () => { + it('renders title', () => { + renderList([]); + expect(screen.getByText('Test Anomalies')).toBeDefined(); + }); + + it('shows empty state when no anomalies', () => { + renderList([]); + expect(screen.getByText(/no anomalies/i)).toBeDefined(); + }); + + it('renders anomaly cards', () => { + renderList([makeAnomaly(), makeAnomaly({ id: 'anom_2', description: 'Another error' })]); + expect(screen.getByText('HTTP 500 on form submit')).toBeDefined(); + expect(screen.getByText('Another error')).toBeDefined(); + }); + + it('filters by severity when severity button is clicked', async () => { + const user = userEvent.setup(); + renderList([ + makeAnomaly({ id: 'a1', severity: 'high', description: 'High error' }), + makeAnomaly({ id: 'a2', severity: 'low', description: 'Low error' }), + ]); + + // Both are visible initially (all severities selected) + expect(screen.getByText('High error')).toBeDefined(); + expect(screen.getByText('Low error')).toBeDefined(); + + // Click "high" to deselect it + const highBtn = screen.getAllByRole('button').find((b) => b.textContent === 'HIGH'); + if (highBtn) await user.click(highBtn); + + // High error should now be hidden + expect(screen.queryByText('High error')).toBeNull(); + expect(screen.getByText('Low error')).toBeDefined(); + }); + + it('filters by description search', async () => { + const user = userEvent.setup(); + renderList([ + makeAnomaly({ id: 'a1', description: 'Server crashed unexpectedly' }), + makeAnomaly({ id: 'a2', description: 'Timeout on login' }), + ]); + + const searchInput = screen.getByPlaceholderText(/search/i); + await user.type(searchInput, 'crashed'); + + expect(screen.getByText('Server crashed unexpectedly')).toBeDefined(); + expect(screen.queryByText('Timeout on login')).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/NewSessionForm.test.tsx b/frontend/src/__tests__/NewSessionForm.test.tsx new file mode 100644 index 0000000..bd89d09 --- /dev/null +++ b/frontend/src/__tests__/NewSessionForm.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { NewSessionForm } from '../components/NewSessionForm'; + +// Mock the api module +vi.mock('../hooks/useApi', () => ({ + api: { + createSession: vi.fn(), + }, + apiFetch: vi.fn(), +})); + +import { api } from '../hooks/useApi'; + +function renderForm(onCreated = vi.fn()) { + return render( + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('NewSessionForm', () => { + it('renders URL field and submit button', () => { + renderForm(); + expect(screen.getByLabelText(/target url/i)).toBeDefined(); + expect(screen.getByRole('button', { name: /start exploration/i })).toBeDefined(); + }); + + it('renders auth type selector', () => { + renderForm(); + expect(screen.getByLabelText(/auth type/i)).toBeDefined(); + }); + + it('shows login flow fields when login_flow is selected', async () => { + const user = userEvent.setup(); + renderForm(); + + const authSelect = screen.getByLabelText(/auth type/i); + await user.selectOptions(authSelect, 'login_flow'); + + expect(screen.getByLabelText(/login url/i)).toBeDefined(); + expect(screen.getByLabelText(/username$/i)).toBeDefined(); + expect(screen.getByLabelText(/password$/i)).toBeDefined(); + }); + + it('does NOT show login flow fields when auth is none', () => { + renderForm(); + expect(screen.queryByLabelText(/login url/i)).toBeNull(); + }); + + it('calls onCreated with sessionId on success', async () => { + const user = userEvent.setup(); + const onCreated = vi.fn(); + (api.createSession as ReturnType).mockResolvedValue({ + sessionId: 'sess_test', + status: 'running', + startedAt: new Date().toISOString(), + }); + + renderForm(onCreated); + + const urlInput = screen.getByLabelText(/target url/i); + await user.clear(urlInput); + await user.type(urlInput, 'http://localhost:3000'); + + await user.click(screen.getByRole('button', { name: /start exploration/i })); + + await waitFor(() => { + expect(onCreated).toHaveBeenCalledWith('sess_test'); + }); + }); + + it('shows error message on failed session creation', async () => { + const user = userEvent.setup(); + (api.createSession as ReturnType).mockRejectedValue(new Error('Max concurrent sessions reached')); + + renderForm(); + + await user.click(screen.getByRole('button', { name: /start exploration/i })); + + await waitFor(() => { + expect(screen.getByText(/max concurrent sessions reached/i)).toBeDefined(); + }); + }); + + it('renders fuzzing toggle and intensity selector', () => { + renderForm(); + expect(screen.getByLabelText(/enable fuzzing/i)).toBeDefined(); + }); +}); diff --git a/frontend/src/__tests__/SeverityBadge.test.tsx b/frontend/src/__tests__/SeverityBadge.test.tsx new file mode 100644 index 0000000..776a09f --- /dev/null +++ b/frontend/src/__tests__/SeverityBadge.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SeverityBadge } from '../components/SeverityBadge'; + +describe('SeverityBadge', () => { + it('renders severity text in uppercase', () => { + render(); + expect(screen.getByText('HIGH')).toBeDefined(); + }); + + it('applies red background for critical', () => { + const { container } = render(); + const badge = container.firstChild as HTMLElement; + expect(badge.className).toContain('bg-red-500'); + }); + + it('applies orange background for high', () => { + const { container } = render(); + const badge = container.firstChild as HTMLElement; + expect(badge.className).toContain('bg-orange-500'); + }); + + it('applies yellow background for medium', () => { + const { container } = render(); + const badge = container.firstChild as HTMLElement; + expect(badge.className).toContain('bg-yellow-500'); + }); + + it('applies blue background for low', () => { + const { container } = render(); + const badge = container.firstChild as HTMLElement; + expect(badge.className).toContain('bg-blue-500'); + }); +}); diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AnomalyCard.tsx b/frontend/src/components/AnomalyCard.tsx new file mode 100644 index 0000000..932b1cb --- /dev/null +++ b/frontend/src/components/AnomalyCard.tsx @@ -0,0 +1,35 @@ +import { Link } from 'react-router-dom'; +import type { AnomalySummary } from '../types'; +import { SeverityBadge } from './SeverityBadge'; + +const BROWSER_COLORS: Record = { + chromium: 'bg-blue-800 text-blue-200', + firefox: 'bg-orange-800 text-orange-200', + webkit: 'bg-purple-800 text-purple-200', +}; + +interface Props { + anomaly: AnomalySummary; +} + +export function AnomalyCard({ anomaly }: Props) { + return ( +
+ +
+
+ + {anomaly.type} + + {anomaly.browser && ( + + {anomaly.browser} + + )} +
+

{anomaly.description}

+

{new Date(anomaly.timestamp).toLocaleString()}

+
+
+ ); +} diff --git a/frontend/src/components/AnomalyList.tsx b/frontend/src/components/AnomalyList.tsx new file mode 100644 index 0000000..11feb09 --- /dev/null +++ b/frontend/src/components/AnomalyList.tsx @@ -0,0 +1,179 @@ +import { useState, useMemo } from 'react'; +import type { AnomalySummary, Severity, AnomalyType, Session, BrowserType } from '../types'; +import { AnomalyCard } from './AnomalyCard'; +import { SeverityBadge } from './SeverityBadge'; + +const ALL_SEVERITIES: Severity[] = ['low', 'medium', 'high', 'critical']; +const ALL_TYPES: AnomalyType[] = [ + 'http_error', + 'js_exception', + 'console_error', + 'navigation_fail', + 'element_missing', + 'timeout', + 'validation_bypass', + 'server_error_on_fuzz', + 'xss_reflection', + 'visual_regression', + 'accessibility_violation', + 'mobile_layout_issue', + 'performance_degradation', + 'offline_handling_missing', + 'slow_network_no_feedback', + 'external_service_crash', +]; +const ALL_BROWSERS: Array = ['all', 'chromium', 'firefox', 'webkit']; + +type SortMode = 'newest' | 'severity'; + +const SEVERITY_ORDER: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +interface Props { + anomalies: AnomalySummary[]; + title?: string; + sessions?: Session[]; +} + +export function AnomalyList({ anomalies, title = 'Anomalies', sessions }: Props) { + const [severities, setSeverities] = useState>(new Set(ALL_SEVERITIES)); + const [typeFilter, setTypeFilter] = useState('all'); + const [sessionFilter, setSessionFilter] = useState('all'); + const [browserFilter, setBrowserFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [sortMode, setSortMode] = useState('newest'); + + function toggleSeverity(s: Severity) { + setSeverities((prev) => { + const next = new Set(prev); + if (next.has(s)) { + next.delete(s); + } else { + next.add(s); + } + return next; + }); + } + + const filtered = useMemo(() => { + let result = anomalies.filter((a) => { + if (!severities.has(a.severity)) return false; + if (typeFilter !== 'all' && a.type !== typeFilter) return false; + if (sessionFilter !== 'all' && a.sessionId !== sessionFilter) return false; + if (browserFilter !== 'all' && a.browser !== browserFilter) return false; + if (search.trim() && !a.description.toLowerCase().includes(search.trim().toLowerCase())) return false; + return true; + }); + + if (sortMode === 'newest') { + result = [...result].sort((a, b) => b.timestamp - a.timestamp); + } else { + result = [...result].sort((a, b) => { + const diff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (diff !== 0) return diff; + return b.timestamp - a.timestamp; + }); + } + + return result; + }, [anomalies, severities, typeFilter, sessionFilter, search, sortMode]); + + return ( +
+

{title}

+ + {/* Filter bar */} +
+ {/* Severity checkboxes */} +
+ Severity: + {ALL_SEVERITIES.map((s) => ( + + ))} +
+ + {/* Row: type, session, search, sort */} +
+ {/* Type dropdown */} + + + {/* Session dropdown (only if sessions prop provided) */} + {sessions && sessions.length > 0 && ( + + )} + + {/* Browser filter */} + + + {/* Description search */} + setSearch(e.target.value)} + className="bg-gray-700 text-gray-200 text-xs rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 min-w-32" + /> + + {/* Sort */} + +
+
+ + {filtered.length === 0 ? ( +

No anomalies match the current filters.

+ ) : ( +
+ {filtered.map((a) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/LiveFeed.tsx b/frontend/src/components/LiveFeed.tsx new file mode 100644 index 0000000..8e04514 --- /dev/null +++ b/frontend/src/components/LiveFeed.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; + +export interface FeedEvent { + id: string; + event: string; + text: string; + timestamp: number; +} + +const EVENT_COLOR: Record = { + 'state:discovered': 'text-green-400', + 'action:executed': 'text-yellow-400', + 'anomaly:detected': 'text-red-400', + 'session:started': 'text-blue-400', + 'session:completed': 'text-blue-400', + 'session:error': 'text-red-500', +}; + +interface Props { + events: FeedEvent[]; +} + +export function LiveFeed({ events }: Props) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [events.length]); + + return ( +
+ {events.length === 0 && ( +

Waiting for events…

+ )} + {events.map((e) => ( +
+ {new Date(e.timestamp).toLocaleTimeString()} + {e.event} + {e.text} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/NewSessionForm.tsx b/frontend/src/components/NewSessionForm.tsx new file mode 100644 index 0000000..04d5076 --- /dev/null +++ b/frontend/src/components/NewSessionForm.tsx @@ -0,0 +1,434 @@ +import { useState } from 'react'; +import { api } from '../hooks/useApi'; +import type { AuthType, FuzzingIntensity, NetworkProfile } from '../types'; + +interface Props { + onCreated: (sessionId: string) => void; +} + +interface HeaderPair { + key: string; + value: string; +} + +export function NewSessionForm({ onCreated }: Props) { + // Basic fields + const [url, setUrl] = useState('http://localhost:3000'); + const [seed, setSeed] = useState(42); + const [maxStates, setMaxStates] = useState(50); + const [maxDepth, setMaxDepth] = useState(5); + const [actionDelayMs, setActionDelayMs] = useState(500); + const [allowedDomains, setAllowedDomains] = useState(''); + const [excludedPaths, setExcludedPaths] = useState(''); + + // Auth + const [authType, setAuthType] = useState('none'); + // login_flow fields + const [loginUrl, setLoginUrl] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [usernameSelector, setUsernameSelector] = useState(''); + const [passwordSelector, setPasswordSelector] = useState(''); + const [submitSelector, setSubmitSelector] = useState(''); + // cookies field + const [cookiesJson, setCookiesJson] = useState(''); + // headers field + const [headerPairs, setHeaderPairs] = useState([{ key: '', value: '' }]); + + // Fuzzing + const [fuzzingEnabled, setFuzzingEnabled] = useState(true); + const [fuzzingIntensity, setFuzzingIntensity] = useState('medium'); + + // Network Chaos + const [chaosEnabled, setChaosEnabled] = useState(false); + const [chaosProfile, setChaosProfile] = useState('fast-3g'); + const [chaosExpanded, setChaosExpanded] = useState(false); + const [blockedEndpoints, setBlockedEndpoints] = useState(''); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + function addHeaderPair() { + setHeaderPairs((prev) => [...prev, { key: '', value: '' }]); + } + + function removeHeaderPair(index: number) { + setHeaderPairs((prev) => prev.filter((_, i) => i !== index)); + } + + function updateHeaderPair(index: number, field: 'key' | 'value', val: string) { + setHeaderPairs((prev) => + prev.map((pair, i) => (i === index ? { ...pair, [field]: val } : pair)) + ); + } + + function buildAuth() { + if (authType === 'none') return null; + if (authType === 'login_flow') { + return { + type: 'login_flow', + loginUrl, + username, + password, + usernameSelector, + passwordSelector, + submitSelector, + }; + } + if (authType === 'cookies') { + try { + const cookies = JSON.parse(cookiesJson || '[]'); + return { type: 'cookies', cookies }; + } catch { + throw new Error('Invalid JSON for cookies array'); + } + } + if (authType === 'headers') { + const headers: Record = {}; + for (const { key, value } of headerPairs) { + if (key.trim()) headers[key.trim()] = value; + } + return { type: 'headers', headers }; + } + return null; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const auth = buildAuth(); + const config = { + maxStates, + maxDepth, + actionDelayMs, + allowedDomains: allowedDomains + ? allowedDomains.split(',').map((s) => s.trim()).filter(Boolean) + : [], + excludedPaths: excludedPaths + ? excludedPaths.split(',').map((s) => s.trim()).filter(Boolean) + : [], + auth, + fuzzingEnabled, + fuzzingIntensity, + networkChaos: { + enabled: chaosEnabled, + profile: chaosProfile, + blockedEndpoints: blockedEndpoints + ? blockedEndpoints.split(',').map((s) => s.trim()).filter(Boolean) + : [], + slowEndpoints: [], + }, + }; + const res = await api.createSession({ url, seed, config }); + onCreated(res.sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start session'); + } finally { + setLoading(false); + } + } + + const inputClass = + 'w-full bg-gray-700 text-white rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'; + const labelClass = 'block text-sm text-gray-400 mb-1'; + const sectionClass = 'border-t border-gray-700 pt-4'; + + return ( +
+

New Exploration

+ + {/* Target URL */} +
+ + setUrl(e.target.value)} + required + className={inputClass} + /> +
+ + {/* Seed + Max States + Max Depth */} +
+
+ + setSeed(Number(e.target.value))} + className={inputClass} + /> +
+
+ + setMaxStates(Number(e.target.value))} + className={inputClass} + /> +
+
+ + setMaxDepth(Number(e.target.value))} + className={inputClass} + /> +
+
+ + {/* Action Delay */} +
+
+ + setActionDelayMs(Number(e.target.value))} + className={inputClass} + /> +
+
+ + {/* Allowed Domains + Excluded Paths */} +
+
+ + setAllowedDomains(e.target.value)} + placeholder="hostname auto-detected" + className={inputClass} + /> +
+
+ + setExcludedPaths(e.target.value)} + placeholder="/logout, /admin" + className={inputClass} + /> +
+
+ + {/* Auth */} +
+
+ + +
+ + {authType === 'login_flow' && ( +
+

Login Flow Config

+
+ + setLoginUrl(e.target.value)} className={inputClass} /> +
+
+
+ + setUsername(e.target.value)} className={inputClass} /> +
+
+ + setPassword(e.target.value)} className={inputClass} /> +
+
+
+ + setUsernameSelector(e.target.value)} placeholder="#username" className={inputClass} /> +
+
+ + setPasswordSelector(e.target.value)} placeholder="#password" className={inputClass} /> +
+
+ + setSubmitSelector(e.target.value)} placeholder="button[type=submit]" className={inputClass} /> +
+
+ )} + + {authType === 'cookies' && ( +
+

Cookie Array (JSON)

+