diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d2edaa9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Lockfiles are generated files. Git's line-level text merge can combine +# both sides' insertions into syntactically invalid JSON *without raising a +# conflict* (this silently broke backend/package.json + package-lock.json in +# PR #233). Merge them as binary so any concurrent change surfaces as an +# explicit conflict; resolve by taking main's copy and regenerating: +# git checkout origin/main -- package-lock.json && npm install +package-lock.json merge=binary +bun.lock merge=binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9213ea8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Summary + + + +## Why / Motivation + + + +## Changes + + + +## Tradeoffs & risks + + + +## How verified + + + +## Checklist + +- [ ] Ran the relevant build/test command for the area changed. +- [ ] Reviewed `git diff` and removed unrelated changes. +- [ ] Updated docs / env examples if setup, config, or behavior changed. +- [ ] No secrets, API keys, real documents, or `.env` files committed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c41f910 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +# CI: build and test. +# +# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo +# layout) to this repository's backend/ + frontend/ layout. Test steps use +# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this +# workflow is safe to merge before or after the test-harness and evals PRs: +# on a tree without those pieces the test steps no-op and the build still +# gates the merge. Beyond the fork version this also builds the frontend +# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and +# runs eslint as a blocking gate (the error backlog is at zero). +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: Backend build and tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: backend/package-lock.json + + # Git's line-level merge can splice both sides of package(-lock).json + # into invalid JSON without a conflict (bit PR #233). npm's own error + # for an unparseable lockfile is the misleading "npm ci can only + # install with an existing package-lock.json" — fail fast with the real + # reason instead. + - name: Validate package.json and lockfile parse + run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))" + + - run: npm ci + + # No-ops on a tree without a "test" script (e.g. before the vitest + # harness PR merges); runs the suite once it exists. + - run: npm test --if-present + + - run: npm run build + + frontend: + name: Frontend build and tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + # Same silent-merge-corruption guard as the backend job. + - name: Validate package.json and lockfile parse + run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))" + + - run: npm ci + + # No-ops on a tree without a "test" script (e.g. before the vitest + # harness PR merges); runs the suite once it exists. + - run: npm test --if-present + + # Blocking gate: the eslint error backlog was burned down in this PR + # (0 errors; warnings do not fail the step), so any new error fails CI. + - run: npm run lint + + # Production build. NEXT_PUBLIC_* values are inlined at build time and + # only need to be well-formed here — nothing is contacted during build. + # This catches type errors (next build runs tsc) and broken routes/imports. + - run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder + NEXT_PUBLIC_API_BASE_URL: http://localhost:3001 + + # ----------------------------------------------------------------------- + # Offline eval harness: deterministic scorecard for citation accuracy, + # prompt-injection resistance, and privilege/PII leakage. Runs against + # committed fixtures (no network, no LLM calls, no secrets), so it is cheap + # enough to gate every PR. --threshold 1.0 = every case must pass. + # Skips gracefully until the evals PR merges. + # ----------------------------------------------------------------------- + evals: + name: Eval harness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run eval harness (skips if evals/ not present) + run: | + if [ -f evals/run.mjs ]; then + node evals/run.mjs --threshold 1.0 + else + echo "evals/run.mjs not present on this tree; skipping" + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47e61fe..ba973e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,3 +54,22 @@ Frontend: ```bash npm run build --prefix frontend ``` + +## Testing + +```bash +npm test --prefix backend # backend unit + route integration tests (vitest) +npm test --prefix frontend # frontend component/hook tests (vitest + jsdom) +npm run test:e2e # Playwright end-to-end suite — see docs/e2e-ci.md +node evals/run.mjs --threshold 1.0 # offline eval harness (no network, no API keys) +npm run test:stack --prefix backend # gated: real-Supabase auth/access tests (run `supabase start` first) +``` + +- New features and bug fixes should come with a test at the lowest layer that + can catch the regression: unit first, then route-level integration, then + end-to-end only for flows a browser is genuinely needed to prove. +- CI runs the build, unit/integration tests, and the eval harness on every PR + (`.github/workflows/ci.yml`), and the Playwright suite in a full local stack + (`.github/workflows/e2e.yml`). +- Tests that need a live Supabase or an LLM key are env-gated and skip cleanly + when the environment is absent — a plain `npm test` should always be green. diff --git a/backend/migrations/20260716_01_chat_messages_workflow.sql b/backend/migrations/20260716_01_chat_messages_workflow.sql new file mode 100644 index 0000000..618aa09 --- /dev/null +++ b/backend/migrations/20260716_01_chat_messages_workflow.sql @@ -0,0 +1,12 @@ +-- Add chat_messages.workflow. +-- +-- The app persists a `workflow` field on user messages +-- (backend/src/routes/chat.ts, projectChat.ts) and reads it back when rendering +-- chat history (frontend ChatView -> UserMessage, to show which workflow the +-- message was sent under). The column was referenced in code but never created +-- by schema.sql or any migration, so every user-message insert failed with +-- PostgREST PGRST204 ("Could not find the 'workflow' column") and was dropped +-- silently — user prompts vanished on reload while the assistant reply +-- persisted. Mirrors the existing content/files jsonb columns. +alter table public.chat_messages + add column if not exists workflow jsonb; diff --git a/backend/package-lock.json b/backend/package-lock.json index c7597a9..7bed718 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "mike-backend", "version": "1.0.0", + "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.787.0", @@ -36,9 +37,13 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.1", + "@vitest/coverage-v8": "^4.1.9", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.9" } }, "node_modules/@anthropic-ai/sdk": { @@ -966,6 +971,42 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -975,6 +1016,64 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1452,6 +1551,34 @@ "hono": "^4" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -2059,6 +2186,38 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2071,6 +2230,26 @@ ], "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2153,6 +2332,270 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -2885,6 +3328,13 @@ "node": ">=18.0.0" } }, + "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/@supabase/auth-js": { "version": "2.102.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.102.1.tgz", @@ -2971,6 +3421,17 @@ "node": ">=20.0.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2982,6 +3443,17 @@ "@types/node": "*" } }, + "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/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2992,6 +3464,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3002,6 +3481,20 @@ "@types/node": "*" } }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -3035,6 +3528,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3114,6 +3614,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3123,6 +3647,150 @@ "@types/node": "*" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -3214,12 +3882,48 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "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/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3346,6 +4050,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -3382,6 +4119,13 @@ "node": ">= 0.6" } }, + "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": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -3397,6 +4141,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3461,6 +4212,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3480,6 +4241,27 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -3662,6 +4444,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3674,6 +4463,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3722,6 +4527,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3752,6 +4567,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3834,6 +4659,13 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -3886,6 +4718,24 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -3927,6 +4777,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3939,6 +4806,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4097,6 +4982,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4109,6 +5004,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -4120,9 +5031,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4149,6 +5060,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -4309,6 +5227,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -4318,6 +5275,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -4416,6 +5380,267 @@ "immediate": "~3.0.5" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -4433,6 +5658,44 @@ "underscore": "^1.13.1" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mammoth": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", @@ -4664,6 +5927,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4771,6 +6048,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "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/pdfjs-dist": { "version": "4.10.38", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", @@ -4792,6 +6076,26 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -4801,6 +6105,54 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "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/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -4991,6 +6343,40 @@ "node": ">= 4" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -5094,6 +6480,19 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -5244,12 +6643,36 @@ "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/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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "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/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5259,6 +6682,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -5294,6 +6724,147 @@ ], "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "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/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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -5422,6 +6993,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -5446,6 +7185,23 @@ "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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 195a6ac..0223d9a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:stack": "bash scripts/test-stack.sh", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@anthropic-ai/sdk": "^0.90.0", @@ -36,9 +39,13 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.1", + "@vitest/coverage-v8": "^4.1.9", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.9" }, "license": "AGPL-3.0-only" } diff --git a/backend/schema.sql b/backend/schema.sql index 8d29838..6819473 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -550,6 +550,7 @@ create table if not exists public.chat_messages ( role text not null, content jsonb, files jsonb, + workflow jsonb, citations jsonb, created_at timestamptz not null default now() ); @@ -884,3 +885,14 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated; revoke all on public.user_mcp_tool_audit_logs from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; + +-- Tables created by this file are owned by the database bootstrap role. The +-- backend connects as service_role, so grant it only the data privileges that +-- the direct browser roles above intentionally do not have. RLS is still +-- enabled as defense in depth; service_role bypasses it for the backend path. +grant select, insert, update, delete + on all tables in schema public + to service_role; +grant usage, select + on all sequences in schema public + to service_role; diff --git a/backend/scripts/test-stack.sh b/backend/scripts/test-stack.sh new file mode 100755 index 0000000..f75deae --- /dev/null +++ b/backend/scripts/test-stack.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Run the gated stack-level integration tests against a local Supabase stack. +# +# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of +# mocks. They are the harness you re-run on every Supabase image bump to prove +# the auth↔API contract and the deny-all RLS firewall still hold. +# +# Usage: supabase start # in the repo, once +# npm run test:stack (from backend/) +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +SCHEMA_FILE="$BACKEND_DIR/schema.sql" + +if ! command -v supabase >/dev/null 2>&1; then + echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2 + exit 1 +fi + +STATUS="$(supabase status -o json 2>/dev/null)" || { + echo "No running Supabase stack. Start one with: supabase start" >&2 + exit 1 +} + +read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; } + +SUPABASE_TEST_URL="$(read_key API_URL)" +SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)" +SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)" +SUPABASE_TEST_DB_URL="$(read_key DB_URL)" + +if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then + echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2 + exit 1 +fi +export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2 + exit 1 +fi + +# A newly started local stack contains Supabase's system schemas but none of +# Mike's application tables. Initialize only an empty stack: silently resetting +# or modifying an existing application database would be surprising. +PROJECTS_TABLE="$( + psql "$SUPABASE_TEST_DB_URL" -XAtq \ + -c "select to_regclass('public.projects');" +)" +if [[ "$PROJECTS_TABLE" != "projects" ]]; then + echo "Mike schema not found; loading $SCHEMA_FILE" + psql "$SUPABASE_TEST_DB_URL" -X \ + --set ON_ERROR_STOP=1 \ + --file "$SCHEMA_FILE" +fi + +echo "Running stack integration tests against $SUPABASE_TEST_URL" +cd "$BACKEND_DIR" +exec npx vitest run \ + src/__tests__/integration/stack.supabase.test.ts \ + src/__tests__/integration/access.supabase.test.ts \ + "$@" diff --git a/backend/src/__tests__/integration/access.supabase.test.ts b/backend/src/__tests__/integration/access.supabase.test.ts new file mode 100644 index 0000000..edfea6d --- /dev/null +++ b/backend/src/__tests__/integration/access.supabase.test.ts @@ -0,0 +1,97 @@ +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, it } from "vitest"; +import { + filterAccessibleDocumentIds, + listAccessibleProjectIds, +} from "../../lib/access"; + +// Gated: runs only against a real (local) Supabase stack. +// supabase start, then export: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY +// or use scripts/test-stack.sh which reads them from `supabase status`. +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; + +const maybeDescribe = url && serviceKey ? describe : describe.skip; + +maybeDescribe("Supabase access integration", () => { + it("proves tabular document filtering drops foreign document IDs", async () => { + const admin = createClient(url!, serviceKey!, { + auth: { persistSession: false }, + }); + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const ownerId = crypto.randomUUID(); + const reviewerId = crypto.randomUUID(); + const sharedProjectId = crypto.randomUUID(); + const privateProjectId = crypto.randomUUID(); + const sharedDocId = crypto.randomUUID(); + const privateDocId = crypto.randomUUID(); + + try { + const projectsInsert = await admin.from("projects").insert([ + { + id: sharedProjectId, + user_id: ownerId, + name: `shared-${suffix}`, + shared_with: [`reviewer-${suffix}@example.com`], + }, + { + id: privateProjectId, + user_id: ownerId, + name: `private-${suffix}`, + shared_with: [], + }, + ]); + if (projectsInsert.error) { + throw new Error( + `Could not seed projects: ${projectsInsert.error.message}`, + { cause: projectsInsert.error }, + ); + } + + // filename/file_type live on document_versions in this schema — + // the documents rows only need identity + ownership columns. + const documentsInsert = await admin.from("documents").insert([ + { + id: sharedDocId, + user_id: ownerId, + project_id: sharedProjectId, + }, + { + id: privateDocId, + user_id: ownerId, + project_id: privateProjectId, + }, + ]); + if (documentsInsert.error) { + throw new Error( + `Could not seed documents: ${documentsInsert.error.message}`, + { cause: documentsInsert.error }, + ); + } + + await expect( + listAccessibleProjectIds( + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toContain(sharedProjectId); + + await expect( + filterAccessibleDocumentIds( + [sharedDocId, privateDocId], + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toEqual([sharedDocId]); + } finally { + await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]); + await admin + .from("projects") + .delete() + .in("id", [sharedProjectId, privateProjectId]); + } + }); +}); diff --git a/backend/src/__tests__/integration/chat.routes.test.ts b/backend/src/__tests__/integration/chat.routes.test.ts new file mode 100644 index 0000000..64e71ec --- /dev/null +++ b/backend/src/__tests__/integration/chat.routes.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above +// the imports) can reference it. Lets each test drive the stream outcome. +const { runLLMStream } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), +})); + +// A permissive, chainable Supabase stub. Every query-builder method returns the +// same object (so arbitrary chains work), the object is awaitable (thenable), +// and the terminal single()/maybeSingle() resolve to a chat row. The chat +// routes only read `.id`/`.title` and check `.error`, so this is enough to let +// a request flow through chat creation and message inserts without real IO. +function makeQuery() { + const result = { data: { id: "chat-1", title: null }, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// Authenticate every request as user "u1" without exercising the real Supabase +// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by +// the app) imports it at module load. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Keep the real error helpers (the failure-path test relies on genuine +// isAbortError + AssistantStreamError behavior) but stub the functions that +// would otherwise hit the DB or the LLM. +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /chat — streaming endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "hi there", + events: [], + citations: [], + }); + }); + + it("streams SSE with a chat_id event on the happy path", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + // Headers were already flushed (200) before the stream threw, so the + // failure surfaces as an in-stream error event + [DONE]. + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); + + it("returns 400 on an empty messages array (never starts a stream)", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ messages: [] }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("detail"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when messages is missing entirely", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when chat_id is not a non-empty string", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ ...VALID_BODY, chat_id: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("chat_id must be a non-empty string"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /chat/:chatId", () => { + it("returns 400 when title is missing", async () => { + const res = await request(app) + .patch("/chat/chat-1") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("title is required"); + }); +}); diff --git a/backend/src/__tests__/integration/documentsUpload.routes.test.ts b/backend/src/__tests__/integration/documentsUpload.routes.test.ts new file mode 100644 index 0000000..e7544cf --- /dev/null +++ b/backend/src/__tests__/integration/documentsUpload.routes.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +function mockSupabase() { + const result = { data: null, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "order", "limit", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve(result).then(resolve); + return { + from: vi.fn(() => q), + rpc: vi.fn(() => Promise.resolve(result)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Stub the storage IO functions so a request that clears validation never +// touches R2/S3, while keeping the rest of the storage module (key builders, +// disposition helpers) real. The validation tests below reject before storage +// is reached, but this guards against accidental real IO regardless. +vi.mock("../../lib/storage", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uploadFile: vi.fn(async () => {}), + downloadFile: vi.fn(async () => null), + deleteFile: vi.fn(async () => {}), + }; +}); + +import { app } from "../../app"; + +describe("POST /single-documents — upload validation", () => { + it("rejects an unsupported file extension with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .attach("file", Buffer.from("hello world"), { + filename: "notes.txt", + contentType: "text/plain", + }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/unsupported file type/i); + }); + + it("rejects a request with no file attached with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .field("note", "no file here"); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("file is required"); + }); +}); + +describe("POST /single-documents/download-zip — bounds", () => { + it("returns 400 when document_ids is empty", async () => { + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: [] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/document_ids is required/i); + }); + + it("returns 404 when none of the requested documents are accessible", async () => { + // The documents lookup resolves to no rows (stubbed DB), so the + // access filter leaves nothing to zip. + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: ["d-other-user"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("No documents found"); + }); +}); diff --git a/backend/src/__tests__/integration/health.test.ts b/backend/src/__tests__/integration/health.test.ts new file mode 100644 index 0000000..8a36172 --- /dev/null +++ b/backend/src/__tests__/integration/health.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at +// request time (not import time), so setting them here is early enough even +// though imported modules evaluate before this assignment runs. +process.env.SUPABASE_URL = "http://supabase.test.local"; +process.env.SUPABASE_SECRET_KEY = "test-service-key"; + +// Mock the supabase-js client factory so the real requireAuth middleware never +// makes a network call: auth.getUser() resolves to no user for any token, +// simulating an invalid/expired JWT. +vi.mock("@supabase/supabase-js", () => ({ + createClient: vi.fn(() => ({ + from: () => { + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "filter", + "order", "limit", + ]; + for (const m of chain) q[m] = () => q; + q.single = () => Promise.resolve({ data: null, error: null }); + q.maybeSingle = () => Promise.resolve({ data: null, error: null }); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve({ data: null, error: null }).then(resolve); + return q; + }, + rpc: () => Promise.resolve({ data: null, error: null }), + auth: { + getUser: () => + Promise.resolve({ data: { user: null }, error: null }), + }, + })), +})); + +// Vitest hoists vi.mock() calls before all imports, so this regular import +// receives the mocked supabase-js module even though it appears after the +// vi.mock() call in source order. +import { app } from "../../app"; + +describe("GET /health", () => { + it("returns 200 with { ok: true }", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); + +describe("requireAuth middleware", () => { + it("rejects requests with no Authorization header (401)", async () => { + const res = await request(app).get("/chat"); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("detail"); + }); + + it("rejects requests with a non-Bearer Authorization header (401)", async () => { + const res = await request(app) + .get("/chat") + .set("Authorization", "Basic dXNlcjpwYXNz"); + expect(res.status).toBe(401); + }); + + it("rejects requests with an invalid Bearer token (401)", async () => { + // The mocked createClient().auth.getUser returns { user: null } for + // any token — simulating an expired/invalid token. + const res = await request(app) + .get("/chat") + .set("Authorization", "Bearer invalid-token"); + expect(res.status).toBe(401); + expect(res.body.detail).toMatch(/invalid|expired/i); + }); +}); + +describe("404 handling", () => { + it("returns 404 for unknown routes", async () => { + const res = await request(app).get("/this-route-does-not-exist"); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/integration/projectChat.routes.test.ts b/backend/src/__tests__/integration/projectChat.routes.test.ts new file mode 100644 index 0000000..8f6af78 --- /dev/null +++ b/backend/src/__tests__/integration/projectChat.routes.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), + checkProjectAccess: vi.fn(), +})); + +function makeQuery() { + const result = { + data: { id: "chat-1", title: null, project_id: "p1" }, + error: null, + }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildProjectDocContext: vi.fn(async () => ({ + docIndex: {}, + docStore: new Map(), + folderPaths: new Map(), + })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /projects/:projectId/chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "", + events: [], + citations: [], + }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + }); + + it("returns 404 and never streams when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + // The guard fires before any LLM stream. + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("streams SSE on the happy path with project access granted", async () => { + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); +}); diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts new file mode 100644 index 0000000..408f24e --- /dev/null +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we want to reconfigure per-test. +// --------------------------------------------------------------------------- +const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({ + checkProjectAccess: vi.fn(), + deleteUserProjects: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach; +// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the +// per-table result, and rpc() resolves to a per-call result. Insert payloads +// are recorded so tests can assert on normalisation (lowercasing / dedupe). +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Every export of lib/access must be present — other routers (chat, documents, +// downloads, tabular) import from it at app load. +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +// user router imports all four cleanup helpers at module load. +vi.mock("../../lib/userDataCleanup", () => ({ + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), + deleteAllUserChats: vi.fn(async () => {}), + deleteAllUserTabularReviews: vi.fn(async () => {}), + deleteUserAccountData: vi.fn(async () => {}), +})); + +// Version-path enrichment hits the DB in real life; no-op it so the route +// responses are driven purely by the documents/projects table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: vi.fn(async () => null), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("projects.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + deleteUserProjects.mockResolvedValue(1); + }); + + // ── GET /projects (overview) ────────────────────────────────────────── + describe("GET /projects", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "p1", name: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /projects (create) ─────────────────────────────────────────── + describe("POST /projects", () => { + it("returns 400 when name is missing/blank", async () => { + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("name is required"); + }); + + it("returns 400 when sharing the project with yourself", async () => { + // The authed user's email is u1@test.local; supplying it (in any + // case) must be rejected. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Beta", shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("creates the project (201) and normalises shared_with", async () => { + // Sharing requires each recipient to have a mirrored user_profiles + // row (findMissingUserEmails); seed both emails so validation + // passes and the create path proceeds. + supabaseState.tables.user_profiles = { + data: [{ email: "a@x.com" }, { email: "b@x.com" }], + error: null, + }; + supabaseState.tables.projects = { + data: { + id: "p9", + name: "Gamma", + user_id: "u1", + shared_with: ["a@x.com", "b@x.com"], + }, + error: null, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ + name: " Gamma ", + shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "p9", documents: [] }); + + // The insert payload should be lowercased, deduped, trimmed and + // the name trimmed. + const insert = supabaseState.inserts.find( + (i) => i.table === "projects", + ); + expect(insert?.payload).toMatchObject({ + name: "Gamma", + shared_with: ["a@x.com", "b@x.com"], + }); + }); + + it("returns 400 when a shared_with recipient is not a Mike user", async () => { + // No user_profiles rows seeded → findMissingUserEmails reports the + // recipient as unknown and the create is rejected before insert. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Gamma", shared_with: ["ghost@x.com"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "ghost@x.com does not belong to a Mike user.", + ); + expect( + supabaseState.inserts.find((i) => i.table === "projects"), + ).toBeUndefined(); + }); + + it("returns 500 when the insert errors", async () => { + supabaseState.tables.projects = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Delta" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /projects/:projectId (detail, inline access) ────────────────── + describe("GET /projects/:projectId", () => { + it("returns 404 when the project does not exist", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 404 when the caller is neither owner nor shared", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["other@x.com"], + }, + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("grants access to a shared member (is_owner false)", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["u1@test.local"], + }, + error: null, + }; + supabaseState.tables.documents = { data: [], error: null }; + supabaseState.tables.project_subfolders = { data: [], error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "p1", is_owner: false }); + }); + + it("returns 200 with documents/folders/is_owner when owned", async () => { + supabaseState.tables.projects = { + data: { id: "p1", user_id: "u1", shared_with: null }, + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", user_id: "u1" }], + error: null, + }; + supabaseState.tables.project_subfolders = { + data: [{ id: "f1" }], + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "p1", + is_owner: true, + documents: [{ id: "d1" }], + folders: [{ id: "f1" }], + }); + }); + }); + + // ── GET /projects/:projectId/documents (checkProjectAccess guard) ───── + describe("GET /projects/:projectId/documents", () => { + it("returns 404 when checkProjectAccess denies access", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + + it("returns 200 with documents when access is granted", async () => { + supabaseState.tables.documents = { + data: [{ id: "d1" }, { id: "d2" }], + error: null, + }; + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + }); + + // ── PATCH /projects/:projectId (sharing normalisation) ──────────────── + describe("PATCH /projects/:projectId", () => { + it("returns 400 when sharing the project with yourself", async () => { + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ shared_with: ["u1@test.local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("returns 404 when the update matches no owned project", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ name: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + }); + + // ── DELETE /projects/:projectId ─────────────────────────────────────── + describe("DELETE /projects/:projectId", () => { + it("returns 404 when nothing was deleted", async () => { + deleteUserProjects.mockResolvedValue(0); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 204 when the project is deleted", async () => { + deleteUserProjects.mockResolvedValue(1); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(204); + // Signature is deleteUserProjects(db, userId, [projectId]). + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ["p1"], + ); + }); + + it("returns 500 when deletion throws", async () => { + deleteUserProjects.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/stack.supabase.test.ts b/backend/src/__tests__/integration/stack.supabase.test.ts new file mode 100644 index 0000000..b74a051 --- /dev/null +++ b/backend/src/__tests__/integration/stack.supabase.test.ts @@ -0,0 +1,146 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth + +// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed +// Supabase version set safe — it's what you re-run on every image bump to prove +// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors +// the security model's central claim: RLS denies the user/anon path, and the API +// reaches data only via the service-role key. +// +// Gated: skipped unless a stack is provided (default CI unit run skips it). +// Locally: `supabase start`, then export the printed keys as: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const anonKey = process.env.SUPABASE_TEST_ANON_KEY; +const maybeDescribe = + url && serviceKey && anonKey ? describe : describe.skip; + +// Every public table the app owns (backend/schema.sql + migrations). The +// anon/user path must never return rows from any of these (deny-all); a +// regression that ships a table without RLS — or with a permissive policy — +// trips the leak sweep below. A table missing from an older local stack +// returns an error (no rows), which never counts as a leak. +const PUBLIC_TABLES = [ + "chat_messages", "chats", "courtlistener_citation_index", + "courtlistener_opinion_cluster_index", "document_edits", + "document_versions", "documents", "hidden_workflows", "library_folders", + "project_subfolders", "projects", "tabular_cells", + "tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews", + "user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors", + "user_mcp_oauth_states", "user_mcp_oauth_tokens", + "user_mcp_tool_audit_logs", "user_profiles", + "workflow_open_source_submissions", "workflow_shares", "workflows", +]; + +maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => { + const password = "StackTest1!"; + const emailA = `stack-a-${Date.now()}@test.local`; + const emailB = `stack-b-${Date.now()}@test.local`; + + let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path + let userA = ""; + let userB = ""; + let tokenA = ""; + let projectId = ""; + + // A client acting as a signed-in end user (anon key + the user's JWT): this is + // the path RLS must fence off. + const asUser = (token: string) => + createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + + beforeAll(async () => { + admin = createClient(url!, serviceKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const a = await admin.auth.admin.createUser({ + email: emailA, password, email_confirm: true, + }); + const b = await admin.auth.admin.createUser({ + email: emailB, password, email_confirm: true, + }); + if (a.error || !a.data.user) throw a.error ?? new Error("no user A"); + if (b.error || !b.data.user) throw b.error ?? new Error("no user B"); + userA = a.data.user.id; + userB = b.data.user.id; + + // Sign in as A to get a real access token (the token the API middleware + // validates via auth.getUser). + const signIn = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailA, password }); + if (signIn.error || !signIn.data.session) { + throw signIn.error ?? new Error("no session for A"); + } + tokenA = signIn.data.session.access_token; + + // Seed one row owned by A via the service role (the app's real write path). + const proj = await admin + .from("projects") + .insert({ user_id: userA, name: "Stack Test Project" }) + .select("id") + .single(); + if (proj.error || !proj.data) throw proj.error ?? new Error("no project"); + projectId = proj.data.id; + }); + + afterAll(async () => { + if (projectId) await admin.from("projects").delete().eq("id", projectId); + if (userA) await admin.auth.admin.deleteUser(userA); + if (userB) await admin.auth.admin.deleteUser(userB); + }); + + it("auth contract: the access token resolves to its user (middleware path)", async () => { + const { data, error } = await admin.auth.getUser(tokenA); + expect(error).toBeNull(); + expect(data.user?.id).toBe(userA); + expect(data.user?.email).toBe(emailA); + }); + + it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => { + // Service role (app data path) sees the project… + const svc = await admin + .from("projects").select("id").eq("id", projectId); + expect(svc.error).toBeNull(); + expect(svc.data ?? []).toHaveLength(1); + + // …but the owner, going through the user/anon path, sees zero rows — + // deny-all RLS is the firewall; the app must use the service role. + const owner = await asUser(tokenA) + .from("projects").select("id").eq("id", projectId); + expect(owner.data ?? []).toHaveLength(0); + + // And the owner's profile (if any) is equally invisible to the user path. + const prof = await asUser(tokenA) + .from("user_profiles").select("user_id").eq("user_id", userA); + expect(prof.data ?? []).toHaveLength(0); + }); + + it("tenant isolation: user B cannot read user A's project via the user path", async () => { + const signInB = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailB, password }); + const tokenB = signInB.data.session!.access_token; + + const cross = await asUser(tokenB) + .from("projects").select("id").eq("id", projectId); + expect(cross.data ?? []).toHaveLength(0); + }); + + it("leak sweep: no public table returns rows to the authenticated user path", async () => { + const client = asUser(tokenA); + const leaks: string[] = []; + for (const table of PUBLIC_TABLES) { + const { data } = await client.from(table).select("*").limit(1); + if ((data ?? []).length > 0) leaks.push(table); + } + // Any table returning rows to a normal user means RLS is missing or a + // policy is permissive — the exact regression this guards against. + expect(leaks).toEqual([]); + }); +}); diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts new file mode 100644 index 0000000..c828148 --- /dev/null +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -0,0 +1,713 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns reconfigured per-test. Access helpers + model settings are +// mocked so the tests drive review-access decisions, document-access filtering +// and the missing-API-key guard without touching real Supabase / LLM IO. The +// streaming endpoints (chat/generate) are only exercised up to their GUARDS — +// the SSE loop itself is never reached in these tests. +// --------------------------------------------------------------------------- +const { + ensureReviewAccess, + checkProjectAccess, + filterAccessibleDocumentIds, + getUserModelSettings, + loadActiveVersion, +} = vi.hoisted(() => ({ + ensureReviewAccess: vi.fn(), + checkProjectAccess: vi.fn(), + filterAccessibleDocumentIds: vi.fn(), + getUserModelSettings: vi.fn(), + loadActiveVersion: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds +// `supabaseState` in beforeEach; terminal query operations resolve to the +// per-table result, rpc() resolves to a per-call result. Insert payloads are +// recorded so tests can assert on what got persisted. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/access", () => ({ + ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args), + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + filterAccessibleDocumentIds: (...args: unknown[]) => + filterAccessibleDocumentIds(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +// Version-path enrichment + active-version resolution hit the DB in real life; +// no-op them so route responses are driven purely by the table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("tabular.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: caller is the owner with full access. + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + // Default: every requested doc is accessible (identity passthrough). + filterAccessibleDocumentIds.mockImplementation( + async (ids: string[]) => ids, + ); + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: { claude: "sk-test" }, + }); + loadActiveVersion.mockResolvedValue(null); + }); + + // ── GET /tabular-review (overview) ──────────────────────────────────── + describe("GET /tabular-review", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "r1", title: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /tabular-review (create) ───────────────────────────────────── + describe("POST /tabular-review", () => { + it("creates a review (201) and only persists accessible documents", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r9", title: "Gamma", document_ids: ["d1"] }, + error: null, + }; + // d2 is not accessible — it must be filtered out of the insert. + filterAccessibleDocumentIds.mockResolvedValue(["d1"]); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + title: "Gamma", + document_ids: ["d1", "d2"], + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "r9" }); + + const reviewInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_reviews", + ); + expect(reviewInsert?.payload).toMatchObject({ + document_ids: ["d1"], + }); + // Cells are created for accessible docs × columns only (1 × 1). + const cellInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_cells", + ); + expect(cellInsert?.payload).toEqual([ + { + review_id: "r9", + document_id: "d1", + column_index: 0, + status: "pending", + }, + ]); + }); + + it("returns 404 when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + project_id: "p-nope", + document_ids: [], + columns_config: [], + }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 500 when the review insert errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ document_ids: [], columns_config: [] }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /tabular-review/:reviewId (detail) ──────────────────────────── + describe("GET /tabular-review/:reviewId", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 200 with review/cells/documents + is_owner", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + document_ids: ["d1"], + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { + data: [ + { + id: "c1", + document_id: "d1", + column_index: 0, + content: null, + status: "pending", + }, + ], + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", current_version_id: null }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body.review).toMatchObject({ id: "r1", is_owner: true }); + expect(res.body.cells).toHaveLength(1); + expect(res.body.documents).toEqual([ + { id: "d1", current_version_id: null }, + ]); + }); + }); + + // ── PATCH /tabular-review/:reviewId ─────────────────────────────────── + describe("PATCH /tabular-review/:reviewId", () => { + it("returns 400 when project_id is an invalid type", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ project_id: 123 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "project_id must be a non-empty string or null", + ); + }); + + it("returns 400 when sharing the review with yourself", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a tabular review with yourself.", + ); + }); + + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ title: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 403 when a non-owner edits columns_config", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: "p1" }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false }); + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] }); + + expect(res.status).toBe(403); + expect(res.body.detail).toBe("Only the review owner can change columns"); + }); + }); + + // ── DELETE /tabular-review/:reviewId ────────────────────────────────── + describe("DELETE /tabular-review/:reviewId", () => { + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(204); + }); + + it("returns 500 when the delete errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "delete failed" }, + }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("delete failed"); + }); + }); + + // ── POST /tabular-review/:reviewId/clear-cells ──────────────────────── + describe("POST /tabular-review/:reviewId/clear-cells", () => { + it("returns 400 when document_ids is missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("document_ids is required"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(204); + }); + }); + + // ── POST /tabular-review/:reviewId/regenerate-cell ──────────────────── + describe("POST /tabular-review/:reviewId/regenerate-cell", () => { + it("returns 400 when document_id / column_index are missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "document_id and column_index are required", + ); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when the column is not configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 5, name: "Other", prompt: "p" }], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Column not found"); + }); + + it("returns 404 when the document is not accessible", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + filterAccessibleDocumentIds.mockResolvedValue([]); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d-forbidden", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Document not found"); + }); + + it("returns 422 with missing_api_key when the model key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.documents = { + data: { id: "d1", current_version_id: null }, + error: null, + }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + expect(res.body.provider).toBe("claude"); + }); + }); + + // ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ─── + describe("POST /tabular-review/:reviewId/generate", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when no columns are configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("No columns configured"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ─────── + describe("POST /tabular-review/:reviewId/chat", () => { + it("returns 400 when no user message is present", async () => { + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "assistant", content: "hi" }] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("messages must include a user message"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── GET /tabular-review/:reviewId/chats ─────────────────────────────── + describe("GET /tabular-review/:reviewId/chats", () => { + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns the chat list when access is granted", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + supabaseState.tables.tabular_review_chats = { + data: [{ id: "chat-1", title: "T", user_id: "u1" }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { id: "chat-1", title: "T", user_id: "u1" }, + ]); + }); + }); +}); diff --git a/backend/src/__tests__/integration/user.routes.test.ts b/backend/src/__tests__/integration/user.routes.test.ts new file mode 100644 index 0000000..d47b5bb --- /dev/null +++ b/backend/src/__tests__/integration/user.routes.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we reconfigure per-test. These cover the three security +// surfaces this suite baselines: +// - the MFA route guard (requireMfaIfEnrolled) +// - the API-key crypto boundary (userApiKeys) +// - the destructive data export / deletion helpers (userDataExport / +// userDataCleanup) +// Each is a vi.fn so we can both reconfigure behaviour and assert call args. +// --------------------------------------------------------------------------- +const { + requireMfaIfEnrolled, + getUserApiKeyStatus, + saveUserApiKey, + hasEnvApiKey, + normalizeApiKeyProvider, + deleteAllUserChats, + deleteAllUserTabularReviews, + deleteUserAccountData, + deleteUserProjects, + buildUserAccountExport, + buildUserChatsExport, + buildUserTabularReviewsExport, +} = vi.hoisted(() => ({ + requireMfaIfEnrolled: vi.fn(), + getUserApiKeyStatus: vi.fn(), + saveUserApiKey: vi.fn(), + hasEnvApiKey: vi.fn(), + normalizeApiKeyProvider: vi.fn(), + deleteAllUserChats: vi.fn(), + deleteAllUserTabularReviews: vi.fn(), + deleteUserAccountData: vi.fn(), + deleteUserProjects: vi.fn(), + buildUserAccountExport: vi.fn(), + buildUserChatsExport: vi.fn(), + buildUserTabularReviewsExport: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. The only route in this suite that reaches the +// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests +// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the +// per-table result and auth.admin methods are stubbed where routes call them. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + tables: Record; + adminGetUserById: QueryResult; + adminDeleteUser: { error: unknown }; +}; + +function resetSupabaseState() { + supabaseState = { + tables: {}, + adminGetUserById: { + data: { user: { id: "u1", factors: [] } }, + error: null, + }, + adminDeleteUser: { error: null }, + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", "insert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + admin: { + getUserById: vi.fn(() => + Promise.resolve(supabaseState.adminGetUserById), + ), + deleteUser: vi.fn(() => + Promise.resolve(supabaseState.adminDeleteUser), + ), + }, + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable +// guard so we can drive both the satisfied (next()) and rejected +// (403 mfa_verification_required) paths. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + res.locals.token = "test-token"; + next(); + }, + requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) => + requireMfaIfEnrolled(req, res, next), +})); + +// API-key crypto boundary: the route must funnel writes through saveUserApiKey +// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns +// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings +// imports it at module load. +vi.mock("../../lib/userApiKeys", () => ({ + getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args), + saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args), + hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args), + normalizeApiKeyProvider: (...args: unknown[]) => + normalizeApiKeyProvider(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/userDataCleanup", () => ({ + deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args), + deleteAllUserTabularReviews: (...args: unknown[]) => + deleteAllUserTabularReviews(...args), + deleteUserAccountData: (...args: unknown[]) => + deleteUserAccountData(...args), + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), +})); + +vi.mock("../../lib/userDataExport", () => ({ + buildUserAccountExport: (...args: unknown[]) => + buildUserAccountExport(...args), + buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args), + buildUserTabularReviewsExport: (...args: unknown[]) => + buildUserTabularReviewsExport(...args), + userExportFilename: (kind: string, userId: string) => + `mike-${kind}-export-${userId.slice(0, 8)}.json`, +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +// A complete user_profiles row with credits_reset_date in the future so the +// monthly-reset branch in loadProfile is not triggered. +function profileRow(overrides: Record = {}) { + return { + display_name: "Ada", + organisation: "Acme", + message_credits_used: 3, + credits_reset_date: "2999-01-01T00:00:00.000Z", + tier: "Pro", + title_model: null, + tabular_model: "gemini-3-flash-preview", + mfa_on_login: false, + legal_research_us: true, + ...overrides, + }; +} + +const STATUS = { claude: true, openai: false, gemini: false, sources: {} }; + +// The exact 403 body the web client's MFA gate consumes (mirrors the real +// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor. +function rejectMfa(_req: unknown, res: any) { + res.status(403).json({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); +} + +describe("user.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: MFA satisfied (guard passes through). + requireMfaIfEnrolled.mockImplementation( + (_req: unknown, _res: unknown, next: () => void) => next(), + ); + getUserApiKeyStatus.mockResolvedValue(STATUS); + saveUserApiKey.mockResolvedValue(undefined); + hasEnvApiKey.mockReturnValue(false); + normalizeApiKeyProvider.mockImplementation((v: string) => + ["claude", "openai", "gemini"].includes(v) ? v : null, + ); + deleteAllUserChats.mockResolvedValue(undefined); + deleteAllUserTabularReviews.mockResolvedValue(undefined); + deleteUserAccountData.mockResolvedValue(undefined); + deleteUserProjects.mockResolvedValue(undefined); + buildUserAccountExport.mockResolvedValue({ account: "data" }); + buildUserChatsExport.mockResolvedValue({ chats: "data" }); + buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" }); + }); + + // ── GET /user/profile (MFA bootstrap path) ──────────────────────────── + describe("GET /user/profile", () => { + it("returns the serialized profile plus apiKeyStatus", async () => { + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + displayName: "Ada", + organisation: "Acme", + messageCreditsUsed: 3, + tier: "Pro", + legalResearchUs: true, + mfaOnLogin: false, + apiKeyStatus: STATUS, + }); + // Presence-only key status — never plaintext. + expect(JSON.stringify(res.body)).not.toContain("sk-"); + }); + + it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => { + // Even if the MFA factor were unsatisfied, profile must remain + // reachable so the client can render the verification gate. + requireMfaIfEnrolled.mockImplementation(rejectMfa); + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + + it("returns 500 with detail when the profile load errors", async () => { + supabaseState.tables.user_profiles = { + data: null, + error: { message: "db down" }, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("db down"); + }); + }); + + // ── POST /user/profile (bootstrap upsert) ───────────────────────────── + describe("POST /user/profile", () => { + it("ensures the profile row and returns ok", async () => { + const res = await request(app).post("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + }); + + // ── GET /user/api-keys (presence without plaintext) ─────────────────── + describe("GET /user/api-keys", () => { + it("returns the boolean key-status map", async () => { + const res = await request(app).get("/user/api-keys").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + expect(getUserApiKeyStatus).toHaveBeenCalledWith( + "u1", + expect.anything(), + ); + }); + }); + + // ── PUT /user/api-keys/:provider (crypto + MFA guard) ───────────────── + describe("PUT /user/api-keys/:provider", () => { + it("stores the key via the encryption helper and returns status", async () => { + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-secret-value" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + // The plaintext key must go through saveUserApiKey (the encryption + // boundary), keyed by provider + value, never persisted by the route. + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "claude", + "sk-secret-value", + expect.anything(), + ); + }); + + it("deletes the key when api_key is omitted (null value)", async () => { + const res = await request(app) + .put("/user/api-keys/openai") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(200); + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "openai", + null, + expect.anything(), + ); + }); + + it("returns 400 for an unsupported provider", async () => { + const res = await request(app) + .put("/user/api-keys/bogus") + .set(...AUTH) + .send({ api_key: "x" }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Unsupported provider"); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 409 when the provider is configured by the server env", async () => { + hasEnvApiKey.mockReturnValue(true); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(409); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 500 when saving the key throws", async () => { + saveUserApiKey.mockRejectedValue(new Error("kms unavailable")); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("kms unavailable"); + }); + + it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); + // Guarded: the crypto path is never reached. + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + }); + + // ── Data export endpoints (MFA-guarded, attachment headers) ─────────── + describe("data export endpoints", () => { + it("GET /user/export returns the account export as a JSON attachment", async () => { + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ account: "data" }); + expect(res.headers["content-type"]).toContain("application/json"); + expect(res.headers["content-disposition"]).toContain("attachment"); + expect(res.headers["content-disposition"]).toContain( + "mike-account-export-u1.json", + ); + expect(buildUserAccountExport).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("GET /user/chats/export returns the chats export", async () => { + const res = await request(app) + .get("/user/chats/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ chats: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-chats-export-u1.json", + ); + expect(buildUserChatsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/tabular-reviews/export returns the reviews export", async () => { + const res = await request(app) + .get("/user/tabular-reviews/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ reviews: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-tabular-reviews-export-u1.json", + ); + expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/export returns 500 when the builder throws", async () => { + buildUserAccountExport.mockRejectedValue(new Error("export boom")); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("export boom"); + }); + + it("GET /user/export is rejected when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(buildUserAccountExport).not.toHaveBeenCalled(); + }); + }); + + // ── Data deletion endpoints (MFA-guarded, cleanup helpers) ──────────── + describe("data deletion endpoints", () => { + it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => { + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserChats).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => { + const res = await request(app) + .delete("/user/projects") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => { + const res = await request(app) + .delete("/user/tabular-reviews") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserTabularReviews).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/account purges data then deletes the auth user (204)", async () => { + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(204); + // Account purge runs the cleanup helper with id + email. + expect(deleteUserAccountData).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("DELETE /user/account returns 500 when the auth-user delete errors", async () => { + supabaseState.adminDeleteUser = { error: { message: "auth boom" } }; + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("auth boom"); + }); + + it("DELETE /user/chats returns 500 when cleanup throws", async () => { + deleteAllUserChats.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + + it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(deleteUserAccountData).not.toHaveBeenCalled(); + }); + }); + + // ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ──────── + describe("PATCH /user/security/mfa-login", () => { + it("returns 400 when enabling without a verified TOTP factor", async () => { + supabaseState.adminGetUserById = { + data: { user: { id: "u1", factors: [] } }, + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(400); + expect(res.body.detail).toContain("authenticator app"); + }); + + it("enables MFA-on-login when a verified TOTP factor exists", async () => { + supabaseState.adminGetUserById = { + data: { + user: { + id: "u1", + factors: [ + { factor_type: "totp", status: "verified" }, + ], + }, + }, + error: null, + }; + supabaseState.tables.user_profiles = { + data: profileRow({ mfa_on_login: true }), + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mfaOnLogin: true }); + }); + + it("returns 400 on a non-boolean enabled field", async () => { + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: "yes" }); + + expect(res.status).toBe(400); + }); + + it("is rejected with 403 when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + }); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..19cb5c3 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,161 @@ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { chatRouter } from "./routes/chat"; +import { projectsRouter } from "./routes/projects"; +import { projectChatRouter } from "./routes/projectChat"; +import { documentsRouter } from "./routes/documents"; +import { libraryRouter } from "./routes/library"; +import { tabularRouter } from "./routes/tabular"; +import { workflowsRouter } from "./routes/workflows"; +import { userRouter } from "./routes/user"; +import { downloadsRouter } from "./routes/downloads"; +import { caseLawRouter } from "./routes/caseLaw"; + +export const app = express(); +const isProduction = process.env.NODE_ENV === "production"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function minutes(value: number): number { + return value * 60 * 1000; +} + +function hours(value: number): number { + return minutes(value * 60); +} + +function makeLimiter(options: { + windowMs: number; + max: number; + message?: string; +}) { + return rateLimit({ + windowMs: options.windowMs, + max: options.max, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.method === "OPTIONS", + message: { + detail: + options.message ?? "Too many requests. Please try again later.", + }, + }); +} + +const generalLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_GENERAL_MAX", 300), +}); + +const chatLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_MAX", 30), + message: "Too many chat requests. Please try again later.", +}); + +const chatCreateLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), +}); + +const uploadLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), + message: "Too many upload requests. Please try again later.", +}); + +const exportLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_EXPORT_MAX", 10), + message: "Too many export requests. Please try again later.", +}); + +const dataDeleteLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), + message: "Too many data deletion requests. Please try again later.", +}); + +function jsonLimitForPath(path: string): string { + return "50mb"; +} + +app.disable("x-powered-by"); +app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: isProduction + ? { + maxAge: 15552000, + includeSubDomains: true, + } + : false, + referrerPolicy: { policy: "no-referrer" }, + }), +); + +app.use( + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + credentials: true, + }), +); + +app.use(generalLimiter); + +app.post("/chat", chatLimiter); +app.post("/projects/:projectId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/generate", chatLimiter); +app.post("/chat/create", chatCreateLimiter); +app.post("/chat/:chatId/generate-title", chatCreateLimiter); +app.post("/single-documents", uploadLimiter); +app.post("/library/:kind/documents", uploadLimiter); +app.post("/single-documents/:documentId/versions", uploadLimiter); +app.put( + "/single-documents/:documentId/versions/:versionId/file", + uploadLimiter, +); +app.post("/projects/:projectId/documents", uploadLimiter); +app.get("/user/export", exportLimiter); +app.get("/user/chats/export", exportLimiter); +app.get("/user/tabular-reviews/export", exportLimiter); +app.delete("/user/account", dataDeleteLimiter); +app.delete("/user/chats", dataDeleteLimiter); +app.delete("/user/projects", dataDeleteLimiter); +app.delete("/user/tabular-reviews", dataDeleteLimiter); + +app.use((req, res, next) => + express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), +); + +app.use("/chat", chatRouter); +app.use("/projects", projectsRouter); +app.use("/projects/:projectId/chat", projectChatRouter); +app.use("/single-documents", documentsRouter); +app.use("/library", libraryRouter); +app.use("/tabular-review", tabularRouter); +app.use("/workflows", workflowsRouter); +app.use("/user", userRouter); +app.use("/users", userRouter); +app.use("/download", downloadsRouter); +app.use("/case-law", caseLawRouter); + +app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/index.ts b/backend/src/index.ts index b8d36cf..ddf28c7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,165 +1,6 @@ -import "dotenv/config"; -import express from "express"; -import cors from "cors"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import { chatRouter } from "./routes/chat"; -import { projectsRouter } from "./routes/projects"; -import { projectChatRouter } from "./routes/projectChat"; -import { documentsRouter } from "./routes/documents"; -import { libraryRouter } from "./routes/library"; -import { tabularRouter } from "./routes/tabular"; -import { workflowsRouter } from "./routes/workflows"; -import { userRouter } from "./routes/user"; -import { downloadsRouter } from "./routes/downloads"; -import { caseLawRouter } from "./routes/caseLaw"; +import { app } from "./app"; -const app = express(); const PORT = process.env.PORT ?? 3001; -const isProduction = process.env.NODE_ENV === "production"; - -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function minutes(value: number): number { - return value * 60 * 1000; -} - -function hours(value: number): number { - return minutes(value * 60); -} - -function makeLimiter(options: { - windowMs: number; - max: number; - message?: string; -}) { - return rateLimit({ - windowMs: options.windowMs, - max: options.max, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => req.method === "OPTIONS", - message: { - detail: - options.message ?? "Too many requests. Please try again later.", - }, - }); -} - -const generalLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_GENERAL_MAX", 300), -}); - -const chatLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_MAX", 30), - message: "Too many chat requests. Please try again later.", -}); - -const chatCreateLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), -}); - -const uploadLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), - message: "Too many upload requests. Please try again later.", -}); - -const exportLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_EXPORT_MAX", 10), - message: "Too many export requests. Please try again later.", -}); - -const dataDeleteLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), - message: "Too many data deletion requests. Please try again later.", -}); - -function jsonLimitForPath(path: string): string { - return "50mb"; -} - -app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); - -app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'none'"], - baseUri: ["'none'"], - frameAncestors: ["'none'"], - }, - }, - crossOriginEmbedderPolicy: false, - hsts: isProduction - ? { - maxAge: 15552000, - includeSubDomains: true, - } - : false, - referrerPolicy: { policy: "no-referrer" }, - }), -); - -app.use( - cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", - credentials: true, - }), -); - -app.use(generalLimiter); - -app.post("/chat", chatLimiter); -app.post("/projects/:projectId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/generate", chatLimiter); -app.post("/chat/create", chatCreateLimiter); -app.post("/chat/:chatId/generate-title", chatCreateLimiter); -app.post("/single-documents", uploadLimiter); -app.post("/library/:kind/documents", uploadLimiter); -app.post("/single-documents/:documentId/versions", uploadLimiter); -app.put( - "/single-documents/:documentId/versions/:versionId/file", - uploadLimiter, -); -app.post("/projects/:projectId/documents", uploadLimiter); -app.get("/user/export", exportLimiter); -app.get("/user/chats/export", exportLimiter); -app.get("/user/tabular-reviews/export", exportLimiter); -app.delete("/user/account", dataDeleteLimiter); -app.delete("/user/chats", dataDeleteLimiter); -app.delete("/user/projects", dataDeleteLimiter); -app.delete("/user/tabular-reviews", dataDeleteLimiter); - -app.use((req, res, next) => - express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), -); - -app.use("/chat", chatRouter); -app.use("/projects", projectsRouter); -app.use("/projects/:projectId/chat", projectChatRouter); -app.use("/single-documents", documentsRouter); -app.use("/library", libraryRouter); -app.use("/tabular-review", tabularRouter); -app.use("/workflows", workflowsRouter); -app.use("/user", userRouter); -app.use("/users", userRouter); -app.use("/download", downloadsRouter); -app.use("/case-law", caseLawRouter); - -app.get("/health", (_req, res) => res.json({ ok: true })); app.listen(PORT, () => { console.log(`Mike backend running on port ${PORT}`); diff --git a/backend/src/lib/__tests__/access.test.ts b/backend/src/lib/__tests__/access.test.ts new file mode 100644 index 0000000..ddc4656 --- /dev/null +++ b/backend/src/lib/__tests__/access.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + checkProjectAccess, + ensureDocAccess, + ensureReviewAccess, + filterAccessibleDocumentIds, + listAccessibleProjectIds, +} from "../access"; + +type Row = Record; + +function makeDb(tables: Record) { + return { + from(table: string) { + let rows = [...(tables[table] ?? [])]; + const query = { + select: () => query, + eq: (column: string, value: unknown) => { + rows = rows.filter((row) => row[column] === value); + return query; + }, + neq: (column: string, value: unknown) => { + rows = rows.filter((row) => row[column] !== value); + return query; + }, + in: (column: string, values: unknown[]) => { + rows = rows.filter((row) => values.includes(row[column])); + return query; + }, + filter: (column: string, operator: string, value: string) => { + if (operator !== "cs") return query; + const expected = (JSON.parse(value) as string[]).map((item) => + item.toLowerCase(), + ); + rows = rows.filter((row) => { + const actual = row[column]; + const normalizedActual = Array.isArray(actual) + ? actual.map((item) => String(item).toLowerCase()) + : []; + return ( + Array.isArray(actual) && + expected.every((item) => normalizedActual.includes(item)) + ); + }); + return query; + }, + single: async () => ({ data: rows[0] ?? null, error: null }), + then: ( + resolve: (value: { data: Row[]; error: null }) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve({ data: rows, error: null }).then(resolve, reject), + }; + return query; + }, + } as any; +} + +describe("access helpers", () => { + const db = makeDb({ + projects: [ + { id: "own-project", user_id: "owner", shared_with: [] }, + { + id: "shared-project", + user_id: "other-owner", + shared_with: ["Reviewer@Example.com"], + }, + { id: "private-project", user_id: "other-owner", shared_with: [] }, + ], + documents: [ + { id: "own-doc", user_id: "owner", project_id: null }, + { + id: "shared-doc", + user_id: "other-owner", + project_id: "shared-project", + }, + { + id: "private-doc", + user_id: "other-owner", + project_id: "private-project", + }, + ], + }); + + it("allows project owners", async () => { + await expect( + checkProjectAccess("own-project", "owner", "owner@example.com", db), + ).resolves.toMatchObject({ ok: true, isOwner: true }); + }); + + it("allows shared project access case-insensitively", async () => { + await expect( + checkProjectAccess( + "shared-project", + "reviewer", + "reviewer@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: false }); + }); + + it("denies private project access", async () => { + await expect( + checkProjectAccess( + "private-project", + "reviewer", + "reviewer@example.com", + db, + ), + ).resolves.toEqual({ ok: false }); + }); + + it("allows document owners and shared-project readers", async () => { + await expect( + ensureDocAccess( + { user_id: "owner", project_id: null }, + "owner", + "owner@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: true }); + + await expect( + ensureDocAccess( + { user_id: "other-owner", project_id: "shared-project" }, + "reviewer", + "reviewer@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: false }); + }); + + it("filters user-supplied document IDs to accessible documents only", async () => { + await expect( + filterAccessibleDocumentIds( + ["own-doc", "shared-doc", "private-doc", "missing-doc"], + "reviewer", + "reviewer@example.com", + db, + ), + ).resolves.toEqual(["shared-doc"]); + }); + + it("lists own and directly shared projects", async () => { + await expect( + listAccessibleProjectIds("owner", "reviewer@example.com", db), + ).resolves.toEqual(expect.arrayContaining(["own-project", "shared-project"])); + }); + + it("allows direct review sharing without project access", async () => { + await expect( + ensureReviewAccess( + { + user_id: "other-owner", + project_id: null, + shared_with: ["Reviewer@Example.com"], + }, + "reviewer", + "reviewer@example.com", + db, + ), + ).resolves.toMatchObject({ ok: true, isOwner: false }); + }); +}); diff --git a/backend/src/lib/__tests__/chatTypes.test.ts b/backend/src/lib/__tests__/chatTypes.test.ts new file mode 100644 index 0000000..5da834a --- /dev/null +++ b/backend/src/lib/__tests__/chatTypes.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDoc, + resolveDocLabel, + type DocIndex, + type DocStore, +} from "../chat/types"; + +// --------------------------------------------------------------------------- +// resolveDoc +// --------------------------------------------------------------------------- + +describe("resolveDoc", () => { + const index: DocIndex = { + "doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" }, + "doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" }, + }; + + it("returns the doc entry for a known label", () => { + expect(resolveDoc("doc-1", index)).toEqual({ + document_id: "uuid-aaa", + filename: "contract.pdf", + }); + }); + + it("returns undefined for an unknown label", () => { + expect(resolveDoc("doc-99", index)).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(resolveDoc("", index)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveDocLabel +// --------------------------------------------------------------------------- + +describe("resolveDocLabel", () => { + const store: DocStore = new Map([ + ["doc-1", { storage_path: "path/a", file_type: "pdf", filename: "contract.pdf" }], + ["doc-2", { storage_path: "path/b", file_type: "pdf", filename: "nda.pdf" }], + ]); + + const index: DocIndex = { + "doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" }, + "doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" }, + }; + + it("resolves by label when the label is in the store", () => { + expect(resolveDocLabel("doc-1", store, index)).toBe("doc-1"); + }); + + it("resolves by filename when the filename matches a store entry", () => { + expect(resolveDocLabel("contract.pdf", store, index)).toBe("doc-1"); + }); + + it("resolves by document UUID via the docIndex", () => { + expect(resolveDocLabel("uuid-bbb", store, index)).toBe("doc-2"); + }); + + it("returns null when nothing matches", () => { + expect(resolveDocLabel("unknown-id", store, index)).toBeNull(); + }); + + it("returns null when docIndex is omitted and only UUID matches", () => { + // Without the index there is no fallback for raw UUIDs. + expect(resolveDocLabel("uuid-aaa", store)).toBeNull(); + }); + + it("prioritises exact label match over filename match", () => { + // If a label happens to equal a filename of a different doc, + // the label match wins. + const storeWithCrossMatch: DocStore = new Map([ + ["nda.pdf", { storage_path: "path/c", file_type: "pdf", filename: "contract.pdf" }], + ]); + // "nda.pdf" is a label here, and it IS in the store, so it should + // be returned directly without the filename-fallback loop. + expect(resolveDocLabel("nda.pdf", storeWithCrossMatch)).toBe("nda.pdf"); + }); +}); diff --git a/backend/src/lib/__tests__/citations.test.ts b/backend/src/lib/__tests__/citations.test.ts new file mode 100644 index 0000000..08b0c9c --- /dev/null +++ b/backend/src/lib/__tests__/citations.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect } from "vitest"; +import { + parseCitations, + parseCitationsWithDiagnostics, + parsePartialCitationObjects, + createCitation, + CITATIONS_OPEN_TAG, + CITATIONS_CLOSE_TAG, +} from "../chat/citations"; +import type { DocIndex } from "../chat/types"; + +function citationsBlock(json: string) { + return `Answer text.\n${CITATIONS_OPEN_TAG}\n${json}\n${CITATIONS_CLOSE_TAG}`; +} + +// --------------------------------------------------------------------------- +// parseCitationsWithDiagnostics +// --------------------------------------------------------------------------- + +describe("parseCitationsWithDiagnostics", () => { + it("reports no block when the tags are absent", () => { + const { citations, diagnostics } = + parseCitationsWithDiagnostics("plain answer"); + expect(citations).toEqual([]); + expect(diagnostics).toEqual({ hasBlock: false, rawLength: 0, error: null }); + }); + + it("reports a JSON parse error for malformed block content", () => { + const { citations, diagnostics } = parseCitationsWithDiagnostics( + citationsBlock("[{not json"), + ); + expect(citations).toEqual([]); + expect(diagnostics.hasBlock).toBe(true); + expect(diagnostics.rawLength).toBeGreaterThan(0); + expect(diagnostics.error).toBeTruthy(); + }); + + it("reports an error when the block JSON is not an array", () => { + const { citations, diagnostics } = parseCitationsWithDiagnostics( + citationsBlock('{"ref": 1}'), + ); + expect(citations).toEqual([]); + expect(diagnostics.error).toBe("CITATIONS block JSON was not an array."); + }); +}); + +// --------------------------------------------------------------------------- +// parseCitations — document citations +// --------------------------------------------------------------------------- + +describe("parseCitations (document citations)", () => { + it("parses a minimal document citation", () => { + const [citation] = parseCitations( + citationsBlock( + '[{"ref": 1, "doc_id": "doc-1", "page": 3, "quote": "the term"}]', + ), + ); + expect(citation).toMatchObject({ + kind: "document", + ref: 1, + doc_id: "doc-1", + page: 3, + quote: "the term", + }); + expect(citation.quotes).toHaveLength(1); + }); + + it("derives ref from a [N] marker when ref is missing", () => { + const [citation] = parseCitations( + citationsBlock( + '[{"marker": "[7]", "doc_id": "doc-1", "page": 1, "quote": "q"}]', + ), + ); + expect(citation.ref).toBe(7); + }); + + it("drops entries without a usable ref or marker", () => { + expect( + parseCitations( + citationsBlock('[{"doc_id": "doc-1", "quote": "q", "marker": "nope"}]'), + ), + ).toEqual([]); + }); + + it("drops document entries without doc_id or quote", () => { + expect( + parseCitations(citationsBlock('[{"ref": 1, "doc_id": "doc-1"}]')), + ).toEqual([]); + expect( + parseCitations(citationsBlock('[{"ref": 1, "quote": "q"}]')), + ).toEqual([]); + }); + + it("drops non-object entries but keeps valid ones", () => { + const citations = parseCitations( + citationsBlock( + '[null, "junk", {"ref": 2, "doc_id": "doc-2", "page": 4, "quote": "kept"}]', + ), + ); + expect(citations).toHaveLength(1); + expect(citations[0]).toMatchObject({ ref: 2, doc_id: "doc-2" }); + }); + + it("accepts a text field as a quote alias", () => { + const [citation] = parseCitations( + citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 2, "text": "aliased"}]'), + ); + expect(citation.kind).toBe("document"); + expect((citation as { quote: string }).quote).toBe("aliased"); + }); + + it("normalizes pages: numbers kept, ranges kept, junk becomes 1", () => { + const citations = parseCitations( + citationsBlock( + '[{"ref": 1, "doc_id": "d", "page": 5, "quote": "a"},' + + '{"ref": 2, "doc_id": "d", "page": "3-5", "quote": "b"},' + + '{"ref": 3, "doc_id": "d", "page": "12", "quote": "c"},' + + '{"ref": 4, "doc_id": "d", "page": "unknown", "quote": "d"}]', + ), + ); + expect(citations.map((c) => (c as { page: number | string }).page)).toEqual([ + 5, + "3-5", + 12, + 1, + ]); + }); + + it("keeps at most 3 quotes and inherits top-level page/sheet/cell", () => { + const [citation] = parseCitations( + citationsBlock( + JSON.stringify([ + { + ref: 1, + doc_id: "doc-1", + page: 9, + sheet: "Summary", + cell: "B7", + quotes: [ + { quote: "one" }, + { quote: "two", page: 2 }, + { quote: "three", sheet: "Detail", cell: "C1" }, + { quote: "four" }, + ], + }, + ]), + ), + ); + expect(citation.kind).toBe("document"); + const doc = citation as { + page: number | string; + quotes: { page: number | string; quote: string; sheet?: string; cell?: string }[]; + }; + expect(doc.quotes).toHaveLength(3); + expect(doc.quotes[0]).toEqual({ + page: 9, + quote: "one", + sheet: "Summary", + cell: "B7", + }); + expect(doc.quotes[1].page).toBe(2); + expect(doc.quotes[2]).toMatchObject({ sheet: "Detail", cell: "C1" }); + // Top-level fields mirror the first quote. + expect(doc.page).toBe(9); + }); + + it("skips quote rows without text", () => { + const [citation] = parseCitations( + citationsBlock( + '[{"ref": 1, "doc_id": "doc-1", "page": 1,' + + ' "quotes": [{"page": 2}, {"quote": " "}, {"quote": "real"}]}]', + ), + ); + expect((citation as { quotes: unknown[] }).quotes).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// parseCitations — case citations +// --------------------------------------------------------------------------- + +describe("parseCitations (case citations)", () => { + it("parses a case citation from a numeric cluster_id", () => { + const [citation] = parseCitations( + citationsBlock('[{"ref": 1, "cluster_id": 12345, "quote": "held that"}]'), + ); + expect(citation).toMatchObject({ kind: "case", ref: 1, cluster_id: 12345 }); + expect((citation as { quotes: unknown[] }).quotes).toEqual([ + { opinionId: null, type: null, author: null, quote: "held that" }, + ]); + }); + + it("accepts clusterId camelCase and string cluster ids", () => { + const citations = parseCitations( + citationsBlock( + '[{"ref": 1, "clusterId": 7, "quote": "a"},' + + '{"ref": 2, "cluster_id": "42", "quote": "b"}]', + ), + ); + expect(citations.map((c) => (c as { cluster_id: number }).cluster_id)).toEqual([ + 7, 42, + ]); + }); + + it("floors fractional cluster ids", () => { + const [citation] = parseCitations( + citationsBlock('[{"ref": 1, "cluster_id": 12.9, "quote": "q"}]'), + ); + expect((citation as { cluster_id: number }).cluster_id).toBe(12); + }); + + it("treats non-positive cluster ids as document citations", () => { + // cluster_id 0 fails the > 0 check, so the entry needs a doc_id. + expect( + parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 0, "quote": "q"}]')), + ).toEqual([]); + }); + + it("normalizes structured case quotes with opinion metadata", () => { + const [citation] = parseCitations( + citationsBlock( + JSON.stringify([ + { + ref: 3, + cluster_id: 99, + quotes: [ + { + quote: "majority text", + opinion_id: 11.7, + type: "majority", + author: "Judge A", + }, + { text: "concurrence text", opinionId: 12 }, + { type: "no quote text, dropped" }, + ], + }, + ]), + ), + ); + expect((citation as { quotes: unknown[] }).quotes).toEqual([ + { + opinionId: 11, + type: "majority", + author: "Judge A", + quote: "majority text", + }, + { opinionId: 12, type: null, author: null, quote: "concurrence text" }, + ]); + }); + + it("drops case citations with no quotes at all", () => { + expect( + parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 5}]')), + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// parsePartialCitationObjects +// --------------------------------------------------------------------------- + +describe("parsePartialCitationObjects", () => { + it("returns [] when no array has started", () => { + expect(parsePartialCitationObjects("streaming ")).toEqual([]); + }); + + it("extracts complete objects and ignores a trailing incomplete one", () => { + const partial = + '[{"ref": 1, "doc_id": "doc-1", "page": 2, "quote": "done"},' + + ' {"ref": 2, "doc_id": "doc-2", "page": 3, "quote": "still stream'; + const citations = parsePartialCitationObjects(partial); + expect(citations).toHaveLength(1); + expect(citations[0]).toMatchObject({ ref: 1, doc_id: "doc-1" }); + }); + + it("handles braces and escaped quotes inside string values", () => { + const partial = + '[{"ref": 1, "doc_id": "doc-1", "page": 1,' + + ' "quote": "clause {a} says \\"stop\\""}'; + const citations = parsePartialCitationObjects(partial); + expect(citations).toHaveLength(1); + expect((citations[0] as { quote: string }).quote).toBe( + 'clause {a} says "stop"', + ); + }); + + it("ignores content after the closing tag", () => { + const text = + '[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}]' + + '[{"ref": 9, "doc_id": "b", "page": 1, "quote": "after"}]'; + const citations = parsePartialCitationObjects(text); + expect(citations).toHaveLength(1); + expect(citations[0]).toMatchObject({ ref: 1 }); + }); + + it("stops at the array close bracket", () => { + const text = + '[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}] {"ref": 2, "doc_id": "b", "page": 1, "quote": "outside"}'; + const citations = parsePartialCitationObjects(text); + expect(citations).toHaveLength(1); + }); + + it("skips malformed objects but keeps later valid ones", () => { + const text = + '[{"ref": bad}, {"ref": 2, "doc_id": "doc-2", "page": 1, "quote": "ok"}'; + const citations = parsePartialCitationObjects(text); + expect(citations).toHaveLength(1); + expect(citations[0]).toMatchObject({ ref: 2 }); + }); +}); + +// --------------------------------------------------------------------------- +// createCitation +// --------------------------------------------------------------------------- + +describe("createCitation", () => { + const docIndex: DocIndex = { + "doc-1": { + document_id: "uuid-aaa", + filename: "contract.pdf", + version_id: "ver-1", + version_number: 2, + }, + }; + + it("builds a document citation payload from the doc index", () => { + const [parsed] = parseCitations( + citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 4, "quote": "q"}]'), + ); + expect(createCitation(parsed, docIndex)).toMatchObject({ + type: "citation_data", + kind: "document", + ref: 1, + doc_id: "doc-1", + document_id: "uuid-aaa", + version_id: "ver-1", + version_number: 2, + filename: "contract.pdf", + page: 4, + quote: "q", + }); + }); + + it("falls back to the raw doc_id as filename when unresolvable", () => { + const [parsed] = parseCitations( + citationsBlock('[{"ref": 1, "doc_id": "doc-9", "page": 1, "quote": "q"}]'), + ); + const citation = createCitation(parsed, docIndex); + expect(citation).toMatchObject({ + filename: "doc-9", + document_id: undefined, + version_id: null, + }); + }); + + it("enriches a case citation from the cluster map", () => { + const [parsed] = parseCitations( + citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'), + ); + const cases = new Map([ + [ + 55, + { + caseName: "Smith v. Jones", + citations: ["123 U.S. 456", "alt cite"], + url: "https://example.test/case", + pdfUrl: null, + dateFiled: "1990-01-02", + }, + ], + ]); + expect(createCitation(parsed, docIndex, cases)).toMatchObject({ + type: "citation_data", + kind: "case", + ref: 2, + cluster_id: 55, + case_name: "Smith v. Jones", + citation: "123 U.S. 456", + url: "https://example.test/case", + pdfUrl: null, + dateFiled: "1990-01-02", + }); + }); + + it("nulls case metadata when the cluster map has no entry", () => { + const [parsed] = parseCitations( + citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'), + ); + expect(createCitation(parsed, docIndex)).toMatchObject({ + case_name: null, + citation: null, + url: null, + pdfUrl: null, + dateFiled: null, + }); + }); +}); diff --git a/backend/src/lib/__tests__/documentVersions.test.ts b/backend/src/lib/__tests__/documentVersions.test.ts new file mode 100644 index 0000000..4442fa3 --- /dev/null +++ b/backend/src/lib/__tests__/documentVersions.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from "vitest"; +import { + loadActiveVersion, + attachActiveVersionPaths, + attachLatestVersionNumbers, +} from "../documentVersions"; + +type Row = Record; + +/** + * Read-only Supabase mock covering the query chains documentVersions uses: + * select/eq/in/is/not filters plus single() and awaiting the builder. + */ +function makeDb(tables: Record) { + return { + from(table: string) { + let rows = [...(tables[table] ?? [])]; + const query: any = { + select: () => query, + eq: (column: string, value: unknown) => { + rows = rows.filter((row) => row[column] === value); + return query; + }, + in: (column: string, values: unknown[]) => { + rows = rows.filter((row) => values.includes(row[column])); + return query; + }, + is: (column: string, value: unknown) => { + rows = rows.filter((row) => (row[column] ?? null) === value); + return query; + }, + not: (column: string, operator: string, value: unknown) => { + if (operator === "is" && value === null) { + rows = rows.filter((row) => row[column] != null); + } + return query; + }, + single: async () => ({ data: rows[0] ?? null, error: null }), + then: ( + resolve: (value: { data: Row[]; error: null }) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve({ data: rows, error: null }).then(resolve, reject), + }; + return query; + }, + } as any; +} + +/** Shape of the mutable doc rows the attach* helpers annotate in place. */ +type TestDoc = { + id: string; + current_version_id?: string | null; + latest_version_number?: number | null; + [k: string]: unknown; +}; + +const FULL_VERSION = { + id: "ver-1", + document_id: "doc-1", + storage_path: "documents/u/doc-1/source.pdf", + pdf_storage_path: "documents/u/doc-1/converted.pdf", + version_number: 3, + filename: "contract.pdf", + source: "upload", + file_type: "application/pdf", + size_bytes: 1024, + page_count: 12, + deleted_at: null, +}; + +// --------------------------------------------------------------------------- +// loadActiveVersion +// --------------------------------------------------------------------------- + +describe("loadActiveVersion", () => { + it("resolves the document's current version by default", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-1" }], + document_versions: [FULL_VERSION], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({ + id: "ver-1", + storage_path: "documents/u/doc-1/source.pdf", + pdf_storage_path: "documents/u/doc-1/converted.pdf", + version_number: 3, + filename: "contract.pdf", + source: "upload", + file_type: "application/pdf", + size_bytes: 1024, + page_count: 12, + }); + }); + + it("prefers an explicit versionId over current_version_id", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-1" }], + document_versions: [ + FULL_VERSION, + { ...FULL_VERSION, id: "ver-2", version_number: 2 }, + ], + }); + const version = await loadActiveVersion("doc-1", db, "ver-2"); + expect(version?.id).toBe("ver-2"); + expect(version?.version_number).toBe(2); + }); + + it("returns null when neither versionId nor current_version_id exist", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: null }], + document_versions: [FULL_VERSION], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull(); + }); + + it("returns null when the version belongs to a different document", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-other" }], + document_versions: [ + { ...FULL_VERSION, id: "ver-other", document_id: "doc-2" }, + ], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull(); + // Also guards against a spoofed explicit versionId. + await expect( + loadActiveVersion("doc-1", db, "ver-other"), + ).resolves.toBeNull(); + }); + + it("returns null for soft-deleted versions", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-1" }], + document_versions: [ + { ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" }, + ], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull(); + }); + + it("returns null when the version has no storage_path", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-1" }], + document_versions: [{ ...FULL_VERSION, storage_path: null }], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull(); + }); + + it("defaults optional metadata fields to null", async () => { + const db = makeDb({ + documents: [{ id: "doc-1", current_version_id: "ver-1" }], + document_versions: [ + { + id: "ver-1", + document_id: "doc-1", + storage_path: "documents/u/doc-1/source.docx", + deleted_at: null, + }, + ], + }); + await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({ + id: "ver-1", + storage_path: "documents/u/doc-1/source.docx", + pdf_storage_path: null, + version_number: null, + filename: null, + source: null, + file_type: null, + size_bytes: null, + page_count: null, + }); + }); +}); + +// --------------------------------------------------------------------------- +// attachActiveVersionPaths +// --------------------------------------------------------------------------- + +describe("attachActiveVersionPaths", () => { + it("returns the same empty array untouched", async () => { + const db = makeDb({ document_versions: [] }); + const docs: TestDoc[] = []; + await expect(attachActiveVersionPaths(db, docs)).resolves.toBe(docs); + }); + + it("nulls all fields when no document has a current version", async () => { + const db = makeDb({ document_versions: [FULL_VERSION] }); + const [doc] = await attachActiveVersionPaths(db, [ + { id: "doc-1", current_version_id: null }, + ]); + expect(doc).toMatchObject({ + filename: "Untitled document", + storage_path: null, + pdf_storage_path: null, + file_type: null, + size_bytes: null, + page_count: null, + }); + }); + + it("merges active-version metadata onto each row", async () => { + const db = makeDb({ + document_versions: [ + FULL_VERSION, + { + ...FULL_VERSION, + id: "ver-2", + storage_path: "documents/u/doc-2/source.docx", + pdf_storage_path: null, + filename: "nda.docx", + version_number: 1, + }, + ], + }); + const docs = await attachActiveVersionPaths(db, [ + { id: "doc-1", current_version_id: "ver-1" }, + { id: "doc-2", current_version_id: "ver-2" }, + { id: "doc-3", current_version_id: null }, + ]); + expect(docs[0]).toMatchObject({ + storage_path: "documents/u/doc-1/source.pdf", + pdf_storage_path: "documents/u/doc-1/converted.pdf", + active_version_number: 3, + filename: "contract.pdf", + file_type: "application/pdf", + size_bytes: 1024, + page_count: 12, + }); + expect(docs[1]).toMatchObject({ + storage_path: "documents/u/doc-2/source.docx", + pdf_storage_path: null, + active_version_number: 1, + filename: "nda.docx", + }); + // Mixed list: the version-less doc still gets explicit nulls. + expect(docs[2]).toMatchObject({ + storage_path: null, + filename: "Untitled document", + }); + }); + + it("falls back to 'Untitled document' for blank filenames", async () => { + const db = makeDb({ + document_versions: [{ ...FULL_VERSION, filename: " " }], + }); + const [doc] = await attachActiveVersionPaths(db, [ + { id: "doc-1", current_version_id: "ver-1" }, + ]); + expect(doc.filename).toBe("Untitled document"); + }); + + it("ignores soft-deleted versions", async () => { + const db = makeDb({ + document_versions: [ + { ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" }, + ], + }); + const [doc] = await attachActiveVersionPaths(db, [ + { id: "doc-1", current_version_id: "ver-1" }, + ]); + expect(doc.storage_path).toBeNull(); + expect(doc.filename).toBe("Untitled document"); + }); +}); + +// --------------------------------------------------------------------------- +// attachLatestVersionNumbers +// --------------------------------------------------------------------------- + +describe("attachLatestVersionNumbers", () => { + const versionRow = ( + document_id: string, + version_number: number | null, + overrides: Row = {}, + ) => ({ + document_id, + version_number, + source: "assistant_edit", + deleted_at: null, + ...overrides, + }); + + it("returns the same empty array untouched", async () => { + const db = makeDb({ document_versions: [] }); + const docs: TestDoc[] = []; + await expect(attachLatestVersionNumbers(db, docs)).resolves.toBe(docs); + }); + + it("attaches the max assistant_edit version number per document", async () => { + const db = makeDb({ + document_versions: [ + versionRow("doc-1", 1), + versionRow("doc-1", 4), + versionRow("doc-1", 2), + versionRow("doc-2", 7), + ], + }); + const docs = await attachLatestVersionNumbers(db, [ + { id: "doc-1" }, + { id: "doc-2" }, + { id: "doc-3" }, + ]); + expect(docs.map((d) => d.latest_version_number)).toEqual([4, 7, null]); + }); + + it("ignores non-assistant_edit and soft-deleted versions", async () => { + const db = makeDb({ + document_versions: [ + versionRow("doc-1", 9, { source: "upload" }), + versionRow("doc-1", 8, { deleted_at: "2026-01-01T00:00:00Z" }), + versionRow("doc-1", 2), + ], + }); + const docs = await attachLatestVersionNumbers(db, [{ id: "doc-1" }]); + expect(docs[0].latest_version_number).toBe(2); + }); +}); diff --git a/backend/src/lib/__tests__/downloadTokens.test.ts b/backend/src/lib/__tests__/downloadTokens.test.ts new file mode 100644 index 0000000..84e0201 --- /dev/null +++ b/backend/src/lib/__tests__/downloadTokens.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { signDownload, verifyDownload, buildDownloadUrl } from "../downloadTokens"; + +const SECRET = "test-secret-32-bytes-long-enough!!"; + +beforeAll(() => { + process.env.DOWNLOAD_SIGNING_SECRET = SECRET; +}); + +afterAll(() => { + delete process.env.DOWNLOAD_SIGNING_SECRET; +}); + +describe("signDownload", () => { + it("returns a two-part dot-separated token", () => { + const token = signDownload("documents/user/doc.pdf", "contract.pdf"); + const parts = token.split("."); + expect(parts).toHaveLength(2); + expect(parts[0].length).toBeGreaterThan(0); + expect(parts[1].length).toBeGreaterThan(0); + }); + + it("produces different tokens for different paths", () => { + const t1 = signDownload("documents/a/file.pdf", "a.pdf"); + const t2 = signDownload("documents/b/file.pdf", "b.pdf"); + expect(t1).not.toBe(t2); + }); + + it("uses base64url characters only (no +, /, =)", () => { + const token = signDownload("documents/user/file.pdf", "file.pdf"); + expect(token).not.toMatch(/[+/=]/); + }); +}); + +describe("verifyDownload", () => { + it("round-trips a valid token", () => { + const path = "documents/user123/doc456/source.pdf"; + const filename = "Contract Final v2.pdf"; + const token = signDownload(path, filename); + const result = verifyDownload(token); + expect(result).not.toBeNull(); + expect(result!.path).toBe(path); + expect(result!.filename).toBe(filename); + }); + + it("returns null for a tampered payload", () => { + const token = signDownload("documents/user/file.pdf", "file.pdf"); + const [, sig] = token.split("."); + const fakePayload = Buffer.from( + JSON.stringify({ p: "documents/attacker/file.pdf", f: "file.pdf" }), + ) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + expect(verifyDownload(`${fakePayload}.${sig}`)).toBeNull(); + }); + + it("returns null for a tampered signature", () => { + const token = signDownload("documents/user/file.pdf", "file.pdf"); + const [enc] = token.split("."); + const fakeSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + expect(verifyDownload(`${enc}.${fakeSig}`)).toBeNull(); + }); + + it("returns null for a token with too many parts", () => { + expect(verifyDownload("a.b.c")).toBeNull(); + }); + + it("returns null for a token with too few parts", () => { + expect(verifyDownload("onlyonepart")).toBeNull(); + }); + + it("returns null when payload JSON is missing required fields", () => { + const bad = Buffer.from(JSON.stringify({ x: 1 })) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + const sig = Buffer.alloc(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + expect(verifyDownload(`${bad}.${sig}`)).toBeNull(); + }); + + it("returns null when signed with a different secret", () => { + const token = signDownload("documents/user/file.pdf", "file.pdf"); + process.env.DOWNLOAD_SIGNING_SECRET = "different-secret-value-!!"; + const result = verifyDownload(token); + process.env.DOWNLOAD_SIGNING_SECRET = SECRET; + expect(result).toBeNull(); + }); +}); + +describe("buildDownloadUrl", () => { + it("returns a path starting with /download/", () => { + const url = buildDownloadUrl("documents/user/file.pdf", "file.pdf"); + expect(url).toMatch(/^\/download\//); + }); + + it("embeds a verifiable token in the URL", () => { + const path = "documents/user/file.pdf"; + const filename = "file.pdf"; + const url = buildDownloadUrl(path, filename); + const token = url.replace("/download/", ""); + const result = verifyDownload(token); + expect(result).not.toBeNull(); + expect(result!.path).toBe(path); + expect(result!.filename).toBe(filename); + }); +}); diff --git a/backend/src/lib/__tests__/llmModels.test.ts b/backend/src/lib/__tests__/llmModels.test.ts new file mode 100644 index 0000000..be3d7d5 --- /dev/null +++ b/backend/src/lib/__tests__/llmModels.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + CLAUDE_MAIN_MODELS, + GEMINI_MAIN_MODELS, + OPENAI_MAIN_MODELS, + CLAUDE_MID_MODELS, + GEMINI_MID_MODELS, + OPENAI_MID_MODELS, + CLAUDE_LOW_MODELS, + GEMINI_LOW_MODELS, + OPENAI_LOW_MODELS, + DEFAULT_MAIN_MODEL, + DEFAULT_TITLE_MODEL, + DEFAULT_TABULAR_MODEL, + providerForModel, + resolveModel, +} from "../llm/models"; + +// --------------------------------------------------------------------------- +// providerForModel +// --------------------------------------------------------------------------- + +describe("providerForModel", () => { + it("maps claude-* ids to the claude provider", () => { + for (const model of [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...CLAUDE_LOW_MODELS]) { + expect(providerForModel(model)).toBe("claude"); + } + }); + + it("maps gemini-* ids to the gemini provider", () => { + for (const model of [...GEMINI_MAIN_MODELS, ...GEMINI_MID_MODELS, ...GEMINI_LOW_MODELS]) { + expect(providerForModel(model)).toBe("gemini"); + } + }); + + it("maps gpt-* ids to the openai provider", () => { + for (const model of [...OPENAI_MAIN_MODELS, ...OPENAI_MID_MODELS, ...OPENAI_LOW_MODELS]) { + expect(providerForModel(model)).toBe("openai"); + } + }); + + it("throws on an unknown model id", () => { + expect(() => providerForModel("llama-3")).toThrow(/Unknown model id/); + expect(() => providerForModel("")).toThrow(/Unknown model id/); + }); + + it("infers by prefix only, without validating against the catalog", () => { + // Documents current behavior: any claude-/gemini-/gpt- prefix is + // accepted even if the id is not a canonical model. + expect(providerForModel("claude-nonexistent")).toBe("claude"); + expect(providerForModel("gpt-nonexistent")).toBe("openai"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveModel +// --------------------------------------------------------------------------- + +describe("resolveModel", () => { + it("returns a known model id unchanged", () => { + expect(resolveModel("claude-sonnet-4-6", DEFAULT_MAIN_MODEL)).toBe( + "claude-sonnet-4-6", + ); + expect(resolveModel("gpt-5.4-lite", DEFAULT_TITLE_MODEL)).toBe( + "gpt-5.4-lite", + ); + }); + + it("falls back for unknown model ids", () => { + expect(resolveModel("gpt-3.5-turbo", DEFAULT_MAIN_MODEL)).toBe( + DEFAULT_MAIN_MODEL, + ); + }); + + it("falls back for null, undefined, and empty ids", () => { + expect(resolveModel(null, DEFAULT_MAIN_MODEL)).toBe(DEFAULT_MAIN_MODEL); + expect(resolveModel(undefined, DEFAULT_TABULAR_MODEL)).toBe( + DEFAULT_TABULAR_MODEL, + ); + expect(resolveModel("", DEFAULT_TITLE_MODEL)).toBe(DEFAULT_TITLE_MODEL); + }); + + it("accepts models from every tier of the catalog", () => { + const catalog = [ + ...CLAUDE_MAIN_MODELS, + ...GEMINI_MAIN_MODELS, + ...OPENAI_MAIN_MODELS, + ...CLAUDE_MID_MODELS, + ...GEMINI_MID_MODELS, + ...OPENAI_MID_MODELS, + ...CLAUDE_LOW_MODELS, + ...GEMINI_LOW_MODELS, + ...OPENAI_LOW_MODELS, + ]; + for (const model of catalog) { + expect(resolveModel(model, "fallback-model")).toBe(model); + } + }); +}); + +// --------------------------------------------------------------------------- +// Default model sanity +// --------------------------------------------------------------------------- + +describe("default models", () => { + it("every default resolves to itself (defaults are in the catalog)", () => { + expect(resolveModel(DEFAULT_MAIN_MODEL, "x")).toBe(DEFAULT_MAIN_MODEL); + expect(resolveModel(DEFAULT_TITLE_MODEL, "x")).toBe(DEFAULT_TITLE_MODEL); + expect(resolveModel(DEFAULT_TABULAR_MODEL, "x")).toBe( + DEFAULT_TABULAR_MODEL, + ); + }); + + it("every default has a resolvable provider", () => { + expect(providerForModel(DEFAULT_MAIN_MODEL)).toBe("gemini"); + expect(providerForModel(DEFAULT_TITLE_MODEL)).toBe("gemini"); + expect(providerForModel(DEFAULT_TABULAR_MODEL)).toBe("gemini"); + }); +}); diff --git a/backend/src/lib/__tests__/safeError.test.ts b/backend/src/lib/__tests__/safeError.test.ts new file mode 100644 index 0000000..4bd8373 --- /dev/null +++ b/backend/src/lib/__tests__/safeError.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { + redactSensitiveText, + safeErrorMessage, + safeErrorLog, +} from "../safeError"; + +// --------------------------------------------------------------------------- +// redactSensitiveText +// --------------------------------------------------------------------------- + +describe("redactSensitiveText", () => { + it('redacts the OpenAI "Incorrect API key provided" message', () => { + expect( + redactSensitiveText( + "Incorrect API key provided: sk-proj-abc123def456ghi789.", + ), + ).toBe("Incorrect API key provided: [redacted]."); + }); + + it("keeps the trailing period optional in the incorrect-key message", () => { + expect( + redactSensitiveText("Incorrect API key provided: badkey123"), + ).toBe("Incorrect API key provided: [redacted]"); + }); + + it("redacts secrets after api_key labels", () => { + expect(redactSensitiveText("api_key: mysecret123")).toBe( + "api_key: [redacted]", + ); + expect(redactSensitiveText("api key = mysecret123")).toBe( + "api key = [redacted]", + ); + }); + + it("redacts secrets after token/secret/authorization labels", () => { + expect(redactSensitiveText("token: abcdef123456")).toBe( + "token: [redacted]", + ); + expect(redactSensitiveText("secret is abcdef123456")).toBe( + "secret is [redacted]", + ); + expect(redactSensitiveText("authorization: abcdef123456")).toBe( + "authorization: [redacted]", + ); + }); + + it("leaves short values after labels alone (below 6 chars)", () => { + expect(redactSensitiveText("token: abc")).toBe("token: abc"); + }); + + it("redacts bare OpenAI-style sk- keys anywhere in the text", () => { + expect( + redactSensitiveText("request failed for sk-abc123def456ghi789 today"), + ).toBe("request failed for [redacted] today"); + }); + + it("redacts bare Anthropic-style sk-ant- keys", () => { + expect( + redactSensitiveText("used sk-ant-api03-abc123def456"), + ).toBe("used [redacted]"); + }); + + it("redacts bare Google AIza keys", () => { + expect( + redactSensitiveText("key AIzaSyA1234567890abcdefghij failed"), + ).toBe("key [redacted] failed"); + }); + + it("redacts multiple secrets in one string", () => { + const result = redactSensitiveText( + "first sk-abc123def456ghi789 then AIzaSyA1234567890abcdefghij", + ); + expect(result).toBe("first [redacted] then [redacted]"); + }); + + it("leaves ordinary text unchanged", () => { + expect(redactSensitiveText("Document not found")).toBe( + "Document not found", + ); + }); +}); + +// --------------------------------------------------------------------------- +// safeErrorMessage +// --------------------------------------------------------------------------- + +describe("safeErrorMessage", () => { + it("uses the message of an Error instance", () => { + expect(safeErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("redacts secrets inside an Error message", () => { + expect( + safeErrorMessage(new Error("bad key sk-abc123def456ghi789")), + ).toBe("bad key [redacted]"); + }); + + it("passes plain strings through (redacted)", () => { + expect(safeErrorMessage("token: abcdef123456")).toBe( + "token: [redacted]", + ); + }); + + it("falls back for non-Error, non-string values", () => { + expect(safeErrorMessage(42)).toBe("Unexpected error"); + expect(safeErrorMessage(null)).toBe("Unexpected error"); + expect(safeErrorMessage({ message: "obj" })).toBe("Unexpected error"); + }); + + it("falls back for an Error with an empty message", () => { + expect(safeErrorMessage(new Error(""))).toBe("Unexpected error"); + }); + + it("honors a custom fallback", () => { + expect(safeErrorMessage(undefined, "Chat failed")).toBe("Chat failed"); + }); +}); + +// --------------------------------------------------------------------------- +// safeErrorLog +// --------------------------------------------------------------------------- + +describe("safeErrorLog", () => { + it("captures name, message, and stack for an Error", () => { + const error = new Error("boom"); + const log = safeErrorLog(error); + expect(log.name).toBe("Error"); + expect(log.message).toBe("boom"); + expect(log.stack).toContain("boom"); + }); + + it("redacts secrets in the message and stack", () => { + const error = new Error("bad key sk-abc123def456ghi789"); + const log = safeErrorLog(error); + expect(log.message).toBe("bad key [redacted]"); + expect(log.stack).not.toContain("sk-abc123def456ghi789"); + }); + + it("falls back to 'Unexpected error' for an empty Error message", () => { + expect(safeErrorLog(new Error("")).message).toBe("Unexpected error"); + }); + + it("omits the stack when the Error has none", () => { + const error = new Error("boom"); + error.stack = undefined; + expect(safeErrorLog(error).stack).toBeUndefined(); + }); + + it("handles non-Error values with a null name and no stack", () => { + const log = safeErrorLog("plain failure"); + expect(log).toEqual({ name: null, message: "plain failure" }); + }); +}); diff --git a/backend/src/lib/__tests__/storage.test.ts b/backend/src/lib/__tests__/storage.test.ts new file mode 100644 index 0000000..7a70eab --- /dev/null +++ b/backend/src/lib/__tests__/storage.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; + +import { + normalizeDownloadFilename, + sanitizeDispositionFilename, + encodeRFC5987, + buildContentDisposition, + storageKey, + pdfStorageKey, + generatedDocKey, + versionStorageKey, +} from "../storage"; + +describe("normalizeDownloadFilename", () => { + it("trims surrounding whitespace", () => { + expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf"); + }); + + it("falls back to 'download' for empty string", () => { + expect(normalizeDownloadFilename("")).toBe("download"); + expect(normalizeDownloadFilename(" ")).toBe("download"); + }); + + it("replaces control characters with underscore", () => { + expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf"); + expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf"); + }); + + it("replaces forward and backward slashes with underscore", () => { + expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf"); + expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf"); + }); + + it("preserves normal filenames unchanged", () => { + expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe( + "Contract v2 (Final).pdf", + ); + }); +}); + +describe("sanitizeDispositionFilename", () => { + it("strips double-quote characters", () => { + expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf"); + }); + + it("strips backslash characters", () => { + expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf"); + }); + + it("strips non-ASCII characters", () => { + expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf"); + }); + + it("still applies normalizeDownloadFilename rules first", () => { + expect(sanitizeDispositionFilename(" ")).toBe("download"); + }); +}); + +describe("encodeRFC5987", () => { + it("encodes spaces as %20", () => { + expect(encodeRFC5987("hello world")).toBe("hello%20world"); + }); + + it("encodes single-quote as %27", () => { + expect(encodeRFC5987("it's")).toContain("%27"); + }); + + it("encodes ( and ) as %28 and %29", () => { + const result = encodeRFC5987("a(b)c"); + expect(result).toContain("%28"); + expect(result).toContain("%29"); + }); + + it("encodes * as %2A", () => { + expect(encodeRFC5987("a*b")).toContain("%2A"); + }); + + it("leaves safe ASCII characters unencoded", () => { + expect(encodeRFC5987("file.pdf")).toBe("file.pdf"); + }); +}); + +describe("buildContentDisposition", () => { + it("produces an attachment header with ASCII filename", () => { + const header = buildContentDisposition("attachment", "contract.pdf"); + expect(header).toMatch(/^attachment;/); + expect(header).toContain('filename="contract.pdf"'); + expect(header).toContain("filename*=UTF-8''contract.pdf"); + }); + + it("produces an inline header", () => { + const header = buildContentDisposition("inline", "preview.pdf"); + expect(header).toMatch(/^inline;/); + }); + + it("encodes unicode filename in filename* param", () => { + const header = buildContentDisposition("attachment", "Ünïcödé.pdf"); + expect(header).toContain("filename*=UTF-8''"); + expect(header).not.toContain("Ü"); + }); +}); + +describe("storageKey", () => { + it("includes userId, docId, and correct extension", () => { + const key = storageKey("user1", "doc1", "contract.pdf"); + expect(key).toBe("documents/user1/doc1/source.pdf"); + }); + + it("falls back to .bin for extensions longer than 16 chars", () => { + const key = storageKey("user1", "doc1", "file.toolongextension1234"); + expect(key).toBe("documents/user1/doc1/source.bin"); + }); + + it("falls back to .bin when no extension", () => { + const key = storageKey("user1", "doc1", "noextension"); + expect(key).toBe("documents/user1/doc1/source.bin"); + }); +}); + +describe("pdfStorageKey", () => { + it("places PDF in the correct path with stem", () => { + const key = pdfStorageKey("user1", "doc1", "contract"); + expect(key).toBe("documents/user1/doc1/contract.pdf"); + }); +}); + +describe("generatedDocKey", () => { + it("uses generated/ prefix and .docx extension for docx files", () => { + const key = generatedDocKey("user1", "doc1", "output.docx"); + expect(key).toBe("generated/user1/doc1/generated.docx"); + }); + + it("falls back to .docx for extensions longer than 16 chars", () => { + const key = generatedDocKey("user1", "doc1", "output.toolongextension1234"); + expect(key).toBe("generated/user1/doc1/generated.docx"); + }); +}); + +describe("versionStorageKey", () => { + it("includes userId, docId, versionSlug, and extension", () => { + const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf"); + expect(key).toBe("documents/user1/doc1/versions/v2.pdf"); + }); + + it("falls back to .bin for unknown extensions", () => { + const key = versionStorageKey("user1", "doc1", "v2", "file"); + expect(key).toBe("documents/user1/doc1/versions/v2.bin"); + }); +}); diff --git a/backend/src/lib/__tests__/userApiKeys.test.ts b/backend/src/lib/__tests__/userApiKeys.test.ts new file mode 100644 index 0000000..c7dbe72 --- /dev/null +++ b/backend/src/lib/__tests__/userApiKeys.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { normalizeApiKeyProvider, hasEnvApiKey } from "../userApiKeys"; + +describe("normalizeApiKeyProvider", () => { + it('returns "claude" for "claude"', () => { + expect(normalizeApiKeyProvider("claude")).toBe("claude"); + }); + + it('returns "openai" for "openai"', () => { + expect(normalizeApiKeyProvider("openai")).toBe("openai"); + }); + + it('returns "gemini" for "gemini"', () => { + expect(normalizeApiKeyProvider("gemini")).toBe("gemini"); + }); + + it("returns null for unknown provider strings", () => { + expect(normalizeApiKeyProvider("unknown")).toBeNull(); + expect(normalizeApiKeyProvider("")).toBeNull(); + expect(normalizeApiKeyProvider("Claude")).toBeNull(); + expect(normalizeApiKeyProvider("OPENAI")).toBeNull(); + }); +}); + +describe("hasEnvApiKey", () => { + const envVars = [ + "ANTHROPIC_API_KEY", + "CLAUDE_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + ]; + + // Clear before AND after each test so keys exported in the developer's + // shell (or CI) can't leak into assertions. + beforeEach(() => { + for (const v of envVars) delete process.env[v]; + }); + + afterEach(() => { + for (const v of envVars) delete process.env[v]; + }); + + it("returns true for claude when ANTHROPIC_API_KEY is set", () => { + process.env.ANTHROPIC_API_KEY = "sk-ant-test"; + expect(hasEnvApiKey("claude")).toBe(true); + }); + + it("returns true for claude when CLAUDE_API_KEY is set as fallback", () => { + process.env.CLAUDE_API_KEY = "sk-claude-test"; + expect(hasEnvApiKey("claude")).toBe(true); + }); + + it("returns true for openai when OPENAI_API_KEY is set", () => { + process.env.OPENAI_API_KEY = "sk-openai-test"; + expect(hasEnvApiKey("openai")).toBe(true); + }); + + it("returns true for gemini when GEMINI_API_KEY is set", () => { + process.env.GEMINI_API_KEY = "gemini-key-test"; + expect(hasEnvApiKey("gemini")).toBe(true); + }); + + it("returns false when no env key is set for the provider", () => { + expect(hasEnvApiKey("claude")).toBe(false); + expect(hasEnvApiKey("openai")).toBe(false); + expect(hasEnvApiKey("gemini")).toBe(false); + }); + + it("ignores whitespace-only env values", () => { + process.env.ANTHROPIC_API_KEY = " "; + expect(hasEnvApiKey("claude")).toBe(false); + }); +}); diff --git a/backend/src/lib/__tests__/userDataCleanup.test.ts b/backend/src/lib/__tests__/userDataCleanup.test.ts new file mode 100644 index 0000000..35a8ed1 --- /dev/null +++ b/backend/src/lib/__tests__/userDataCleanup.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../storage", () => ({ + deleteFile: vi.fn(async () => {}), + listFiles: vi.fn(async () => [] as string[]), +})); + +import { deleteFile, listFiles } from "../storage"; +import { + deleteAllUserChats, + deleteAllUserTabularReviews, + deleteUserProjects, + deleteUserAccountData, +} from "../userDataCleanup"; + +const deleteFileMock = vi.mocked(deleteFile); +const listFilesMock = vi.mocked(listFiles); + +type Row = Record; + +/** + * Stateful Supabase mock: deletes and updates mutate `tables`, so tests can + * assert on exactly which rows survived a cleanup call. Supports the chains + * userDataCleanup uses (select/delete/update + eq/in/filter-cs) and can + * inject a delete error per table to exercise error propagation. + */ +function makeDb( + initialTables: Record, + options: { deleteErrors?: Record } = {}, +) { + const tables: Record = {}; + for (const [name, rows] of Object.entries(initialTables)) { + tables[name] = rows.map((row) => ({ ...row })); + } + const db = { + from(table: string) { + const rowsOf = () => tables[table] ?? (tables[table] = []); + let predicate: (row: Row) => boolean = () => true; + let mode: "select" | "delete" | "update" = "select"; + let patch: Row = {}; + const narrow = (next: (row: Row) => boolean) => { + const prev = predicate; + predicate = (row) => prev(row) && next(row); + }; + const query: any = { + select: () => query, + delete: () => { + mode = "delete"; + return query; + }, + update: (value: Row) => { + mode = "update"; + patch = value; + return query; + }, + eq: (column: string, value: unknown) => { + narrow((row) => row[column] === value); + return query; + }, + in: (column: string, values: unknown[]) => { + narrow((row) => values.includes(row[column])); + return query; + }, + filter: (column: string, operator: string, value: string) => { + if (operator !== "cs") return query; + const expected = (JSON.parse(value) as string[]).map((item) => + item.toLowerCase(), + ); + narrow((row) => { + const actual = row[column]; + if (!Array.isArray(actual)) return false; + const normalized = actual.map((item) => + String(item).toLowerCase(), + ); + return expected.every((item) => normalized.includes(item)); + }); + return query; + }, + then: ( + resolve: (value: { data: Row[] | null; error: unknown }) => unknown, + reject?: (reason: unknown) => unknown, + ) => { + let result: { data: Row[] | null; error: unknown }; + if (mode === "delete") { + const message = options.deleteErrors?.[table]; + if (message) { + result = { data: null, error: { message } }; + } else { + tables[table] = rowsOf().filter((row) => !predicate(row)); + result = { data: null, error: null }; + } + } else if (mode === "update") { + for (const row of rowsOf().filter(predicate)) { + Object.assign(row, patch); + } + result = { data: null, error: null }; + } else { + result = { + data: rowsOf().filter(predicate).map((row) => ({ ...row })), + error: null, + }; + } + return Promise.resolve(result).then(resolve, reject); + }, + }; + return query; + }, + }; + return { db: db as any, tables }; +} + +const ids = (rows: Row[] | undefined) => (rows ?? []).map((row) => row.id); + +beforeEach(() => { + deleteFileMock.mockClear(); + deleteFileMock.mockResolvedValue(undefined as never); + listFilesMock.mockClear(); + listFilesMock.mockResolvedValue([]); +}); + +// --------------------------------------------------------------------------- +// deleteAllUserChats +// --------------------------------------------------------------------------- + +describe("deleteAllUserChats", () => { + it("deletes only the target user's assistant and tabular chats", async () => { + const { db, tables } = makeDb({ + chats: [ + { id: "c1", user_id: "u1" }, + { id: "c2", user_id: "u2" }, + ], + tabular_review_chats: [ + { id: "tc1", user_id: "u1" }, + { id: "tc2", user_id: "u2" }, + ], + }); + await deleteAllUserChats(db, "u1"); + expect(ids(tables.chats)).toEqual(["c2"]); + expect(ids(tables.tabular_review_chats)).toEqual(["tc2"]); + }); + + it("surfaces delete failures with context", async () => { + const { db } = makeDb( + { chats: [{ id: "c1", user_id: "u1" }], tabular_review_chats: [] }, + { deleteErrors: { chats: "boom" } }, + ); + await expect(deleteAllUserChats(db, "u1")).rejects.toThrow( + "Failed to delete assistant chats: boom", + ); + }); +}); + +// --------------------------------------------------------------------------- +// deleteAllUserTabularReviews +// --------------------------------------------------------------------------- + +describe("deleteAllUserTabularReviews", () => { + const fixture = () => + makeDb({ + tabular_reviews: [ + { id: "r1", user_id: "u1" }, + { id: "r2", user_id: "u1" }, + { id: "r-other", user_id: "u2" }, + ], + tabular_review_chats: [ + { id: "rc1", review_id: "r1" }, + { id: "rc-other", review_id: "r-other" }, + ], + tabular_review_chat_messages: [ + { id: "rm1", chat_id: "rc1" }, + { id: "rm-other", chat_id: "rc-other" }, + ], + tabular_cells: [ + { id: "cell1", review_id: "r1" }, + { id: "cell-other", review_id: "r-other" }, + ], + }); + + it("cascades messages, chats, and cells before the reviews", async () => { + const { db, tables } = fixture(); + await expect(deleteAllUserTabularReviews(db, "u1")).resolves.toBe(2); + expect(ids(tables.tabular_reviews)).toEqual(["r-other"]); + expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]); + expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]); + expect(ids(tables.tabular_cells)).toEqual(["cell-other"]); + }); + + it("returns 0 and deletes nothing for a user with no reviews", async () => { + const { db, tables } = fixture(); + await expect(deleteAllUserTabularReviews(db, "u3")).resolves.toBe(0); + expect(tables.tabular_reviews).toHaveLength(3); + expect(tables.tabular_cells).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// deleteUserProjects +// --------------------------------------------------------------------------- + +describe("deleteUserProjects", () => { + const fixture = () => + makeDb({ + projects: [ + { id: "p1", user_id: "u1" }, + { id: "p2", user_id: "u1" }, + { id: "p-other", user_id: "u2" }, + ], + documents: [ + { id: "d1", user_id: "u1", project_id: "p1" }, + { id: "d-loose", user_id: "u1", project_id: null }, + { id: "d-other", user_id: "u2", project_id: "p-other" }, + ], + document_versions: [ + { + id: "v1", + document_id: "d1", + storage_path: "documents/u1/d1/source.pdf", + pdf_storage_path: "documents/u1/d1/converted.pdf", + }, + { + id: "v-other", + document_id: "d-other", + storage_path: "documents/u2/d-other/source.pdf", + pdf_storage_path: null, + }, + ], + chats: [ + { id: "c1", project_id: "p1" }, + { id: "c-other", project_id: "p-other" }, + ], + chat_messages: [ + { id: "m1", chat_id: "c1" }, + { id: "m-other", chat_id: "c-other" }, + ], + tabular_reviews: [ + { id: "r1", project_id: "p1" }, + { id: "r-other", project_id: "p-other" }, + ], + tabular_review_chats: [ + { id: "rc1", review_id: "r1" }, + { id: "rc-other", review_id: "r-other" }, + ], + tabular_review_chat_messages: [ + { id: "rm1", chat_id: "rc1" }, + { id: "rm-other", chat_id: "rc-other" }, + ], + tabular_cells: [ + { id: "cell1", review_id: "r1" }, + { id: "cell-other", review_id: "r-other" }, + ], + project_subfolders: [ + { id: "f1", project_id: "p1" }, + { id: "f-other", project_id: "p-other" }, + ], + }); + + it("cascades project contents and storage files for owned projects", async () => { + const { db, tables } = fixture(); + await expect(deleteUserProjects(db, "u1")).resolves.toBe(2); + + expect(ids(tables.projects)).toEqual(["p-other"]); + expect(ids(tables.documents)).toEqual(["d-loose", "d-other"]); + expect(ids(tables.chats)).toEqual(["c-other"]); + expect(ids(tables.chat_messages)).toEqual(["m-other"]); + expect(ids(tables.tabular_reviews)).toEqual(["r-other"]); + expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]); + expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]); + expect(ids(tables.tabular_cells)).toEqual(["cell-other"]); + expect(ids(tables.project_subfolders)).toEqual(["f-other"]); + + const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path); + expect(deletedPaths.sort()).toEqual([ + "documents/u1/d1/converted.pdf", + "documents/u1/d1/source.pdf", + ]); + }); + + it("restricts deletion to the requested owned projects", async () => { + const { db, tables } = fixture(); + // p-other belongs to u2, so requesting it must not delete anything of theirs. + await expect( + deleteUserProjects(db, "u1", ["p2", "p-other", "p2"]), + ).resolves.toBe(1); + expect(ids(tables.projects)).toEqual(["p1", "p-other"]); + expect(ids(tables.documents)).toEqual(["d1", "d-loose", "d-other"]); + }); + + it("returns 0 for an explicitly empty project list", async () => { + const { db, tables } = fixture(); + await expect(deleteUserProjects(db, "u1", [])).resolves.toBe(0); + expect(tables.projects).toHaveLength(3); + }); + + it("returns 0 when the user owns no projects", async () => { + const { db, tables } = fixture(); + await expect(deleteUserProjects(db, "u3")).resolves.toBe(0); + expect(tables.projects).toHaveLength(3); + expect(deleteFileMock).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// deleteUserAccountData +// --------------------------------------------------------------------------- + +describe("deleteUserAccountData", () => { + const fixture = () => + makeDb({ + projects: [ + { id: "p1", user_id: "u1", shared_with: [] }, + { + id: "p-other", + user_id: "u2", + shared_with: ["u1@example.com", " U1@Example.com ", "keep@example.com"], + }, + ], + tabular_reviews: [ + { id: "r1", user_id: "u1", shared_with: [] }, + { id: "r-other", user_id: "u2", shared_with: ["u1@example.com"] }, + ], + documents: [ + { id: "d1", user_id: "u1", project_id: null }, + // Guest doc uploaded by another user into u1's project: deleted too. + { id: "d-guest", user_id: "u2", project_id: "p1" }, + { id: "d-other", user_id: "u2", project_id: "p-other" }, + ], + document_versions: [ + { + id: "v1", + document_id: "d1", + storage_path: "documents/u1/d1/source.pdf", + pdf_storage_path: "documents/u1/d1/converted.pdf", + }, + { + id: "v-guest", + document_id: "d-guest", + storage_path: "documents/u2/d-guest/source.docx", + pdf_storage_path: null, + }, + { + id: "v-other", + document_id: "d-other", + storage_path: "documents/u2/d-other/source.pdf", + pdf_storage_path: null, + }, + ], + chats: [ + { id: "c1", user_id: "u1" }, + { id: "c-other", user_id: "u2" }, + ], + tabular_review_chats: [{ id: "rc1", user_id: "u1" }], + project_subfolders: [{ id: "f1", user_id: "u1" }], + hidden_workflows: [{ id: "h1", user_id: "u1" }], + workflow_open_source_submissions: [ + { id: "s1", submitted_by_user_id: "u1" }, + ], + workflow_shares: [ + { id: "ws-by", shared_by_user_id: "u1", shared_with_email: "x@y.z" }, + { + id: "ws-to", + shared_by_user_id: "u2", + shared_with_email: "u1@example.com", + }, + { + id: "ws-keep", + shared_by_user_id: "u2", + shared_with_email: "keep@example.com", + }, + ], + workflows: [ + { id: "w1", user_id: "u1" }, + { id: "w-other", user_id: "u2" }, + ], + }); + + it("removes the user's rows, files, and share references everywhere", async () => { + const { db, tables } = fixture(); + listFilesMock.mockResolvedValue(["documents/u1/orphan.bin"]); + + await deleteUserAccountData(db, "u1", " U1@Example.COM "); + + // Owned docs and guest docs inside owned projects are gone. + expect(ids(tables.documents)).toEqual(["d-other"]); + expect(ids(tables.projects)).toEqual(["p-other"]); + expect(ids(tables.chats)).toEqual(["c-other"]); + expect(tables.tabular_review_chats).toEqual([]); + expect(ids(tables.tabular_reviews)).toEqual(["r-other"]); + expect(tables.project_subfolders).toEqual([]); + expect(tables.hidden_workflows).toEqual([]); + expect(tables.workflow_open_source_submissions).toEqual([]); + expect(ids(tables.workflows)).toEqual(["w-other"]); + + // Shares by the user and shares to the user's email are both removed. + expect(ids(tables.workflow_shares)).toEqual(["ws-keep"]); + + // The email is scrubbed from other users' shared_with lists + // (case-insensitively), preserving other collaborators. + expect(tables.projects[0].shared_with).toEqual(["keep@example.com"]); + expect(tables.tabular_reviews[0].shared_with).toEqual([]); + + // Version files for deleted docs plus orphans under the user's prefix. + const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path); + expect(deletedPaths.sort()).toEqual([ + "documents/u1/d1/converted.pdf", + "documents/u1/d1/source.pdf", + "documents/u1/orphan.bin", + "documents/u2/d-guest/source.docx", + ]); + expect(listFilesMock).toHaveBeenCalledWith("documents/u1/"); + }); + + it("treats storage prefix cleanup as best-effort", async () => { + const { db, tables } = fixture(); + listFilesMock.mockRejectedValue(new Error("storage unavailable")); + await expect( + deleteUserAccountData(db, "u1", "u1@example.com"), + ).resolves.toBeUndefined(); + expect(ids(tables.documents)).toEqual(["d-other"]); + }); + + it("skips shared_with scrubbing when no email is known", async () => { + const { db, tables } = fixture(); + await deleteUserAccountData(db, "u1", null); + // Rows referencing the email by value are left in place... + expect(tables.projects.find((row) => row.id === "p-other")?.shared_with) + .toContain("u1@example.com"); + expect(ids(tables.workflow_shares)).toEqual(["ws-to", "ws-keep"]); + // ...but the user's own data is still deleted. + expect(ids(tables.documents)).toEqual(["d-other"]); + expect(tables.workflows.map((row) => row.id)).toEqual(["w-other"]); + }); +}); diff --git a/backend/src/lib/__tests__/userLookup.test.ts b/backend/src/lib/__tests__/userLookup.test.ts new file mode 100644 index 0000000..c954bf4 --- /dev/null +++ b/backend/src/lib/__tests__/userLookup.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeEmail, + normalizeDisplayName, + loadProfileUsersByEmail, + findProfileUserByEmail, + findMissingUserEmails, + syncProfileEmail, +} from "../userLookup"; + +type Row = Record; + +/** + * Minimal user_profiles-shaped Supabase mock. Supports the query chains + * userLookup uses (select/eq/in/not + single-row readers) plus insert and + * update so syncProfileEmail can be exercised end to end. + */ +function makeDb(initialRows: Row[]) { + const tables: Record = { + user_profiles: initialRows.map((row) => ({ ...row })), + }; + return { + tables, + from(table: string) { + const all = () => tables[table] ?? []; + let predicate: (row: Row) => boolean = () => true; + let mode: "select" | "insert" | "update" = "select"; + let pendingRow: Row = {}; + const narrow = (next: (row: Row) => boolean) => { + const prev = predicate; + predicate = (row) => prev(row) && next(row); + }; + const query: any = { + select: () => query, + insert: (row: Row) => { + mode = "insert"; + pendingRow = row; + return query; + }, + update: (patch: Row) => { + mode = "update"; + pendingRow = patch; + return query; + }, + eq: (column: string, value: unknown) => { + narrow((row) => row[column] === value); + return query; + }, + in: (column: string, values: unknown[]) => { + narrow((row) => values.includes(row[column])); + return query; + }, + not: (column: string, operator: string, value: unknown) => { + if (operator === "is" && value === null) { + narrow((row) => row[column] != null); + } + return query; + }, + maybeSingle: async () => ({ + data: all().filter(predicate)[0] ?? null, + error: null, + }), + then: ( + resolve: (value: { data: Row[] | null; error: null }) => unknown, + reject?: (reason: unknown) => unknown, + ) => { + if (mode === "insert") { + all().push({ ...pendingRow }); + return Promise.resolve({ data: null, error: null }).then( + resolve, + reject, + ); + } + if (mode === "update") { + for (const row of all().filter(predicate)) { + Object.assign(row, pendingRow); + } + return Promise.resolve({ data: null, error: null }).then( + resolve, + reject, + ); + } + return Promise.resolve({ + data: all().filter(predicate), + error: null, + }).then(resolve, reject); + }, + }; + return query; + }, + }; +} + +// --------------------------------------------------------------------------- +// normalizeEmail / normalizeDisplayName +// --------------------------------------------------------------------------- + +describe("normalizeEmail", () => { + it("trims and lowercases", () => { + expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com"); + }); + + it("returns empty string for non-strings", () => { + expect(normalizeEmail(null)).toBe(""); + expect(normalizeEmail(undefined)).toBe(""); + expect(normalizeEmail(42)).toBe(""); + }); +}); + +describe("normalizeDisplayName", () => { + it("trims usable names", () => { + expect(normalizeDisplayName(" Ada Lovelace ")).toBe("Ada Lovelace"); + }); + + it("returns null for empty or non-string values", () => { + expect(normalizeDisplayName(" ")).toBeNull(); + expect(normalizeDisplayName("")).toBeNull(); + expect(normalizeDisplayName(null)).toBeNull(); + expect(normalizeDisplayName(7)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// loadProfileUsersByEmail +// --------------------------------------------------------------------------- + +describe("loadProfileUsersByEmail", () => { + it("indexes profiles by normalized email and by id", async () => { + const db = makeDb([ + { user_id: "u1", email: "Alice@Example.com", display_name: " Alice " }, + { user_id: "u2", email: "bob@example.com", display_name: null }, + ]); + const { userByEmail, userById } = await loadProfileUsersByEmail( + db as any, + ); + expect(userByEmail.get("alice@example.com")).toEqual({ + id: "u1", + email: "alice@example.com", + display_name: "Alice", + }); + expect(userById.get("u2")).toEqual({ + id: "u2", + email: "bob@example.com", + display_name: null, + }); + expect(userByEmail.size).toBe(2); + }); + + it("skips rows whose email normalizes to empty", async () => { + const db = makeDb([ + { user_id: "u1", email: " ", display_name: null }, + { user_id: "u2", email: "ok@example.com", display_name: null }, + ]); + const { userByEmail } = await loadProfileUsersByEmail(db as any); + expect(userByEmail.size).toBe(1); + expect(userByEmail.has("ok@example.com")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// findProfileUserByEmail +// --------------------------------------------------------------------------- + +describe("findProfileUserByEmail", () => { + const rows = [ + { user_id: "u1", email: "alice@example.com", display_name: "Alice" }, + ]; + + it("finds a profile by normalized email", async () => { + const db = makeDb(rows); + await expect( + findProfileUserByEmail(db as any, " ALICE@example.com "), + ).resolves.toEqual({ + id: "u1", + email: "alice@example.com", + display_name: "Alice", + }); + }); + + it("returns null when no profile matches", async () => { + const db = makeDb(rows); + await expect( + findProfileUserByEmail(db as any, "missing@example.com"), + ).resolves.toBeNull(); + }); + + it("returns null without querying for empty input", async () => { + const db = makeDb(rows); + await expect(findProfileUserByEmail(db as any, " ")).resolves.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// findMissingUserEmails +// --------------------------------------------------------------------------- + +describe("findMissingUserEmails", () => { + const db = makeDb([ + { user_id: "u1", email: "alice@example.com", display_name: null }, + ]); + + it("returns only emails with no matching profile", async () => { + await expect( + findMissingUserEmails(db as any, [ + "Alice@Example.com", + "carol@example.com", + ]), + ).resolves.toEqual(["carol@example.com"]); + }); + + it("dedupes and drops empty entries before querying", async () => { + await expect( + findMissingUserEmails(db as any, [ + "carol@example.com", + " CAROL@example.com ", + "", + " ", + ]), + ).resolves.toEqual(["carol@example.com"]); + }); + + it("returns [] for an empty input list", async () => { + await expect(findMissingUserEmails(db as any, [])).resolves.toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// syncProfileEmail +// --------------------------------------------------------------------------- + +describe("syncProfileEmail", () => { + it("inserts a profile row when none exists", async () => { + const db = makeDb([]); + const result = await syncProfileEmail(db as any, "u1", "New@Example.com"); + expect(result).toBeNull(); + expect(db.tables.user_profiles).toEqual([ + { user_id: "u1", email: "new@example.com" }, + ]); + }); + + it("is a no-op when the stored email already matches (case-insensitive)", async () => { + const db = makeDb([ + { user_id: "u1", email: "Same@Example.com", display_name: null }, + ]); + const result = await syncProfileEmail(db as any, "u1", "same@example.com"); + expect(result).toBeNull(); + expect(db.tables.user_profiles[0].email).toBe("Same@Example.com"); + }); + + it("updates the stored email when it changed", async () => { + const db = makeDb([ + { user_id: "u1", email: "old@example.com", display_name: null }, + ]); + const result = await syncProfileEmail(db as any, "u1", "New@Example.com"); + expect(result).toBeNull(); + expect(db.tables.user_profiles[0].email).toBe("new@example.com"); + expect(db.tables.user_profiles[0].updated_at).toEqual(expect.any(String)); + }); + + it("returns null without touching the table for missing inputs", async () => { + const db = makeDb([]); + await expect(syncProfileEmail(db as any, "", "a@b.com")).resolves.toBeNull(); + await expect(syncProfileEmail(db as any, "u1", null)).resolves.toBeNull(); + await expect(syncProfileEmail(db as any, "u1", " ")).resolves.toBeNull(); + expect(db.tables.user_profiles).toEqual([]); + }); +}); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index cdf03b4..bea8eca 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -152,9 +152,16 @@ async function attachChatCreatorLabels( } // GET /projects +// Pass ?include=documents to also receive each project's documents in the +// same response. The directory pickers (useDirectoryData) previously fanned +// out one GET /projects/:id per project to obtain those documents; with N +// projects that burst — auth check plus several DB queries per request — +// could overwhelm the Supabase gateway. Batching keeps it at one request +// and a fixed number of queries regardless of project count. projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const userEmail = res.locals.userEmail as string | undefined; + const includeDocuments = req.query.include === "documents"; const db = createServerSupabase(); const { data, error } = await db.rpc("get_projects_overview", { @@ -163,7 +170,45 @@ projectsRouter.get("/", requireAuth, async (req, res) => { }); if (error) return void res.status(500).json({ detail: error.message }); - res.json(data ?? []); + const projects = (data ?? []) as { id: string }[]; + if (!includeDocuments || projects.length === 0) { + return void res.json(projects); + } + + const { data: docs, error: docsError } = await db + .from("documents") + .select("*") + .in( + "project_id", + projects.map((p) => p.id), + ) + .order("created_at", { ascending: true }); + if (docsError) + return void res.status(500).json({ detail: docsError.message }); + + const docsTyped = (docs ?? []) as unknown as { + id: string; + project_id?: string | null; + user_id?: string | null; + current_version_id?: string | null; + }[]; + await attachLatestVersionNumbers(db, docsTyped); + await attachActiveVersionPaths(db, docsTyped); + await attachDocumentOwnerLabels(db, docsTyped); + + const docsByProject = new Map(); + for (const doc of docsTyped) { + if (!doc.project_id) continue; + const bucket = docsByProject.get(doc.project_id); + if (bucket) bucket.push(doc); + else docsByProject.set(doc.project_id, [doc]); + } + res.json( + projects.map((p) => ({ + ...p, + documents: docsByProject.get(p.id) ?? [], + })), + ); }); // POST /projects diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a4b3abf..bc27281 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] } diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts new file mode 100644 index 0000000..b945289 --- /dev/null +++ b/backend/vitest.config.mts @@ -0,0 +1,38 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["dist/**", "node_modules/**"], + // Generous timeouts so cold-start module transform/import latency + // can't cause spurious timeout failures on a cold CI runner. Warm + // tests finish in ~1s; this only guards the pathological cold case — + // it does not mask hangs. + testTimeout: 20000, + hookTimeout: 20000, + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/lib/**"], + // No-regression RATCHET floor, not a target. src/lib/** spans the + // tested libs (access, storage keys/dispositions, downloadTokens, + // userApiKeys provider/env checks, chat doc resolution, safeError, + // llm model resolution, chat citations, userLookup, + // documentVersions, userDataCleanup) AND the large, still-untested + // feature libs (courtlistener, mcp, chat tool dispatch, llm + // providers, spreadsheet/docx handling), so the global number is + // still low. Measured on this tree: 11.18% statements, 10.98% + // branches, 14.43% functions, 10.91% lines. These floors sit just + // below that (rounded down to whole percents) so CI fails on a + // *drop*. Floors only go up: when you add tests, raise them in the + // same PR. Backlog + per-area status: docs/testing-coverage.md. + thresholds: { + statements: 11, + branches: 10, + functions: 14, + lines: 10, + }, + }, + }, +}); diff --git a/docs/testing-coverage.md b/docs/testing-coverage.md new file mode 100644 index 0000000..c62789c --- /dev/null +++ b/docs/testing-coverage.md @@ -0,0 +1,122 @@ +# Backend unit-test coverage + +The backend has a Vitest unit-test harness over `backend/src/lib/**`. This doc +tracks what is covered, what still needs tests, and how the coverage ratchet +works — so you can pick up a checkbox below and land it as a small PR. + +## Running the tests + +```bash +cd backend +npm install +npm test # run all unit tests +npm run test:coverage # same, plus the per-file coverage table + floor check +``` + +Tests live in `backend/src/lib/__tests__/*.test.ts`. Read a couple of the +existing suites first (`access.test.ts`, `userDataCleanup.test.ts`) and match +their conventions: plain in-memory Supabase query mocks (no network, no real +database), one `describe` block per exported function, and tests that assert +current behavior. + +## Current coverage (measured 2026-07) + +Per-area statement coverage from `npm run test:coverage`: + +| Lib area | % statements | Tested? | +| --- | ---: | :---: | +| `lib/safeError.ts` | 100 | ✓ | +| `lib/userDataCleanup.ts` | 100 | ✓ | +| `lib/llm/models.ts` | 100 | ✓ | +| `lib/documentVersions.ts` | 98 | ✓ | +| `lib/chat/citations.ts` | 98 | ✓ | +| `lib/userLookup.ts` | 91 | ✓ | +| `lib/downloadTokens.ts` | 87 | ✓ | +| `lib/chat/types.ts` | 85 | ✓ | +| `lib/access.ts` | 76 | ✓ | +| `lib/storage.ts` | 33 | partial — key/disposition helpers only | +| `lib/userApiKeys.ts` | 13 | partial — provider/env helpers only | +| `lib/documentTypes.ts`, `lib/userSettings.ts`, `lib/upload.ts`, `lib/officeText.ts` | 0 | ✗ | +| `lib/convert.ts`, `lib/spreadsheet.ts`, `lib/docxTrackedChanges.ts` | 0 | ✗ | +| `lib/userDataExport.ts` | 0 | ✗ | +| `lib/courtlistener.ts`, `lib/systemWorkflows.ts` | 0 | ✗ | +| `lib/chat/prompts.ts`, `lib/chat/contextBuilders.ts`, `lib/chat/streaming.ts` | 0 | ✗ | +| `lib/chat/tools/**` (schemas, documentOps, toolDispatcher) | 0 | ✗ | +| `lib/llm/**` (providers, tools, index, rawStreamLog) | ~4 | ✗ (only models.ts) | +| `lib/mcp/**` (client, servers, oauth, types) | 0 | ✗ | + +Global: **11.18% statements / 10.98% branches / 14.43% functions / 10.91% +lines**. The global number is low because `src/lib/**` includes several very +large feature libs (toolDispatcher, documentOps, systemWorkflows, +courtlistener, docxTrackedChanges) that dominate the line count. + +## TODO — untested libs, in priority order + +Each item is meant to be one self-contained PR: add the suite, then raise the +floors in `backend/vitest.config.mts` to just below the new measured numbers. +Size is a rough guess: S ≈ an hour, M ≈ an afternoon. + +- [ ] `lib/documentTypes.ts` — pure catalog/lookup of document types; assert + known types resolve and unknown inputs fall back sanely. (S) +- [ ] `lib/chat/prompts.ts` — pure prompt builders; assert key instructions and + interpolated values appear in the output strings. (S) +- [ ] `lib/userSettings.ts` — title/tabular model resolution from which API + keys a user has; reuse the Supabase mock pattern from + `userLookup.test.ts`. (S) +- [ ] `lib/upload.ts` — multer wrapper: assert LIMIT_FILE_SIZE maps to a 413 + with the right message and other errors pass through. (S) +- [ ] `lib/officeText.ts` — office XML text extraction; build a tiny in-memory + zip fixture with JSZip and assert extracted/decoded text. (S) +- [ ] `lib/chat/tools/toolSchemas.ts` — assert every tool schema has a name, + description, and well-formed parameters (guards against schema drift). (S) +- [ ] `lib/userApiKeys.ts` (rest) — encrypt/decrypt round-trip and DB + load/store paths with a mocked Supabase client. (M) +- [ ] `lib/storage.ts` (rest) — S3 upload/download/list/delete wrappers with a + mocked AWS SDK client. (M) +- [ ] `lib/userDataExport.ts` — export assembly: given seeded mock tables, + assert the export contains the user's data and nobody else's. (M) +- [ ] `lib/spreadsheet.ts` — parse a small in-memory xlsx fixture; assert sheet + and cell extraction, including empty/edge cells. (M) +- [ ] `lib/chat/contextBuilders.ts` — context assembly from doc stores; assert + doc labels, truncation, and ordering. (M) +- [ ] `lib/docxTrackedChanges.ts` — tracked-changes XML round-trip on a minimal + docx fixture: insert/delete runs, accept/reject. High value: document + integrity. (M) +- [ ] `lib/courtlistener.ts` — API client with mocked fetch: query building, + pagination, and error paths. Legal-research correctness. (M) +- [ ] `lib/systemWorkflows.ts` — mostly data: assert workflow definitions are + well-formed (unique ids, non-empty skill markdown). (S) +- [ ] `lib/llm/tools.ts` + `lib/llm/index.ts` — provider-neutral tool plumbing + and provider selection with mocked provider modules. (M) +- [ ] `lib/mcp/types.ts` + `lib/mcp/servers.ts` — server config validation and + allow-listing logic; security relevant. (M) +- [ ] `lib/mcp/client.ts` + `lib/mcp/oauth.ts` — connection lifecycle and OAuth + token handling with a mocked MCP SDK; security relevant. (M) +- [ ] `lib/llm/rawStreamLog.ts` — log path construction and redaction with a + mocked fs. (S) +- [ ] `lib/chat/tools/documentOps.ts` — start with the pure helpers (diff/match + utilities), not the full tool handlers. (M) +- [ ] `lib/chat/tools/toolDispatcher.ts` — dispatch table routing and argument + validation with stubbed tools; don't try to cover every tool body. (M) +- [ ] `lib/chat/streaming.ts` + `lib/llm/{claude,gemini,openai}.ts` — streaming + loops and provider adapters; hardest to unit test, consider extracting + pure chunk-parsing helpers first. (M) + +Not worth unit testing directly: `lib/supabase.ts` and `lib/convert.ts` are +thin wrappers around external services (Supabase auth, LibreOffice); they are +better exercised by the e2e suite. + +## Ratchet policy + +`backend/vitest.config.mts` enforces global coverage **floors** (currently +statements 11 / branches 10 / functions 14 / lines 10). They are a +no-regression ratchet, not a target: + +- **Floors only go up.** Never lower them to get a PR green — that means your + change removed tested behavior or added a large untested lib; add tests + instead. +- **Raise them in the same PR that adds tests.** After your suite passes, run + `npm run test:coverage`, take the new global numbers, and set each floor to + the measured value rounded down to a whole percent. +- Keep the measured numbers in the config comment and the table above honest + when you do. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0a6a2d..d2f80ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "mike", "version": "0.1.0", + "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.1025.0", "@aws-sdk/s3-request-presigner": "^3.1025.0", @@ -56,21 +57,41 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/marked": "^5.0.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.7.0", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.11", "eslint": "^9", "eslint-config-next": "^16.2.6", + "jsdom": "^27.0.1", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", + "vitest": "^4.1.9", "wrangler": "^4.90.0" } }, + "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/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -84,6 +105,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "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.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "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/@ast-grep/napi": { "version": "0.40.5", "resolved": "https://registry.npmjs.org/@ast-grep/napi/-/napi-0.40.5.tgz", @@ -1590,6 +1666,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "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", @@ -1650,6 +1736,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -1833,6 +1951,146 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "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.1.0", + "@csstools/css-calc": "^3.2.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.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "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/@dotenvx/dotenvx": { "version": "1.31.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.31.0.tgz", @@ -1872,21 +2130,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -1894,9 +2152,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2464,6 +2722,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "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/@fast-csv/format": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", @@ -3986,6 +4262,16 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -4650,6 +4936,664 @@ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5849,6 +6793,107 @@ "tailwindcss": "4.2.2" } }, + "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/dom/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/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "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/@tiptap/core": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", @@ -6304,9 +7349,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -6314,6 +7359,70 @@ "tslib": "^2.4.0" } }, + "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/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -6386,10 +7495,17 @@ "@types/ms": "*" } }, + "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==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -7201,6 +8317,120 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -7257,6 +8487,16 @@ "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/agentkeepalive": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", @@ -7584,6 +8824,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -7732,6 +8982,16 @@ "node": ">=0.8" } }, + "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/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -8037,6 +9297,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/chainsaw": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", @@ -8466,6 +9736,53 @@ ], "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "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", @@ -8600,6 +9917,67 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/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/data-urls/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/data-urls/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/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8677,6 +10055,13 @@ } } }, + "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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -8867,6 +10252,14 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "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/dompurify": { "version": "3.4.8", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", @@ -9145,6 +10538,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9745,6 +11145,16 @@ "url": "https://opencollective.com/unified" } }, + "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", @@ -9845,6 +11255,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -11033,6 +12453,19 @@ "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/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -11073,6 +12506,34 @@ "url": "https://opencollective.com/express" } }, + "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/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -11188,6 +12649,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -11570,6 +13041,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -11819,6 +13297,122 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.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", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "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/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/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/jsdom/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/jsdom/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/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12449,6 +14043,17 @@ "jszip": "^3.5.0" } }, + "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", @@ -12859,6 +14464,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -13545,6 +15157,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/miniflare": { "version": "4.20260507.1", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260507.1.tgz", @@ -14066,6 +15688,20 @@ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -14365,9 +16001,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -14393,9 +16029,9 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "dev": true, "funding": [ { @@ -14413,7 +16049,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -14422,9 +16058,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -14450,6 +16086,44 @@ "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/pretty-format/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/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -14841,6 +16515,16 @@ } } }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -14985,6 +16669,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -15384,6 +17082,16 @@ "url": "https://opencollective.com/unified" } }, + "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/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -15500,6 +17208,86 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", @@ -15935,6 +17723,13 @@ "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/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -15992,6 +17787,13 @@ "dev": true, "license": "MIT" }, + "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/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -16011,6 +17813,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -16242,6 +18051,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "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", @@ -16343,6 +18165,13 @@ "uuid": "^10.0.0" } }, + "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/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -16440,15 +18269,32 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "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==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -16457,6 +18303,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tiptap-markdown": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", @@ -16497,6 +18353,26 @@ "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", "license": "MIT" }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -16528,6 +18404,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -17201,12 +19090,1319 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.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/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "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", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "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/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -17232,6 +20428,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -17353,6 +20559,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -17947,6 +21170,16 @@ "xml-js": "bin/cli.js" } }, + "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/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a76e08c..9ed7735 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "vitest run", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", @@ -61,18 +62,24 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/marked": "^5.0.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.7.0", "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.11", "eslint": "^9", "eslint-config-next": "^16.2.6", + "jsdom": "^27.0.1", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", + "vitest": "^4.1.9", "wrangler": "^4.90.0" }, "license": "AGPL-3.0-only" diff --git a/frontend/src/app/components/assistant/AskInputPopup.tsx b/frontend/src/app/components/assistant/AskInputPopup.tsx index 05a20b2..52da7ea 100644 --- a/frontend/src/app/components/assistant/AskInputPopup.tsx +++ b/frontend/src/app/components/assistant/AskInputPopup.tsx @@ -242,6 +242,7 @@ export function AskInputPopup({ }; useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect if (canSubmit) submit(); }); diff --git a/frontend/src/app/components/assistant/CaseLawPanel.tsx b/frontend/src/app/components/assistant/CaseLawPanel.tsx index e60e536..0be4ea7 100644 --- a/frontend/src/app/components/assistant/CaseLawPanel.tsx +++ b/frontend/src/app/components/assistant/CaseLawPanel.tsx @@ -216,6 +216,7 @@ export function CaseLawPanel({ useEffect(() => { if (tab.opinions?.length) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync path of an async fetch effect: serve prop/cache data without a loading flash setOpinions(tab.opinions); setLoading(false); setError(null); @@ -269,10 +270,12 @@ export function CaseLawPanel({ orderOpinions(opinions).find( ({ opinion }) => typeof opinion.opinionId === "number", )?.opinion.opinionId ?? null; + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset active opinion after opinions load setActiveOpinionId(firstOpinionId); }, [opinions]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync quote list when the tab prop changes setRelevantQuotes(tab.quotes ?? []); }, [tab.quotes]); @@ -321,6 +324,7 @@ export function CaseLawPanel({ ); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset quote selection when the quote set changes setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 }); const firstQuote = relevantQuotes[0]; setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null); diff --git a/frontend/src/app/components/assistant/ChatView.tsx b/frontend/src/app/components/assistant/ChatView.tsx index f49e7e4..92591cb 100644 --- a/frontend/src/app/components/assistant/ChatView.tsx +++ b/frontend/src/app/components/assistant/ChatView.tsx @@ -83,6 +83,7 @@ export function ChatView({ const panelCloseTimerRef = useRef(null); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset per-chat UI state when switching chats setHiddenAskInputKeys(new Set()); }, [chatId]); @@ -519,6 +520,7 @@ export function ChatView({ const c = messagesContainerRef.current; if (!c) return; c.addEventListener("scroll", updateScrollButton); + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM updateScrollButton(); return () => c.removeEventListener("scroll", updateScrollButton); }, [messages, updateScrollButton]); @@ -553,6 +555,7 @@ export function ChatView({ useEffect(() => { if (messages.length === 0) { hasScrolledRef.current = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- hide messages until scroll position is restored to avoid a visible jump setMessagesVisible(false); } else if (!hasScrolledRef.current) { const userMsgCount = messages.filter( @@ -682,8 +685,8 @@ export function ChatView({ {msg.role === "user" ? ( ) : ( { if (!hasMultipleQuotes && viewMode === "list") { + // eslint-disable-next-line react-hooks/set-state-in-effect -- collapse list view when quotes drop to a single item setViewMode("single"); } }, [hasMultipleQuotes, viewMode]); diff --git a/frontend/src/app/components/assistant/PreResponseWrapper.tsx b/frontend/src/app/components/assistant/PreResponseWrapper.tsx index 77f3255..e27c45a 100644 --- a/frontend/src/app/components/assistant/PreResponseWrapper.tsx +++ b/frontend/src/app/components/assistant/PreResponseWrapper.tsx @@ -29,6 +29,7 @@ export function PreResponseWrapper({ useEffect(() => { if (forceOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- streaming open/minimize latch (see comment above) setIsOpen(true); return; } diff --git a/frontend/src/app/components/assistant/message/useSmoothedReveal.test.ts b/frontend/src/app/components/assistant/message/useSmoothedReveal.test.ts new file mode 100644 index 0000000..f1e8781 --- /dev/null +++ b/frontend/src/app/components/assistant/message/useSmoothedReveal.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useSmoothedReveal } from "./useSmoothedReveal"; + +const FULL = "**Demo mode** — no AI provider key is configured."; + +describe("useSmoothedReveal", () => { + it("snaps to the full text when the stream ends mid-reveal", async () => { + // Stream a long body in one chunk while active: the rAF pacer will only + // have revealed a prefix by the time the stream ends. + const { result, rerender } = renderHook( + ({ text, active }) => useSmoothedReveal(text, active), + { initialProps: { text: "", active: true } }, + ); + + rerender({ text: FULL, active: true }); + expect(result.current.length).toBeLessThan(FULL.length); + + // Stream ends. The hook must snap to the full text — it drives the + // rendered slice off `revealedInt`, so updating only the internal ref + // would leave the reply frozen at a partial prefix (e.g. "**Demo mo"). + await act(async () => { + rerender({ text: FULL, active: false }); + }); + + expect(result.current).toBe(FULL); + }); + + it("returns the full text immediately for a replayed (non-streaming) message", () => { + const { result } = renderHook(() => useSmoothedReveal(FULL, false)); + expect(result.current).toBe(FULL); + }); + + it("never returns more than the text it was given", async () => { + const { result, rerender } = renderHook( + ({ text, active }) => useSmoothedReveal(text, active), + { initialProps: { text: FULL, active: false } }, + ); + + // Text replaced by something shorter (edited / retried turn). + await act(async () => { + rerender({ text: "short", active: false }); + }); + + expect(result.current).toBe("short"); + }); +}); diff --git a/frontend/src/app/components/modals/Modal.tsx b/frontend/src/app/components/modals/Modal.tsx index cacffcd..30a1b62 100644 --- a/frontend/src/app/components/modals/Modal.tsx +++ b/frontend/src/app/components/modals/Modal.tsx @@ -61,6 +61,7 @@ export function Modal({ // Portals can't render during SSR, so a keep-mounted modal only renders // (hidden) after the first client mount. const [hasMounted, setHasMounted] = useState(false); + // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR portal gate: must flip after first client mount useEffect(() => setHasMounted(true), []); const hasHeader = breadcrumbs?.length; const hasFooter = diff --git a/frontend/src/app/components/shared/FileTypeIcon.test.tsx b/frontend/src/app/components/shared/FileTypeIcon.test.tsx new file mode 100644 index 0000000..ee0007f --- /dev/null +++ b/frontend/src/app/components/shared/FileTypeIcon.test.tsx @@ -0,0 +1,92 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { FileTypeIcon, fileTypeKind } from "./FileTypeIcon"; + +describe("fileTypeKind", () => { + it("maps bare file_type values to a kind", () => { + expect(fileTypeKind("pdf")).toBe("pdf"); + expect(fileTypeKind("docx")).toBe("word"); + expect(fileTypeKind("doc")).toBe("word"); + expect(fileTypeKind("xlsx")).toBe("excel"); + expect(fileTypeKind("xlsm")).toBe("excel"); + expect(fileTypeKind("xls")).toBe("excel"); + expect(fileTypeKind("pptx")).toBe("ppt"); + expect(fileTypeKind("ppt")).toBe("ppt"); + }); + + it("maps filenames by their extension", () => { + expect(fileTypeKind("report.pdf")).toBe("pdf"); + expect(fileTypeKind("Quarterly Deck.PPTX")).toBe("ppt"); + expect(fileTypeKind("model.final.xlsx")).toBe("excel"); + }); + + it("is case-insensitive and trims whitespace", () => { + expect(fileTypeKind(" PDF ")).toBe("pdf"); + expect(fileTypeKind("DOCX")).toBe("word"); + }); + + it("falls back to other for unknown, empty, or nullish input", () => { + expect(fileTypeKind("txt")).toBe("other"); + expect(fileTypeKind("")).toBe("other"); + expect(fileTypeKind(null)).toBe("other"); + expect(fileTypeKind(undefined)).toBe("other"); + }); +}); + +describe("FileTypeIcon", () => { + const svgOf = (container: HTMLElement) => container.querySelector("svg"); + const imgOf = (container: HTMLElement) => container.querySelector("img"); + + it("renders the PDF icon image", () => { + const { container } = render(); + expect(imgOf(container)).toHaveAttribute( + "src", + expect.stringContaining("/icons/file-types/pdf.svg"), + ); + }); + + it("renders the Word icon image", () => { + const { container } = render(); + expect(imgOf(container)).toHaveAttribute( + "src", + expect.stringContaining("/icons/file-types/word.svg"), + ); + }); + + it("renders the Excel icon image", () => { + const { container } = render(); + expect(imgOf(container)).toHaveAttribute( + "src", + expect.stringContaining("/icons/file-types/excel.svg"), + ); + }); + + it("renders a grey icon for unknown types", () => { + const { container } = render(); + expect(svgOf(container)).toHaveClass("text-gray-500"); + }); + + it("renders a muted grayscale image for a known kind", () => { + const { container } = render(); + const img = imgOf(container); + expect(img).toHaveClass("grayscale"); + expect(img).toHaveClass("opacity-35"); + }); + + it("renders a muted grey placeholder for unknown types", () => { + const { container } = render(); + const svg = svgOf(container); + expect(svg).toHaveClass("text-gray-300"); + }); + + it("always applies shrink-0 and merges a custom className", () => { + const { container } = render( + , + ); + const img = imgOf(container); + expect(img).toHaveClass("shrink-0"); + expect(img).toHaveClass("h-6"); + expect(img).toHaveClass("w-6"); + }); +}); diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx index bf0f367..93718c6 100644 --- a/frontend/src/app/components/shared/MfaLoginGate.tsx +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -21,6 +21,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { useEffect(() => { if (!user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync fast paths of the async MFA check effect setGateState("idle"); return; } @@ -64,6 +65,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { if (gateState === "required" && !isVerifyPage) { if (hasRecentMfaVerification()) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear gate when a recent MFA verification exists instead of redirecting setGateState("verified"); return; } diff --git a/frontend/src/app/components/shared/useDirectoryData.ts b/frontend/src/app/components/shared/useDirectoryData.ts index 87e23b9..7d4a500 100644 --- a/frontend/src/app/components/shared/useDirectoryData.ts +++ b/frontend/src/app/components/shared/useDirectoryData.ts @@ -1,11 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - getLibrary, - getProject, - listProjects, -} from "@/app/lib/mikeApi"; +import { getLibrary, listProjects } from "@/app/lib/mikeApi"; import type { Document, LibraryFolder, Project } from "./types"; export type DirectoryTab = "files" | "templates" | "projects"; @@ -45,17 +41,14 @@ async function loadTemplates() { } async function loadProjects() { - const projects = await listProjects(); - const fullProjects = await Promise.all( - projects.map((project) => getProject(project.id)), - ); - const projectCounts = new Map( - projects.map((project) => [project.id, project.document_count ?? 0]), - ); - return fullProjects.map((project) => ({ + // One batched request. Fanning out getProject(id) per project caused an + // N+1 burst on every directory-modal open that could overwhelm the + // Supabase gateway once an account had accumulated projects. + const projects = await listProjects({ includeDocuments: true }); + return projects.map((project) => ({ ...project, document_count: - project.documents?.length ?? projectCounts.get(project.id) ?? 0, + project.documents?.length ?? project.document_count ?? 0, })); } diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 3ab5163..9119953 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -164,6 +164,7 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) { useEffect(() => { if (wasActiveRef.current && !isActive) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition setShowDone(true); setDoneVisible(true); const t = setTimeout(() => setDoneVisible(false), 1500); diff --git a/frontend/src/app/components/tabular/TRTable.test.tsx b/frontend/src/app/components/tabular/TRTable.test.tsx new file mode 100644 index 0000000..7d13e78 --- /dev/null +++ b/frontend/src/app/components/tabular/TRTable.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TRTable } from "./TRTable"; +import type { Document } from "../shared/types"; + +const doc = { id: "doc-1", filename: "report.pdf" } as Document; + +function renderTable() { + return render( + , + ); +} + +describe("TRTable", () => { + // The grid here is div-based (no table/columnheader/rowheader roles), so + // this asserts on rendered content rather than ARIA table semantics. + it("renders the Document header and a row for each document", () => { + renderTable(); + expect(screen.getByText("Document")).toBeInTheDocument(); + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + // One select-all checkbox in the header plus one per document row. + expect(screen.getAllByRole("checkbox")).toHaveLength(2); + }); +}); diff --git a/frontend/src/app/components/ui/button.test.tsx b/frontend/src/app/components/ui/button.test.tsx new file mode 100644 index 0000000..a2deeea --- /dev/null +++ b/frontend/src/app/components/ui/button.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Button } from "./button"; + +describe("Button", () => { + it("renders its children", () => { + render(); + expect( + screen.getByRole("button", { name: "Click me" }), + ).toBeInTheDocument(); + }); + + it("fires onClick when activated", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("applies the variant class for destructive buttons", () => { + render(); + expect(screen.getByRole("button", { name: "Delete" })).toHaveClass( + "bg-destructive", + ); + }); + + it("does not fire onClick while disabled", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Disabled" })); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/components/ui/cite-button.test.tsx b/frontend/src/app/components/ui/cite-button.test.tsx new file mode 100644 index 0000000..ac9e1ad --- /dev/null +++ b/frontend/src/app/components/ui/cite-button.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CiteButton } from "./cite-button"; + +describe("CiteButton", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the default 'Cite' label", () => { + render(); + expect( + screen.getByRole("button", { name: /cite/i }), + ).toBeInTheDocument(); + }); + + it("hides the label when showText is false", () => { + render( + , + ); + expect(screen.queryByText("Cite")).not.toBeInTheDocument(); + }); + + it("copies the quote and citation, then shows 'Copied'", async () => { + // userEvent.setup() installs a clipboard stub on navigator; spy on it. + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText"); + render(); + + await user.click(screen.getByRole("button")); + + expect(writeText).toHaveBeenCalledWith(`"he said 'hi'" Doe 2020`); + expect(await screen.findByText("Copied")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/components/ui/pill-button.test.tsx b/frontend/src/app/components/ui/pill-button.test.tsx new file mode 100644 index 0000000..de2f084 --- /dev/null +++ b/frontend/src/app/components/ui/pill-button.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { PillButton } from "./pill-button"; + +describe("PillButton", () => { + it("renders its children as a button by default", () => { + render(Save); + expect( + screen.getByRole("button", { name: "Save" }), + ).toBeInTheDocument(); + }); + + it("defaults to type=button", () => { + render(Save); + expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute( + "type", + "button", + ); + }); + + it("applies the tone class", () => { + render(Delete); + expect(screen.getByRole("button", { name: "Delete" })).toHaveClass( + "bg-red-600/90", + ); + }); + + it("applies the normal size class when requested", () => { + render( + + Continue + , + ); + expect(screen.getByRole("button", { name: "Continue" })).toHaveClass( + "text-sm", + ); + }); + + it("defaults to the sm size class", () => { + render(Next); + expect(screen.getByRole("button", { name: "Next" })).toHaveClass( + "text-xs", + ); + }); + + it("fires onClick when activated", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Click me + , + ); + + await user.click(screen.getByRole("button", { name: "Click me" })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("does not fire onClick while disabled", async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Disabled + , + ); + + await user.click(screen.getByRole("button", { name: "Disabled" })); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it("renders as its child element via asChild", () => { + render( + + Docs + , + ); + + const link = screen.getByRole("link", { name: "Docs" }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/docs"); + // asChild drops the intrinsic button type onto the child. + expect(link).not.toHaveAttribute("type"); + // Pill styling still lands on the rendered child. + expect(link).toHaveClass("rounded-full"); + }); +}); diff --git a/frontend/src/app/contexts/ChatHistoryContext.tsx b/frontend/src/app/contexts/ChatHistoryContext.tsx index 718fc5f..2bca6d4 100644 --- a/frontend/src/app/contexts/ChatHistoryContext.tsx +++ b/frontend/src/app/contexts/ChatHistoryContext.tsx @@ -73,6 +73,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats setChats([]); setChatLimit(INITIAL_CHAT_LIMIT); setHasMoreChats(false); diff --git a/frontend/src/app/hooks/useFetchDocxBytes.ts b/frontend/src/app/hooks/useFetchDocxBytes.ts index f74bf47..d7cccfe 100644 --- a/frontend/src/app/hooks/useFetchDocxBytes.ts +++ b/frontend/src/app/hooks/useFetchDocxBytes.ts @@ -49,6 +49,7 @@ export function useFetchDocxBytes( useEffect(() => { if (!documentId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale bytes when documentId is removed, within the fetch effect setBytes(null); setDownloadUrl(null); return; diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 93a637f..5b5530e 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -16,6 +16,7 @@ export function useSelectedModel(): [string, (id: string) => void] { const [model, setModelState] = useState(DEFAULT_MODEL_ID); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- hydration-safe localStorage read; SSR must render the default model setModelState(readStored()); }, []); diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index ad3959c..6dbaa1e 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -163,8 +163,11 @@ async function toApiError(response: Response, path: string) { // Projects // --------------------------------------------------------------------------- -export async function listProjects(): Promise { - return apiRequest("/projects"); +export async function listProjects(options?: { + includeDocuments?: boolean; +}): Promise { + const query = options?.includeDocuments ? "?include=documents" : ""; + return apiRequest(`/projects${query}`); } export async function createProject( diff --git a/frontend/src/app/lib/utils.test.ts b/frontend/src/app/lib/utils.test.ts new file mode 100644 index 0000000..31dddcf --- /dev/null +++ b/frontend/src/app/lib/utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { cn, diceCoefficient, isFuzzyMatch } from "./utils"; + +describe("cn", () => { + it("joins truthy class names and drops falsy ones", () => { + expect(cn("a", false && "b", undefined, "c")).toBe("a c"); + }); + + it("merges conflicting tailwind classes, keeping the last", () => { + expect(cn("px-2", "px-4")).toBe("px-4"); + }); +}); + +describe("diceCoefficient", () => { + it("returns 1 for identical strings (ignoring case and punctuation)", () => { + expect(diceCoefficient("Hello, World!", "hello world")).toBe(1); + }); + + it("returns 0 when either input is empty", () => { + expect(diceCoefficient("", "anything")).toBe(0); + expect(diceCoefficient("anything", "")).toBe(0); + }); + + it("returns a partial score for partially overlapping strings", () => { + const score = diceCoefficient("night", "nacht"); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThan(1); + }); +}); + +describe("isFuzzyMatch", () => { + it("matches near-identical strings above the default threshold", () => { + expect(isFuzzyMatch("organization", "organisation")).toBe(true); + }); + + it("rejects clearly different strings", () => { + expect(isFuzzyMatch("apple", "zebra")).toBe(false); + }); + + it("respects a custom threshold", () => { + // "night" vs "nacht" scores ~0.25 — passes a low bar, fails a high one. + expect(isFuzzyMatch("night", "nacht", 0.1)).toBe(true); + expect(isFuzzyMatch("night", "nacht", 0.9)).toBe(false); + }); +}); diff --git a/frontend/src/app/support/page.tsx b/frontend/src/app/support/page.tsx index 08b08d4..b405d92 100644 --- a/frontend/src/app/support/page.tsx +++ b/frontend/src/app/support/page.tsx @@ -228,7 +228,7 @@ export default function SupportPage() { {/* Email Display (if logged in) */} {user?.email && (
- We'll respond to:{" "} + We'll respond to:{" "} {user.email} diff --git a/frontend/vitest.config.mts b/frontend/vitest.config.mts new file mode 100644 index 0000000..8e111b6 --- /dev/null +++ b/frontend/vitest.config.mts @@ -0,0 +1,45 @@ +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +const resolvePath = (relative: string) => + fileURLToPath(new URL(relative, import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + // Mirror the `@/*` path alias from tsconfig.json so unit tests resolve + // the same module specifiers the app uses. + alias: [ + { + find: /^@\/(.*)$/, + replacement: resolvePath("./src/$1"), + }, + ], + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + // app/lib/supabase.ts creates its client at module load, so any + // component whose import graph reaches it needs these set. Dummy + // values — unit tests never talk to Supabase. + env: { + NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: "test-anon-key", + }, + // jsdom 27's CSS-color parser (@asamuzakjp/css-color) is CJS but + // require()s the ESM-only @csstools/css-calc. That require() happens + // in the worker process while the jsdom environment boots — before + // Vite's transform pipeline is involved — so deps.inline can't fix it. + // Instead, let Node itself handle require(esm): default on >=22.12, + // and enabled by this (there harmless) flag on 22.0–22.11. + execArgv: ["--experimental-require-module"], + // Unit tests only. Keep any Playwright e2e specs (*.spec.ts) out. + include: ["src/**/*.test.{ts,tsx}"], + exclude: ["node_modules/**", "e2e/**", "**/*.spec.ts"], + // Generous timeouts to absorb cold-start jsdom + transform latency on CI. + testTimeout: 20000, + hookTimeout: 20000, + }, +}); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest";