mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
Merge remote-tracking branch 'origin/main' into olp-pr/e2e-llm-unskip
# Conflicts: # .github/workflows/e2e.yml # .gitignore # docs/e2e-ci.md # e2e/llm.ts # package.json # playwright.config.ts
This commit is contained in:
commit
5dbb45e93c
29 changed files with 3437 additions and 210 deletions
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
|
|
@ -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
|
||||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -32,6 +32,14 @@ jobs:
|
|||
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
|
||||
|
|
@ -55,6 +63,10 @@ jobs:
|
|||
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
|
||||
|
|
|
|||
19
.github/workflows/e2e.yml
vendored
19
.github/workflows/e2e.yml
vendored
|
|
@ -134,18 +134,17 @@ jobs:
|
|||
psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$m" >/dev/null 2>&1 \
|
||||
|| echo "::warning::migration returned a non-zero status (already applied?): $m"
|
||||
done
|
||||
# 3) schema.sql revokes client grants (anon/authenticated) on purpose, but
|
||||
# assumes a HOSTED Supabase where service_role already holds full table
|
||||
# access. On a fresh CLI stack loaded via psql, service_role gets no grants
|
||||
# on the new public tables, so the backend — which queries exclusively as
|
||||
# service_role (lib/supabase.ts) — 500s with "permission denied for table
|
||||
# user_profiles" on the first write. Grant AFTER migrations so the tables
|
||||
# they add are covered too.
|
||||
# 3) schema.sql now grants service_role its narrowed data privileges
|
||||
# itself, but those GRANT ... ON ALL statements only cover tables that
|
||||
# exist when schema.sql runs — tables created by the migrations above
|
||||
# are not covered, and the backend (which queries exclusively as
|
||||
# service_role, lib/supabase.ts) 500s with "permission denied" on them.
|
||||
# Re-grant AFTER migrations with the SAME privilege set as schema.sql —
|
||||
# deliberately not ALL, so e2e cannot pass on a privilege prod lacks.
|
||||
psql "$DB_URL" -v ON_ERROR_STOP=1 <<'SQL'
|
||||
GRANT USAGE ON SCHEMA public TO service_role;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role;
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO service_role;
|
||||
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO service_role;
|
||||
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;
|
||||
SQL
|
||||
# 4) PostgREST cached its schema when `supabase start` booted it (before
|
||||
# the DDL above). Tell it to reload so the new tables/grants are visible.
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -22,3 +22,7 @@ coverage
|
|||
test-results/
|
||||
playwright-report/
|
||||
e2e/.auth/
|
||||
|
||||
# Local Supabase scaffold (supabase init output) — the local stack is a test
|
||||
# harness only; CI scaffolds its own fresh copy on every run
|
||||
backend/supabase/
|
||||
|
|
|
|||
256
backend/bun.lock
256
backend/bun.lock
|
|
@ -33,9 +33,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",
|
||||
"vitest": "^4.1.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -126,8 +130,24 @@
|
|||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
|
@ -184,6 +204,12 @@
|
|||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
|
||||
|
|
@ -210,8 +236,16 @@
|
|||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
|
||||
|
||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
|
@ -234,6 +268,38 @@
|
|||
|
||||
"@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
|
||||
|
|
@ -336,6 +402,8 @@
|
|||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@supabase/auth-js": ["@supabase/auth-js@2.102.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-2uH2WB0H98TOGDtaFWhxIcR42Dro/VB7VDZanz/4bVJsqioIue1m3TUqu3xciDm2W9r+1LXQvYNsYbQfWmD+uQ=="],
|
||||
|
||||
"@supabase/functions-js": ["@supabase/functions-js@2.102.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-UcrcKTPnAIo+Yp9Jjq9XXwFbsmgRYY637mwka9ZjmTIWcX/xr1pote4OVvaGQycVY1KTiQgjMvpC0Q0yJhRq3w=="],
|
||||
|
|
@ -350,18 +418,30 @@
|
|||
|
||||
"@supabase/supabase-js": ["@supabase/supabase-js@2.102.1", "", { "dependencies": { "@supabase/auth-js": "2.102.1", "@supabase/functions-js": "2.102.1", "@supabase/postgrest-js": "2.102.1", "@supabase/realtime-js": "2.102.1", "@supabase/storage-js": "2.102.1" } }, "sha512-bChxPVeLDnYN9M2d/u4fXsvylwSQG5grAl+HN8f+ZD9a9PuVU+Ru+xGmEsk+b9Iz3rJC9ZQnQUJYQ28fApdWYA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/cookiejar": ["@types/cookiejar@2.1.5", "", {}, "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.8", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="],
|
||||
|
||||
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
||||
|
||||
"@types/multer": ["@types/multer@1.4.13", "", { "dependencies": { "@types/express": "*" } }, "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw=="],
|
||||
|
|
@ -378,8 +458,28 @@
|
|||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
"@types/superagent": ["@types/superagent@8.1.11", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw=="],
|
||||
|
||||
"@types/supertest": ["@types/supertest@7.2.1", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "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" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.10", "", { "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" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
|
@ -396,8 +496,16 @@
|
|||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
|
@ -420,15 +528,25 @@
|
|||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="],
|
||||
|
||||
"concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cookiejar": ["cookiejar@2.1.4", "", {}, "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
|
|
@ -442,10 +560,16 @@
|
|||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="],
|
||||
|
||||
"dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
|
||||
|
||||
"docx": ["docx@9.6.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ=="],
|
||||
|
|
@ -476,18 +600,26 @@
|
|||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
|
||||
|
|
@ -498,18 +630,26 @@
|
|||
|
||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "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" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
|
@ -534,16 +674,22 @@
|
|||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
|
||||
|
||||
"hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
|
|
@ -570,8 +716,16 @@
|
|||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
|
@ -592,10 +746,40 @@
|
|||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": "bin/mammoth" }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
|
@ -606,7 +790,7 @@
|
|||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
|
|
@ -634,6 +818,8 @@
|
|||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
|
@ -656,12 +842,20 @@
|
|||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.22", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
|
@ -692,6 +886,8 @@
|
|||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.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" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
|
@ -704,6 +900,8 @@
|
|||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
|
|
@ -724,16 +922,38 @@
|
|||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||
|
||||
"superagent": ["superagent@10.3.0", "", { "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" } }, "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ=="],
|
||||
|
||||
"supertest": ["supertest@7.2.2", "", { "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" } }, "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
|
@ -762,10 +982,16 @@
|
|||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
|
||||
|
||||
"vitest": ["vitest@4.1.10", "", { "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" }, "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": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
|
@ -796,6 +1022,8 @@
|
|||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"@types/superagent/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
|
@ -804,8 +1032,16 @@
|
|||
|
||||
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
|
@ -816,10 +1052,16 @@
|
|||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"send/mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"superagent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"superagent/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
|
@ -838,8 +1080,6 @@
|
|||
|
||||
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
|
@ -854,16 +1094,22 @@
|
|||
|
||||
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"@types/superagent/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"superagent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
|
|
|||
292
backend/package-lock.json
generated
292
backend/package-lock.json
generated
|
|
@ -37,8 +37,10 @@
|
|||
"@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",
|
||||
"vitest": "^4.1.9"
|
||||
|
|
@ -2203,6 +2205,19 @@
|
|||
"@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",
|
||||
|
|
@ -2225,6 +2240,16 @@
|
|||
"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",
|
||||
|
|
@ -3439,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",
|
||||
|
|
@ -3496,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",
|
||||
|
|
@ -3575,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",
|
||||
|
|
@ -3819,6 +3882,13 @@
|
|||
"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",
|
||||
|
|
@ -3847,6 +3917,13 @@
|
|||
"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",
|
||||
|
|
@ -3983,6 +4060,29 @@
|
|||
"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",
|
||||
|
|
@ -4041,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",
|
||||
|
|
@ -4105,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",
|
||||
|
|
@ -4134,6 +4251,17 @@
|
|||
"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",
|
||||
|
|
@ -4335,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",
|
||||
|
|
@ -4515,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",
|
||||
|
|
@ -4626,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",
|
||||
|
|
@ -4638,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",
|
||||
|
|
@ -4818,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",
|
||||
|
|
@ -4829,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"
|
||||
|
|
@ -6522,6 +6724,90 @@
|
|||
],
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:stack": "bash scripts/test-stack.sh",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -38,8 +39,10 @@
|
|||
"@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",
|
||||
"vitest": "^4.1.9"
|
||||
|
|
|
|||
|
|
@ -885,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;
|
||||
|
|
|
|||
63
backend/scripts/test-stack.sh
Executable file
63
backend/scripts/test-stack.sh
Executable file
|
|
@ -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 \
|
||||
"$@"
|
||||
97
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
97
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/chat")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/storage")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
80
backend/src/__tests__/integration/health.test.ts
Normal file
80
backend/src/__tests__/integration/health.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<typeof import("../../lib/chat")>();
|
||||
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]");
|
||||
});
|
||||
});
|
||||
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
|
|
@ -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<string, QueryResult>;
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> },
|
||||
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<string, unknown> = {}) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
161
backend/src/app.ts
Normal file
161
backend/src/app.ts
Normal file
|
|
@ -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 }));
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ and on manual `workflow_dispatch`, the `e2e / playwright` job:
|
|||
`backend/schema.sql`, then applies every dated migration in `backend/migrations/`
|
||||
on top. `schema.sql` is meant to be the latest shape but in practice lags the
|
||||
migrations (e.g. it is missing `workflow_open_source_submissions`, which
|
||||
`GET /workflows/:id` queries — a 500 without the migrations). It also grants
|
||||
`service_role` full access to the `public` tables afterward, because
|
||||
`schema.sql` revokes client grants assuming a hosted Supabase where
|
||||
`service_role` is already privileged;
|
||||
`GET /workflows/:id` queries — a 500 without the migrations). After the
|
||||
migrations it re-grants `service_role` the same narrowed data privileges
|
||||
`schema.sql` grants (`SELECT/INSERT/UPDATE/DELETE` on tables, `USAGE/SELECT`
|
||||
on sequences): the schema's own `GRANT ... ON ALL` statements only cover
|
||||
tables that existed when the schema loaded, not ones the migrations create;
|
||||
4. writes `backend/.env` and `frontend/.env.local` from the live Supabase values;
|
||||
5. **builds** the web app (`next build`) and serves it with `next start` — a
|
||||
production build, not `next dev`, so there is no on-demand compilation (which
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
|||
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
||||
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
|
||||
import { AppSidebar } from "@/app/components/shared/AppSidebar";
|
||||
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
|
||||
|
||||
export default function MikeLayout({
|
||||
children,
|
||||
|
|
@ -75,11 +76,7 @@ export default function MikeLayout({
|
|||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-700" />
|
||||
</div>
|
||||
);
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
|
|
|||
|
|
@ -4,23 +4,16 @@ import { Suspense } from "react";
|
|||
import { AuthProvider } from "@/app/contexts/AuthContext";
|
||||
import { UserProfileProvider } from "@/app/contexts/UserProfileContext";
|
||||
import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate";
|
||||
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<UserProfileProvider>
|
||||
<Suspense fallback={<ProviderLoader />}>
|
||||
<Suspense fallback={<FullScreenLoader />}>
|
||||
<MfaLoginGate>{children}</MfaLoginGate>
|
||||
</Suspense>
|
||||
</UserProfileProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderLoader() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
14
frontend/src/app/components/shared/FullScreenLoader.tsx
Normal file
14
frontend/src/app/components/shared/FullScreenLoader.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* The single full-screen loading state. Every gate in the provider/layout
|
||||
* chain (Suspense fallback, MFA gate, auth-loading layout) must render this
|
||||
* exact markup: the server and client can resolve those gates differently on
|
||||
* first paint, and identical DOM is what keeps that from being a hydration
|
||||
* mismatch.
|
||||
*/
|
||||
export function FullScreenLoader() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader";
|
||||
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
|
||||
|
||||
type GateState = "idle" | "checking" | "required" | "verified";
|
||||
|
|
@ -91,7 +92,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
|
|||
return gateState === "verified" ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<FullScreenGateLoader />
|
||||
<FullScreenLoader />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,15 +101,15 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
|
|||
return <>{children}</>;
|
||||
}
|
||||
if (gateState === "verified" && isVerifyPage) {
|
||||
return <FullScreenGateLoader />;
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
if (gateState === "verified") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
if (gateState === "required" && !isVerifyPage) {
|
||||
return <FullScreenGateLoader />;
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
return <FullScreenGateLoader />;
|
||||
return <FullScreenLoader />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
|
@ -122,14 +123,6 @@ function safeNextPath(value: string | null) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function FullScreenGateLoader() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function markMfaVerifiedForGate() {
|
||||
window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "bash scripts/e2e-local-stack.sh",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,12 +43,17 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
|
||||
/* Start the backend and the Next.js dev server when running locally */
|
||||
/* Start the backend and the Next.js dev server when running locally.
|
||||
The backend command first runs the local-stack setup (Docker check,
|
||||
supabase start, schema + migrations + grants, env wiring) so a plain
|
||||
`npm run test:e2e` works against a ready local Supabase — see
|
||||
scripts/e2e-local-stack.sh. Idempotent: a few seconds when already up. */
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
command: "npm run dev",
|
||||
command:
|
||||
"bash ../scripts/e2e-local-stack.sh --setup-only && npm run dev",
|
||||
cwd: "backend",
|
||||
url: "http://localhost:3001/health",
|
||||
reuseExistingServer: true,
|
||||
|
|
|
|||
117
scripts/e2e-local-stack.sh
Executable file
117
scripts/e2e-local-stack.sh
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env bash
|
||||
# Boot the full local e2e stack and run the Playwright suite.
|
||||
#
|
||||
# Local-machine mirror of .github/workflows/e2e.yml: starts the Supabase CLI
|
||||
# stack (Docker), loads backend/schema.sql + backend/migrations/ (re-granting
|
||||
# service_role's narrowed privileges afterwards for migration-created tables —
|
||||
# see docs/e2e-ci.md), points backend/.env and frontend/.env.local at the
|
||||
# local stack, then runs `npx playwright test`.
|
||||
#
|
||||
# Usage, from the repo root:
|
||||
# npm run test:e2e:local # whole suite
|
||||
# npm run test:e2e:local -- -g "display name" # extra args go to Playwright
|
||||
#
|
||||
# With --setup-only the script prepares the stack and exits without running
|
||||
# Playwright — playwright.config.ts uses this in the backend webServer command
|
||||
# so a plain `npm run test:e2e` also boots against a ready local stack.
|
||||
#
|
||||
# The first run rewrites the Supabase lines in your env files; the previous
|
||||
# (e.g. hosted) versions are kept once as .env.hosted.bak / .env.local.hosted.bak.
|
||||
# Restore those backups to point back at the hosted project.
|
||||
set -euo pipefail
|
||||
|
||||
SETUP_ONLY=0
|
||||
if [ "${1:-}" = "--setup-only" ]; then
|
||||
SETUP_ONLY=1
|
||||
shift
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BACKEND="$ROOT/backend"
|
||||
FRONTEND="$ROOT/frontend"
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker is not running — start it first (open -a Docker) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKEND"
|
||||
|
||||
if [ ! -f supabase/config.toml ]; then
|
||||
npx supabase init --force --with-vscode-settings=false ||
|
||||
npx supabase init --force
|
||||
fi
|
||||
|
||||
# Idempotent: if the stack is already up this is a no-op.
|
||||
npx supabase start
|
||||
|
||||
STATUS=$(npx supabase status -o json)
|
||||
DB_URL=$(jq -r '.DB_URL' <<<"$STATUS")
|
||||
API_URL=$(jq -r '.API_URL' <<<"$STATUS")
|
||||
ANON_KEY=$(jq -r '.ANON_KEY' <<<"$STATUS")
|
||||
SERVICE_KEY=$(jq -r '.SERVICE_ROLE_KEY' <<<"$STATUS")
|
||||
|
||||
# schema.sql is not idempotent, so only load it into a virgin database; the
|
||||
# dated migrations ARE re-runnable and fill any gap schema.sql has (it lags —
|
||||
# see docs/e2e-ci.md), so apply them every time.
|
||||
if [ "$(psql "$DB_URL" -tAc "SELECT to_regclass('public.user_profiles') IS NULL")" = "t" ]; then
|
||||
echo "Loading schema.sql into fresh database…"
|
||||
psql "$DB_URL" -v ON_ERROR_STOP=1 -q -f schema.sql
|
||||
fi
|
||||
for m in migrations/*.sql; do
|
||||
psql "$DB_URL" -q -f "$m" >/dev/null 2>&1 ||
|
||||
echo "warning: migration returned non-zero (already applied?): $m"
|
||||
done
|
||||
# schema.sql grants these itself, but only for tables that existed when it
|
||||
# ran — re-grant after migrations, with the same narrowed set (not ALL).
|
||||
psql "$DB_URL" -v ON_ERROR_STOP=1 -q <<'SQL'
|
||||
GRANT USAGE ON SCHEMA public TO service_role;
|
||||
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;
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
SQL
|
||||
|
||||
# Rewrite only the Supabase lines of the env files, preserving everything else
|
||||
# (API keys, R2 storage, …). Keep a one-time backup of the pre-local versions.
|
||||
set_kv() {
|
||||
local file=$1 key=$2 value=$3
|
||||
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'index($0, k"=") == 1 { print k "=" v; next } { print }' \
|
||||
"$file" >"$file.tmp" && mv "$file.tmp" "$file"
|
||||
else
|
||||
echo "${key}=${value}" >>"$file"
|
||||
fi
|
||||
}
|
||||
|
||||
[ -f .env ] || cp .env.example .env
|
||||
[ -f .env.hosted.bak ] || cp .env .env.hosted.bak
|
||||
set_kv .env SUPABASE_URL "$API_URL"
|
||||
set_kv .env SUPABASE_SECRET_KEY "$SERVICE_KEY"
|
||||
# The suite fires well over the backend's default 300-requests/15-min general
|
||||
# cap in one run; once tripped every call 429s and profile/list waits time out.
|
||||
# Same overrides CI uses — e2e is not testing throttling.
|
||||
set_kv .env RATE_LIMIT_GENERAL_MAX 100000
|
||||
set_kv .env RATE_LIMIT_CHAT_MAX 100000
|
||||
set_kv .env RATE_LIMIT_CHAT_CREATE_MAX 100000
|
||||
set_kv .env RATE_LIMIT_UPLOAD_MAX 100000
|
||||
set_kv .env RATE_LIMIT_EXPORT_MAX 100000
|
||||
set_kv .env RATE_LIMIT_DATA_DELETE_MAX 100000
|
||||
|
||||
touch "$FRONTEND/.env.local"
|
||||
[ -f "$FRONTEND/.env.local.hosted.bak" ] || cp "$FRONTEND/.env.local" "$FRONTEND/.env.local.hosted.bak"
|
||||
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_SUPABASE_URL "$API_URL"
|
||||
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY "$ANON_KEY"
|
||||
set_kv "$FRONTEND/.env.local" NEXT_PUBLIC_API_BASE_URL "http://localhost:3001"
|
||||
|
||||
echo "Local stack ready: $API_URL (db: ${DB_URL%%\?*})"
|
||||
|
||||
if [ "$SETUP_ONLY" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "NOTE: kill any backend/frontend dev servers started before this script —"
|
||||
echo "they hold the old env. playwright.config.ts starts fresh ones if none run."
|
||||
|
||||
cd "$ROOT"
|
||||
npx playwright test "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue