diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d2edaa98 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Lockfiles are generated files. Git's line-level text merge can combine +# both sides' insertions into syntactically invalid JSON *without raising a +# conflict* (this silently broke backend/package.json + package-lock.json in +# PR #233). Merge them as binary so any concurrent change surfaces as an +# explicit conflict; resolve by taking main's copy and regenerating: +# git checkout origin/main -- package-lock.json && npm install +package-lock.json merge=binary +bun.lock merge=binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b14ec8af..c41f9101 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 45230a81..2e62daac 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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. diff --git a/.gitignore b/.gitignore index bfde53be..f38b4a97 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/bun.lock b/backend/bun.lock index 745ff4ad..c6bd0365 100644 --- a/backend/bun.lock +++ b/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=="], diff --git a/backend/package-lock.json b/backend/package-lock.json index 29bd3f9b..7bed7189 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index d01ff881..0223d9a1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/schema.sql b/backend/schema.sql index e475b764..6819473b 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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; diff --git a/backend/scripts/test-stack.sh b/backend/scripts/test-stack.sh new file mode 100755 index 00000000..f75deaef --- /dev/null +++ b/backend/scripts/test-stack.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Run the gated stack-level integration tests against a local Supabase stack. +# +# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of +# mocks. They are the harness you re-run on every Supabase image bump to prove +# the auth↔API contract and the deny-all RLS firewall still hold. +# +# Usage: supabase start # in the repo, once +# npm run test:stack (from backend/) +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +SCHEMA_FILE="$BACKEND_DIR/schema.sql" + +if ! command -v supabase >/dev/null 2>&1; then + echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2 + exit 1 +fi + +STATUS="$(supabase status -o json 2>/dev/null)" || { + echo "No running Supabase stack. Start one with: supabase start" >&2 + exit 1 +} + +read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; } + +SUPABASE_TEST_URL="$(read_key API_URL)" +SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)" +SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)" +SUPABASE_TEST_DB_URL="$(read_key DB_URL)" + +if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then + echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2 + exit 1 +fi +export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY + +if ! command -v psql >/dev/null 2>&1; then + echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2 + exit 1 +fi + +# A newly started local stack contains Supabase's system schemas but none of +# Mike's application tables. Initialize only an empty stack: silently resetting +# or modifying an existing application database would be surprising. +PROJECTS_TABLE="$( + psql "$SUPABASE_TEST_DB_URL" -XAtq \ + -c "select to_regclass('public.projects');" +)" +if [[ "$PROJECTS_TABLE" != "projects" ]]; then + echo "Mike schema not found; loading $SCHEMA_FILE" + psql "$SUPABASE_TEST_DB_URL" -X \ + --set ON_ERROR_STOP=1 \ + --file "$SCHEMA_FILE" +fi + +echo "Running stack integration tests against $SUPABASE_TEST_URL" +cd "$BACKEND_DIR" +exec npx vitest run \ + src/__tests__/integration/stack.supabase.test.ts \ + src/__tests__/integration/access.supabase.test.ts \ + "$@" diff --git a/backend/src/__tests__/integration/access.supabase.test.ts b/backend/src/__tests__/integration/access.supabase.test.ts new file mode 100644 index 00000000..edfea6df --- /dev/null +++ b/backend/src/__tests__/integration/access.supabase.test.ts @@ -0,0 +1,97 @@ +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, it } from "vitest"; +import { + filterAccessibleDocumentIds, + listAccessibleProjectIds, +} from "../../lib/access"; + +// Gated: runs only against a real (local) Supabase stack. +// supabase start, then export: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY +// or use scripts/test-stack.sh which reads them from `supabase status`. +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; + +const maybeDescribe = url && serviceKey ? describe : describe.skip; + +maybeDescribe("Supabase access integration", () => { + it("proves tabular document filtering drops foreign document IDs", async () => { + const admin = createClient(url!, serviceKey!, { + auth: { persistSession: false }, + }); + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const ownerId = crypto.randomUUID(); + const reviewerId = crypto.randomUUID(); + const sharedProjectId = crypto.randomUUID(); + const privateProjectId = crypto.randomUUID(); + const sharedDocId = crypto.randomUUID(); + const privateDocId = crypto.randomUUID(); + + try { + const projectsInsert = await admin.from("projects").insert([ + { + id: sharedProjectId, + user_id: ownerId, + name: `shared-${suffix}`, + shared_with: [`reviewer-${suffix}@example.com`], + }, + { + id: privateProjectId, + user_id: ownerId, + name: `private-${suffix}`, + shared_with: [], + }, + ]); + if (projectsInsert.error) { + throw new Error( + `Could not seed projects: ${projectsInsert.error.message}`, + { cause: projectsInsert.error }, + ); + } + + // filename/file_type live on document_versions in this schema — + // the documents rows only need identity + ownership columns. + const documentsInsert = await admin.from("documents").insert([ + { + id: sharedDocId, + user_id: ownerId, + project_id: sharedProjectId, + }, + { + id: privateDocId, + user_id: ownerId, + project_id: privateProjectId, + }, + ]); + if (documentsInsert.error) { + throw new Error( + `Could not seed documents: ${documentsInsert.error.message}`, + { cause: documentsInsert.error }, + ); + } + + await expect( + listAccessibleProjectIds( + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toContain(sharedProjectId); + + await expect( + filterAccessibleDocumentIds( + [sharedDocId, privateDocId], + reviewerId, + `reviewer-${suffix}@example.com`, + admin as any, + ), + ).resolves.toEqual([sharedDocId]); + } finally { + await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]); + await admin + .from("projects") + .delete() + .in("id", [sharedProjectId, privateProjectId]); + } + }); +}); diff --git a/backend/src/__tests__/integration/chat.routes.test.ts b/backend/src/__tests__/integration/chat.routes.test.ts new file mode 100644 index 00000000..64e71ecd --- /dev/null +++ b/backend/src/__tests__/integration/chat.routes.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above +// the imports) can reference it. Lets each test drive the stream outcome. +const { runLLMStream } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), +})); + +// A permissive, chainable Supabase stub. Every query-builder method returns the +// same object (so arbitrary chains work), the object is awaitable (thenable), +// and the terminal single()/maybeSingle() resolve to a chat row. The chat +// routes only read `.id`/`.title` and check `.error`, so this is enough to let +// a request flow through chat creation and message inserts without real IO. +function makeQuery() { + const result = { data: { id: "chat-1", title: null }, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// Authenticate every request as user "u1" without exercising the real Supabase +// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by +// the app) imports it at module load. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Keep the real error helpers (the failure-path test relies on genuine +// isAbortError + AssistantStreamError behavior) but stub the functions that +// would otherwise hit the DB or the LLM. +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /chat — streaming endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "hi there", + events: [], + citations: [], + }); + }); + + it("streams SSE with a chat_id event on the happy path", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + // Headers were already flushed (200) before the stream threw, so the + // failure surfaces as an in-stream error event + [DONE]. + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); + + it("returns 400 on an empty messages array (never starts a stream)", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ messages: [] }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("detail"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when messages is missing entirely", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("returns 400 when chat_id is not a non-empty string", async () => { + const res = await request(app) + .post("/chat") + .set("Authorization", "Bearer test") + .send({ ...VALID_BODY, chat_id: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("chat_id must be a non-empty string"); + expect(runLLMStream).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /chat/:chatId", () => { + it("returns 400 when title is missing", async () => { + const res = await request(app) + .patch("/chat/chat-1") + .set("Authorization", "Bearer test") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("title is required"); + }); +}); diff --git a/backend/src/__tests__/integration/documentsUpload.routes.test.ts b/backend/src/__tests__/integration/documentsUpload.routes.test.ts new file mode 100644 index 00000000..e7544cfb --- /dev/null +++ b/backend/src/__tests__/integration/documentsUpload.routes.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +function mockSupabase() { + const result = { data: null, error: null }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "order", "limit", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve(result).then(resolve); + return { + from: vi.fn(() => q), + rpc: vi.fn(() => Promise.resolve(result)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Stub the storage IO functions so a request that clears validation never +// touches R2/S3, while keeping the rest of the storage module (key builders, +// disposition helpers) real. The validation tests below reject before storage +// is reached, but this guards against accidental real IO regardless. +vi.mock("../../lib/storage", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uploadFile: vi.fn(async () => {}), + downloadFile: vi.fn(async () => null), + deleteFile: vi.fn(async () => {}), + }; +}); + +import { app } from "../../app"; + +describe("POST /single-documents — upload validation", () => { + it("rejects an unsupported file extension with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .attach("file", Buffer.from("hello world"), { + filename: "notes.txt", + contentType: "text/plain", + }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/unsupported file type/i); + }); + + it("rejects a request with no file attached with 400", async () => { + const res = await request(app) + .post("/single-documents") + .set("Authorization", "Bearer test") + .field("note", "no file here"); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("file is required"); + }); +}); + +describe("POST /single-documents/download-zip — bounds", () => { + it("returns 400 when document_ids is empty", async () => { + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: [] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/document_ids is required/i); + }); + + it("returns 404 when none of the requested documents are accessible", async () => { + // The documents lookup resolves to no rows (stubbed DB), so the + // access filter leaves nothing to zip. + const res = await request(app) + .post("/single-documents/download-zip") + .set("Authorization", "Bearer test") + .send({ document_ids: ["d-other-user"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("No documents found"); + }); +}); diff --git a/backend/src/__tests__/integration/health.test.ts b/backend/src/__tests__/integration/health.test.ts new file mode 100644 index 00000000..8a361720 --- /dev/null +++ b/backend/src/__tests__/integration/health.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; + +// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at +// request time (not import time), so setting them here is early enough even +// though imported modules evaluate before this assignment runs. +process.env.SUPABASE_URL = "http://supabase.test.local"; +process.env.SUPABASE_SECRET_KEY = "test-service-key"; + +// Mock the supabase-js client factory so the real requireAuth middleware never +// makes a network call: auth.getUser() resolves to no user for any token, +// simulating an invalid/expired JWT. +vi.mock("@supabase/supabase-js", () => ({ + createClient: vi.fn(() => ({ + from: () => { + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "filter", + "order", "limit", + ]; + for (const m of chain) q[m] = () => q; + q.single = () => Promise.resolve({ data: null, error: null }); + q.maybeSingle = () => Promise.resolve({ data: null, error: null }); + q.then = (resolve: (v: unknown) => unknown) => + Promise.resolve({ data: null, error: null }).then(resolve); + return q; + }, + rpc: () => Promise.resolve({ data: null, error: null }), + auth: { + getUser: () => + Promise.resolve({ data: { user: null }, error: null }), + }, + })), +})); + +// Vitest hoists vi.mock() calls before all imports, so this regular import +// receives the mocked supabase-js module even though it appears after the +// vi.mock() call in source order. +import { app } from "../../app"; + +describe("GET /health", () => { + it("returns 200 with { ok: true }", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); + +describe("requireAuth middleware", () => { + it("rejects requests with no Authorization header (401)", async () => { + const res = await request(app).get("/chat"); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("detail"); + }); + + it("rejects requests with a non-Bearer Authorization header (401)", async () => { + const res = await request(app) + .get("/chat") + .set("Authorization", "Basic dXNlcjpwYXNz"); + expect(res.status).toBe(401); + }); + + it("rejects requests with an invalid Bearer token (401)", async () => { + // The mocked createClient().auth.getUser returns { user: null } for + // any token — simulating an expired/invalid token. + const res = await request(app) + .get("/chat") + .set("Authorization", "Bearer invalid-token"); + expect(res.status).toBe(401); + expect(res.body.detail).toMatch(/invalid|expired/i); + }); +}); + +describe("404 handling", () => { + it("returns 404 for unknown routes", async () => { + const res = await request(app).get("/this-route-does-not-exist"); + expect(res.status).toBe(404); + }); +}); diff --git a/backend/src/__tests__/integration/projectChat.routes.test.ts b/backend/src/__tests__/integration/projectChat.routes.test.ts new file mode 100644 index 00000000..8f6af783 --- /dev/null +++ b/backend/src/__tests__/integration/projectChat.routes.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({ + runLLMStream: vi.fn(), + checkProjectAccess: vi.fn(), +})); + +function makeQuery() { + const result = { + data: { id: "chat-1", title: null, project_id: "p1" }, + error: null, + }; + const q: Record = {}; + const chain = [ + "select", "insert", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(result)); + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(result).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn(() => makeQuery()), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/chat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildProjectDocContext: vi.fn(async () => ({ + docIndex: {}, + docStore: new Map(), + folderPaths: new Map(), + })), + enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages), + buildWorkflowStore: vi.fn(async () => new Map()), + buildMessages: vi.fn(() => []), + runLLMStream: (...args: unknown[]) => runLLMStream(...args), + }; +}); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + legal_research_us: false, + title_model: "test-model", + tabular_model: "test-model", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +import { app } from "../../app"; + +const VALID_BODY = { messages: [{ role: "user", content: "hello" }] }; + +describe("POST /projects/:projectId/chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + runLLMStream.mockResolvedValue({ + fullText: "", + events: [], + citations: [], + }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + }); + + it("returns 404 and never streams when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + // The guard fires before any LLM stream. + expect(runLLMStream).not.toHaveBeenCalled(); + }); + + it("streams SSE on the happy path with project access granted", async () => { + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("text/event-stream"); + expect(res.text).toContain('"type":"chat_id"'); + expect(runLLMStream).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => { + runLLMStream.mockRejectedValue(new Error("upstream LLM failure")); + + const res = await request(app) + .post("/projects/p1/chat") + .set("Authorization", "Bearer test") + .send(VALID_BODY); + + expect(res.status).toBe(200); + expect(res.text).toContain('"type":"error"'); + expect(res.text).toContain("[DONE]"); + }); +}); diff --git a/backend/src/__tests__/integration/projects.routes.test.ts b/backend/src/__tests__/integration/projects.routes.test.ts new file mode 100644 index 00000000..408f24e2 --- /dev/null +++ b/backend/src/__tests__/integration/projects.routes.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we want to reconfigure per-test. +// --------------------------------------------------------------------------- +const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({ + checkProjectAccess: vi.fn(), + deleteUserProjects: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach; +// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the +// per-table result, and rpc() resolves to a per-call result. Insert payloads +// are recorded so tests can assert on normalisation (lowercasing / dedupe). +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +// Every export of lib/access must be present — other routers (chat, documents, +// downloads, tabular) import from it at app load. +vi.mock("../../lib/access", () => ({ + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +// user router imports all four cleanup helpers at module load. +vi.mock("../../lib/userDataCleanup", () => ({ + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), + deleteAllUserChats: vi.fn(async () => {}), + deleteAllUserTabularReviews: vi.fn(async () => {}), + deleteUserAccountData: vi.fn(async () => {}), +})); + +// Version-path enrichment hits the DB in real life; no-op it so the route +// responses are driven purely by the documents/projects table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: vi.fn(async () => null), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("projects.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + deleteUserProjects.mockResolvedValue(1); + }); + + // ── GET /projects (overview) ────────────────────────────────────────── + describe("GET /projects", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "p1", name: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/projects").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /projects (create) ─────────────────────────────────────────── + describe("POST /projects", () => { + it("returns 400 when name is missing/blank", async () => { + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: " " }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("name is required"); + }); + + it("returns 400 when sharing the project with yourself", async () => { + // The authed user's email is u1@test.local; supplying it (in any + // case) must be rejected. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Beta", shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("creates the project (201) and normalises shared_with", async () => { + // Sharing requires each recipient to have a mirrored user_profiles + // row (findMissingUserEmails); seed both emails so validation + // passes and the create path proceeds. + supabaseState.tables.user_profiles = { + data: [{ email: "a@x.com" }, { email: "b@x.com" }], + error: null, + }; + supabaseState.tables.projects = { + data: { + id: "p9", + name: "Gamma", + user_id: "u1", + shared_with: ["a@x.com", "b@x.com"], + }, + error: null, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ + name: " Gamma ", + shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "p9", documents: [] }); + + // The insert payload should be lowercased, deduped, trimmed and + // the name trimmed. + const insert = supabaseState.inserts.find( + (i) => i.table === "projects", + ); + expect(insert?.payload).toMatchObject({ + name: "Gamma", + shared_with: ["a@x.com", "b@x.com"], + }); + }); + + it("returns 400 when a shared_with recipient is not a Mike user", async () => { + // No user_profiles rows seeded → findMissingUserEmails reports the + // recipient as unknown and the create is rejected before insert. + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Gamma", shared_with: ["ghost@x.com"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "ghost@x.com does not belong to a Mike user.", + ); + expect( + supabaseState.inserts.find((i) => i.table === "projects"), + ).toBeUndefined(); + }); + + it("returns 500 when the insert errors", async () => { + supabaseState.tables.projects = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/projects") + .set(...AUTH) + .send({ name: "Delta" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /projects/:projectId (detail, inline access) ────────────────── + describe("GET /projects/:projectId", () => { + it("returns 404 when the project does not exist", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 404 when the caller is neither owner nor shared", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["other@x.com"], + }, + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("grants access to a shared member (is_owner false)", async () => { + supabaseState.tables.projects = { + data: { + id: "p1", + user_id: "someone-else", + shared_with: ["u1@test.local"], + }, + error: null, + }; + supabaseState.tables.documents = { data: [], error: null }; + supabaseState.tables.project_subfolders = { data: [], error: null }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "p1", is_owner: false }); + }); + + it("returns 200 with documents/folders/is_owner when owned", async () => { + supabaseState.tables.projects = { + data: { id: "p1", user_id: "u1", shared_with: null }, + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", user_id: "u1" }], + error: null, + }; + supabaseState.tables.project_subfolders = { + data: [{ id: "f1" }], + error: null, + }; + + const res = await request(app).get("/projects/p1").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: "p1", + is_owner: true, + documents: [{ id: "d1" }], + folders: [{ id: "f1" }], + }); + }); + }); + + // ── GET /projects/:projectId/documents (checkProjectAccess guard) ───── + describe("GET /projects/:projectId/documents", () => { + it("returns 404 when checkProjectAccess denies access", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + + it("returns 200 with documents when access is granted", async () => { + supabaseState.tables.documents = { + data: [{ id: "d1" }, { id: "d2" }], + error: null, + }; + + const res = await request(app) + .get("/projects/p1/documents") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]); + expect(checkProjectAccess).toHaveBeenCalledTimes(1); + }); + }); + + // ── PATCH /projects/:projectId (sharing normalisation) ──────────────── + describe("PATCH /projects/:projectId", () => { + it("returns 400 when sharing the project with yourself", async () => { + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ shared_with: ["u1@test.local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a project with yourself.", + ); + }); + + it("returns 404 when the update matches no owned project", async () => { + supabaseState.tables.projects = { data: null, error: null }; + + const res = await request(app) + .patch("/projects/p1") + .set(...AUTH) + .send({ name: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + }); + + // ── DELETE /projects/:projectId ─────────────────────────────────────── + describe("DELETE /projects/:projectId", () => { + it("returns 404 when nothing was deleted", async () => { + deleteUserProjects.mockResolvedValue(0); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 204 when the project is deleted", async () => { + deleteUserProjects.mockResolvedValue(1); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(204); + // Signature is deleteUserProjects(db, userId, [projectId]). + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ["p1"], + ); + }); + + it("returns 500 when deletion throws", async () => { + deleteUserProjects.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/projects/p1").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + }); +}); diff --git a/backend/src/__tests__/integration/stack.supabase.test.ts b/backend/src/__tests__/integration/stack.supabase.test.ts new file mode 100644 index 00000000..b74a051c --- /dev/null +++ b/backend/src/__tests__/integration/stack.supabase.test.ts @@ -0,0 +1,146 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth + +// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed +// Supabase version set safe — it's what you re-run on every image bump to prove +// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors +// the security model's central claim: RLS denies the user/anon path, and the API +// reaches data only via the service-role key. +// +// Gated: skipped unless a stack is provided (default CI unit run skips it). +// Locally: `supabase start`, then export the printed keys as: +// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const anonKey = process.env.SUPABASE_TEST_ANON_KEY; +const maybeDescribe = + url && serviceKey && anonKey ? describe : describe.skip; + +// Every public table the app owns (backend/schema.sql + migrations). The +// anon/user path must never return rows from any of these (deny-all); a +// regression that ships a table without RLS — or with a permissive policy — +// trips the leak sweep below. A table missing from an older local stack +// returns an error (no rows), which never counts as a leak. +const PUBLIC_TABLES = [ + "chat_messages", "chats", "courtlistener_citation_index", + "courtlistener_opinion_cluster_index", "document_edits", + "document_versions", "documents", "hidden_workflows", "library_folders", + "project_subfolders", "projects", "tabular_cells", + "tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews", + "user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors", + "user_mcp_oauth_states", "user_mcp_oauth_tokens", + "user_mcp_tool_audit_logs", "user_profiles", + "workflow_open_source_submissions", "workflow_shares", "workflows", +]; + +maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => { + const password = "StackTest1!"; + const emailA = `stack-a-${Date.now()}@test.local`; + const emailB = `stack-b-${Date.now()}@test.local`; + + let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path + let userA = ""; + let userB = ""; + let tokenA = ""; + let projectId = ""; + + // A client acting as a signed-in end user (anon key + the user's JWT): this is + // the path RLS must fence off. + const asUser = (token: string) => + createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + + beforeAll(async () => { + admin = createClient(url!, serviceKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const a = await admin.auth.admin.createUser({ + email: emailA, password, email_confirm: true, + }); + const b = await admin.auth.admin.createUser({ + email: emailB, password, email_confirm: true, + }); + if (a.error || !a.data.user) throw a.error ?? new Error("no user A"); + if (b.error || !b.data.user) throw b.error ?? new Error("no user B"); + userA = a.data.user.id; + userB = b.data.user.id; + + // Sign in as A to get a real access token (the token the API middleware + // validates via auth.getUser). + const signIn = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailA, password }); + if (signIn.error || !signIn.data.session) { + throw signIn.error ?? new Error("no session for A"); + } + tokenA = signIn.data.session.access_token; + + // Seed one row owned by A via the service role (the app's real write path). + const proj = await admin + .from("projects") + .insert({ user_id: userA, name: "Stack Test Project" }) + .select("id") + .single(); + if (proj.error || !proj.data) throw proj.error ?? new Error("no project"); + projectId = proj.data.id; + }); + + afterAll(async () => { + if (projectId) await admin.from("projects").delete().eq("id", projectId); + if (userA) await admin.auth.admin.deleteUser(userA); + if (userB) await admin.auth.admin.deleteUser(userB); + }); + + it("auth contract: the access token resolves to its user (middleware path)", async () => { + const { data, error } = await admin.auth.getUser(tokenA); + expect(error).toBeNull(); + expect(data.user?.id).toBe(userA); + expect(data.user?.email).toBe(emailA); + }); + + it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => { + // Service role (app data path) sees the project… + const svc = await admin + .from("projects").select("id").eq("id", projectId); + expect(svc.error).toBeNull(); + expect(svc.data ?? []).toHaveLength(1); + + // …but the owner, going through the user/anon path, sees zero rows — + // deny-all RLS is the firewall; the app must use the service role. + const owner = await asUser(tokenA) + .from("projects").select("id").eq("id", projectId); + expect(owner.data ?? []).toHaveLength(0); + + // And the owner's profile (if any) is equally invisible to the user path. + const prof = await asUser(tokenA) + .from("user_profiles").select("user_id").eq("user_id", userA); + expect(prof.data ?? []).toHaveLength(0); + }); + + it("tenant isolation: user B cannot read user A's project via the user path", async () => { + const signInB = await createClient(url!, anonKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }).auth.signInWithPassword({ email: emailB, password }); + const tokenB = signInB.data.session!.access_token; + + const cross = await asUser(tokenB) + .from("projects").select("id").eq("id", projectId); + expect(cross.data ?? []).toHaveLength(0); + }); + + it("leak sweep: no public table returns rows to the authenticated user path", async () => { + const client = asUser(tokenA); + const leaks: string[] = []; + for (const table of PUBLIC_TABLES) { + const { data } = await client.from(table).select("*").limit(1); + if ((data ?? []).length > 0) leaks.push(table); + } + // Any table returning rows to a normal user means RLS is missing or a + // policy is permissive — the exact regression this guards against. + expect(leaks).toEqual([]); + }); +}); diff --git a/backend/src/__tests__/integration/tabular.routes.test.ts b/backend/src/__tests__/integration/tabular.routes.test.ts new file mode 100644 index 00000000..c828148c --- /dev/null +++ b/backend/src/__tests__/integration/tabular.routes.test.ts @@ -0,0 +1,713 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns reconfigured per-test. Access helpers + model settings are +// mocked so the tests drive review-access decisions, document-access filtering +// and the missing-API-key guard without touching real Supabase / LLM IO. The +// streaming endpoints (chat/generate) are only exercised up to their GUARDS — +// the SSE loop itself is never reached in these tests. +// --------------------------------------------------------------------------- +const { + ensureReviewAccess, + checkProjectAccess, + filterAccessibleDocumentIds, + getUserModelSettings, + loadActiveVersion, +} = vi.hoisted(() => ({ + ensureReviewAccess: vi.fn(), + checkProjectAccess: vi.fn(), + filterAccessibleDocumentIds: vi.fn(), + getUserModelSettings: vi.fn(), + loadActiveVersion: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds +// `supabaseState` in beforeEach; terminal query operations resolve to the +// per-table result, rpc() resolves to a per-call result. Insert payloads are +// recorded so tests can assert on what got persisted. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + rpc: QueryResult; + tables: Record; + inserts: { table: string; payload: unknown }[]; +}; + +function resetSupabaseState() { + supabaseState = { + rpc: { data: [], error: null }, + tables: {}, + inserts: [], + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.insert = vi.fn((payload: unknown) => { + supabaseState.inserts.push({ table, payload }); + return q; + }); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + next(); + }, + requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) => + next(), +})); + +vi.mock("../../lib/access", () => ({ + ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args), + checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args), + filterAccessibleDocumentIds: (...args: unknown[]) => + filterAccessibleDocumentIds(...args), + ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })), + listAccessibleProjectIds: vi.fn(async () => []), +})); + +vi.mock("../../lib/userSettings", () => ({ + getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +// Version-path enrichment + active-version resolution hit the DB in real life; +// no-op them so route responses are driven purely by the table stubs. +vi.mock("../../lib/documentVersions", () => ({ + attachActiveVersionPaths: vi.fn(async () => {}), + attachLatestVersionNumbers: vi.fn(async () => {}), + loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args), +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +describe("tabular.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: caller is the owner with full access. + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true }); + checkProjectAccess.mockResolvedValue({ + ok: true, + isOwner: true, + project: { id: "p1", user_id: "u1", shared_with: null }, + }); + // Default: every requested doc is accessible (identity passthrough). + filterAccessibleDocumentIds.mockImplementation( + async (ids: string[]) => ids, + ); + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: { claude: "sk-test" }, + }); + loadActiveVersion.mockResolvedValue(null); + }); + + // ── GET /tabular-review (overview) ──────────────────────────────────── + describe("GET /tabular-review", () => { + it("returns the overview rows from the RPC", async () => { + supabaseState.rpc = { + data: [{ id: "r1", title: "Alpha" }], + error: null, + }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]); + }); + + it("returns 500 with detail when the RPC errors", async () => { + supabaseState.rpc = { data: null, error: { message: "boom" } }; + + const res = await request(app).get("/tabular-review").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("boom"); + }); + }); + + // ── POST /tabular-review (create) ───────────────────────────────────── + describe("POST /tabular-review", () => { + it("creates a review (201) and only persists accessible documents", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r9", title: "Gamma", document_ids: ["d1"] }, + error: null, + }; + // d2 is not accessible — it must be filtered out of the insert. + filterAccessibleDocumentIds.mockResolvedValue(["d1"]); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + title: "Gamma", + document_ids: ["d1", "d2"], + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "r9" }); + + const reviewInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_reviews", + ); + expect(reviewInsert?.payload).toMatchObject({ + document_ids: ["d1"], + }); + // Cells are created for accessible docs × columns only (1 × 1). + const cellInsert = supabaseState.inserts.find( + (i) => i.table === "tabular_cells", + ); + expect(cellInsert?.payload).toEqual([ + { + review_id: "r9", + document_id: "d1", + column_index: 0, + status: "pending", + }, + ]); + }); + + it("returns 404 when project access is denied", async () => { + checkProjectAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ + project_id: "p-nope", + document_ids: [], + columns_config: [], + }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Project not found"); + }); + + it("returns 500 when the review insert errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "insert failed" }, + }; + + const res = await request(app) + .post("/tabular-review") + .set(...AUTH) + .send({ document_ids: [], columns_config: [] }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("insert failed"); + }); + }); + + // ── GET /tabular-review/:reviewId (detail) ──────────────────────────── + describe("GET /tabular-review/:reviewId", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 200 with review/cells/documents + is_owner", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + document_ids: ["d1"], + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { + data: [ + { + id: "c1", + document_id: "d1", + column_index: 0, + content: null, + status: "pending", + }, + ], + error: null, + }; + supabaseState.tables.documents = { + data: [{ id: "d1", current_version_id: null }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body.review).toMatchObject({ id: "r1", is_owner: true }); + expect(res.body.cells).toHaveLength(1); + expect(res.body.documents).toEqual([ + { id: "d1", current_version_id: null }, + ]); + }); + }); + + // ── PATCH /tabular-review/:reviewId ─────────────────────────────────── + describe("PATCH /tabular-review/:reviewId", () => { + it("returns 400 when project_id is an invalid type", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ project_id: 123 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "project_id must be a non-empty string or null", + ); + }); + + it("returns 400 when sharing the review with yourself", async () => { + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ shared_with: ["U1@Test.Local"] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "You cannot share a tabular review with yourself.", + ); + }); + + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ title: "Renamed" }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 403 when a non-owner edits columns_config", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: "p1" }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false }); + + const res = await request(app) + .patch("/tabular-review/r1") + .set(...AUTH) + .send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] }); + + expect(res.status).toBe(403); + expect(res.body.detail).toBe("Only the review owner can change columns"); + }); + }); + + // ── DELETE /tabular-review/:reviewId ────────────────────────────────── + describe("DELETE /tabular-review/:reviewId", () => { + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(204); + }); + + it("returns 500 when the delete errors", async () => { + supabaseState.tables.tabular_reviews = { + data: null, + error: { message: "delete failed" }, + }; + + const res = await request(app) + .delete("/tabular-review/r1") + .set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("delete failed"); + }); + }); + + // ── POST /tabular-review/:reviewId/clear-cells ──────────────────────── + describe("POST /tabular-review/:reviewId/clear-cells", () => { + it("returns 400 when document_ids is missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("document_ids is required"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 204 on success", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/clear-cells") + .set(...AUTH) + .send({ document_ids: ["d1"] }); + + expect(res.status).toBe(204); + }); + }); + + // ── POST /tabular-review/:reviewId/regenerate-cell ──────────────────── + describe("POST /tabular-review/:reviewId/regenerate-cell", () => { + it("returns 400 when document_id / column_index are missing", async () => { + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe( + "document_id and column_index are required", + ); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when the column is not configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 5, name: "Other", prompt: "p" }], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Column not found"); + }); + + it("returns 404 when the document is not accessible", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + filterAccessibleDocumentIds.mockResolvedValue([]); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d-forbidden", column_index: 0 }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Document not found"); + }); + + it("returns 422 with missing_api_key when the model key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.documents = { + data: { id: "d1", current_version_id: null }, + error: null, + }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/regenerate-cell") + .set(...AUTH) + .send({ document_id: "d1", column_index: 0 }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + expect(res.body.provider).toBe("claude"); + }); + }); + + // ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ─── + describe("POST /tabular-review/:reviewId/generate", () => { + it("returns 404 when the review does not exist", async () => { + supabaseState.tables.tabular_reviews = { data: null, error: null }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 400 when no columns are configured", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("No columns configured"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [{ index: 0, name: "Col", prompt: "p" }], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/generate") + .set(...AUTH); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ─────── + describe("POST /tabular-review/:reviewId/chat", () => { + it("returns 400 when no user message is present", async () => { + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "assistant", content: "hi" }] }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("messages must include a user message"); + }); + + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns 422 missing_api_key before streaming when the key is absent", async () => { + supabaseState.tables.tabular_reviews = { + data: { + id: "r1", + user_id: "u1", + project_id: null, + columns_config: [], + }, + error: null, + }; + supabaseState.tables.tabular_cells = { data: [], error: null }; + getUserModelSettings.mockResolvedValue({ + title_model: "claude-haiku-4-5", + tabular_model: "claude-sonnet-4-5", + legal_research_us: false, + api_keys: {}, + }); + + const res = await request(app) + .post("/tabular-review/r1/chat") + .set(...AUTH) + .send({ messages: [{ role: "user", content: "hello" }] }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("missing_api_key"); + }); + }); + + // ── GET /tabular-review/:reviewId/chats ─────────────────────────────── + describe("GET /tabular-review/:reviewId/chats", () => { + it("returns 404 when review access is denied", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "other", project_id: null }, + error: null, + }; + ensureReviewAccess.mockResolvedValue({ ok: false }); + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(404); + expect(res.body.detail).toBe("Review not found"); + }); + + it("returns the chat list when access is granted", async () => { + supabaseState.tables.tabular_reviews = { + data: { id: "r1", user_id: "u1", project_id: null }, + error: null, + }; + supabaseState.tables.tabular_review_chats = { + data: [{ id: "chat-1", title: "T", user_id: "u1" }], + error: null, + }; + + const res = await request(app) + .get("/tabular-review/r1/chats") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { id: "chat-1", title: "T", user_id: "u1" }, + ]); + }); + }); +}); diff --git a/backend/src/__tests__/integration/user.routes.test.ts b/backend/src/__tests__/integration/user.routes.test.ts new file mode 100644 index 00000000..d47b5bb5 --- /dev/null +++ b/backend/src/__tests__/integration/user.routes.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +// --------------------------------------------------------------------------- +// Hoisted mock fns we reconfigure per-test. These cover the three security +// surfaces this suite baselines: +// - the MFA route guard (requireMfaIfEnrolled) +// - the API-key crypto boundary (userApiKeys) +// - the destructive data export / deletion helpers (userDataExport / +// userDataCleanup) +// Each is a vi.fn so we can both reconfigure behaviour and assert call args. +// --------------------------------------------------------------------------- +const { + requireMfaIfEnrolled, + getUserApiKeyStatus, + saveUserApiKey, + hasEnvApiKey, + normalizeApiKeyProvider, + deleteAllUserChats, + deleteAllUserTabularReviews, + deleteUserAccountData, + deleteUserProjects, + buildUserAccountExport, + buildUserChatsExport, + buildUserTabularReviewsExport, +} = vi.hoisted(() => ({ + requireMfaIfEnrolled: vi.fn(), + getUserApiKeyStatus: vi.fn(), + saveUserApiKey: vi.fn(), + hasEnvApiKey: vi.fn(), + normalizeApiKeyProvider: vi.fn(), + deleteAllUserChats: vi.fn(), + deleteAllUserTabularReviews: vi.fn(), + deleteUserAccountData: vi.fn(), + deleteUserProjects: vi.fn(), + buildUserAccountExport: vi.fn(), + buildUserChatsExport: vi.fn(), + buildUserTabularReviewsExport: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Configurable Supabase stub. The only route in this suite that reaches the +// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests +// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the +// per-table result and auth.admin methods are stubbed where routes call them. +// --------------------------------------------------------------------------- +type QueryResult = { data: unknown; error: unknown }; + +let supabaseState: { + tables: Record; + adminGetUserById: QueryResult; + adminDeleteUser: { error: unknown }; +}; + +function resetSupabaseState() { + supabaseState = { + tables: {}, + adminGetUserById: { + data: { user: { id: "u1", factors: [] } }, + error: null, + }, + adminDeleteUser: { error: null }, + }; +} +resetSupabaseState(); + +function resultForTable(table: string): QueryResult { + return supabaseState.tables[table] ?? { data: null, error: null }; +} + +function makeQuery(table: string) { + const q: Record = {}; + const chain = [ + "select", "update", "delete", "upsert", "insert", + "eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte", + "filter", "order", "limit", "range", "contains", + ]; + for (const m of chain) q[m] = vi.fn(() => q); + q.single = vi.fn(() => Promise.resolve(resultForTable(table))); + q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table))); + q.then = ( + resolve: (v: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise.resolve(resultForTable(table)).then(resolve, reject); + return q; +} + +function mockSupabase() { + return { + from: vi.fn((table: string) => makeQuery(table)), + rpc: vi.fn(() => Promise.resolve({ data: null, error: null })), + auth: { + getUser: () => + Promise.resolve({ data: { user: { id: "u1" } }, error: null }), + admin: { + getUserById: vi.fn(() => + Promise.resolve(supabaseState.adminGetUserById), + ), + deleteUser: vi.fn(() => + Promise.resolve(supabaseState.adminDeleteUser), + ), + }, + }, + }; +} + +vi.mock("../../lib/supabase", () => ({ + createServerSupabase: vi.fn(() => mockSupabase()), + getUserIdFromRequest: vi.fn(async () => "u1"), +})); + +// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable +// guard so we can drive both the satisfied (next()) and rejected +// (403 mfa_verification_required) paths. +vi.mock("../../middleware/auth", () => ({ + requireAuth: ( + _req: unknown, + res: { locals: Record }, + next: () => void, + ) => { + res.locals.userId = "u1"; + res.locals.userEmail = "u1@test.local"; + res.locals.token = "test-token"; + next(); + }, + requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) => + requireMfaIfEnrolled(req, res, next), +})); + +// API-key crypto boundary: the route must funnel writes through saveUserApiKey +// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns +// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings +// imports it at module load. +vi.mock("../../lib/userApiKeys", () => ({ + getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args), + saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args), + hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args), + normalizeApiKeyProvider: (...args: unknown[]) => + normalizeApiKeyProvider(...args), + getUserApiKeys: vi.fn(async () => ({})), +})); + +vi.mock("../../lib/userDataCleanup", () => ({ + deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args), + deleteAllUserTabularReviews: (...args: unknown[]) => + deleteAllUserTabularReviews(...args), + deleteUserAccountData: (...args: unknown[]) => + deleteUserAccountData(...args), + deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args), +})); + +vi.mock("../../lib/userDataExport", () => ({ + buildUserAccountExport: (...args: unknown[]) => + buildUserAccountExport(...args), + buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args), + buildUserTabularReviewsExport: (...args: unknown[]) => + buildUserTabularReviewsExport(...args), + userExportFilename: (kind: string, userId: string) => + `mike-${kind}-export-${userId.slice(0, 8)}.json`, +})); + +import { app } from "../../app"; + +const AUTH = ["Authorization", "Bearer test"] as const; + +// A complete user_profiles row with credits_reset_date in the future so the +// monthly-reset branch in loadProfile is not triggered. +function profileRow(overrides: Record = {}) { + return { + display_name: "Ada", + organisation: "Acme", + message_credits_used: 3, + credits_reset_date: "2999-01-01T00:00:00.000Z", + tier: "Pro", + title_model: null, + tabular_model: "gemini-3-flash-preview", + mfa_on_login: false, + legal_research_us: true, + ...overrides, + }; +} + +const STATUS = { claude: true, openai: false, gemini: false, sources: {} }; + +// The exact 403 body the web client's MFA gate consumes (mirrors the real +// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor. +function rejectMfa(_req: unknown, res: any) { + res.status(403).json({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); +} + +describe("user.routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetSupabaseState(); + // Default: MFA satisfied (guard passes through). + requireMfaIfEnrolled.mockImplementation( + (_req: unknown, _res: unknown, next: () => void) => next(), + ); + getUserApiKeyStatus.mockResolvedValue(STATUS); + saveUserApiKey.mockResolvedValue(undefined); + hasEnvApiKey.mockReturnValue(false); + normalizeApiKeyProvider.mockImplementation((v: string) => + ["claude", "openai", "gemini"].includes(v) ? v : null, + ); + deleteAllUserChats.mockResolvedValue(undefined); + deleteAllUserTabularReviews.mockResolvedValue(undefined); + deleteUserAccountData.mockResolvedValue(undefined); + deleteUserProjects.mockResolvedValue(undefined); + buildUserAccountExport.mockResolvedValue({ account: "data" }); + buildUserChatsExport.mockResolvedValue({ chats: "data" }); + buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" }); + }); + + // ── GET /user/profile (MFA bootstrap path) ──────────────────────────── + describe("GET /user/profile", () => { + it("returns the serialized profile plus apiKeyStatus", async () => { + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + displayName: "Ada", + organisation: "Acme", + messageCreditsUsed: 3, + tier: "Pro", + legalResearchUs: true, + mfaOnLogin: false, + apiKeyStatus: STATUS, + }); + // Presence-only key status — never plaintext. + expect(JSON.stringify(res.body)).not.toContain("sk-"); + }); + + it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => { + // Even if the MFA factor were unsatisfied, profile must remain + // reachable so the client can render the verification gate. + requireMfaIfEnrolled.mockImplementation(rejectMfa); + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + + it("returns 500 with detail when the profile load errors", async () => { + supabaseState.tables.user_profiles = { + data: null, + error: { message: "db down" }, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("db down"); + }); + }); + + // ── POST /user/profile (bootstrap upsert) ───────────────────────────── + describe("POST /user/profile", () => { + it("ensures the profile row and returns ok", async () => { + const res = await request(app).post("/user/profile").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(requireMfaIfEnrolled).not.toHaveBeenCalled(); + }); + }); + + // ── GET /user/api-keys (presence without plaintext) ─────────────────── + describe("GET /user/api-keys", () => { + it("returns the boolean key-status map", async () => { + const res = await request(app).get("/user/api-keys").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + expect(getUserApiKeyStatus).toHaveBeenCalledWith( + "u1", + expect.anything(), + ); + }); + }); + + // ── PUT /user/api-keys/:provider (crypto + MFA guard) ───────────────── + describe("PUT /user/api-keys/:provider", () => { + it("stores the key via the encryption helper and returns status", async () => { + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-secret-value" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(STATUS); + // The plaintext key must go through saveUserApiKey (the encryption + // boundary), keyed by provider + value, never persisted by the route. + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "claude", + "sk-secret-value", + expect.anything(), + ); + }); + + it("deletes the key when api_key is omitted (null value)", async () => { + const res = await request(app) + .put("/user/api-keys/openai") + .set(...AUTH) + .send({}); + + expect(res.status).toBe(200); + expect(saveUserApiKey).toHaveBeenCalledWith( + "u1", + "openai", + null, + expect.anything(), + ); + }); + + it("returns 400 for an unsupported provider", async () => { + const res = await request(app) + .put("/user/api-keys/bogus") + .set(...AUTH) + .send({ api_key: "x" }); + + expect(res.status).toBe(400); + expect(res.body.detail).toBe("Unsupported provider"); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 409 when the provider is configured by the server env", async () => { + hasEnvApiKey.mockReturnValue(true); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(409); + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns 500 when saving the key throws", async () => { + saveUserApiKey.mockRejectedValue(new Error("kms unavailable")); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("kms unavailable"); + }); + + it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .put("/user/api-keys/claude") + .set(...AUTH) + .send({ api_key: "sk-x" }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); + // Guarded: the crypto path is never reached. + expect(saveUserApiKey).not.toHaveBeenCalled(); + }); + }); + + // ── Data export endpoints (MFA-guarded, attachment headers) ─────────── + describe("data export endpoints", () => { + it("GET /user/export returns the account export as a JSON attachment", async () => { + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ account: "data" }); + expect(res.headers["content-type"]).toContain("application/json"); + expect(res.headers["content-disposition"]).toContain("attachment"); + expect(res.headers["content-disposition"]).toContain( + "mike-account-export-u1.json", + ); + expect(buildUserAccountExport).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("GET /user/chats/export returns the chats export", async () => { + const res = await request(app) + .get("/user/chats/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ chats: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-chats-export-u1.json", + ); + expect(buildUserChatsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/tabular-reviews/export returns the reviews export", async () => { + const res = await request(app) + .get("/user/tabular-reviews/export") + .set(...AUTH); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ reviews: "data" }); + expect(res.headers["content-disposition"]).toContain( + "mike-tabular-reviews-export-u1.json", + ); + expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1); + }); + + it("GET /user/export returns 500 when the builder throws", async () => { + buildUserAccountExport.mockRejectedValue(new Error("export boom")); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("export boom"); + }); + + it("GET /user/export is rejected when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).get("/user/export").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(buildUserAccountExport).not.toHaveBeenCalled(); + }); + }); + + // ── Data deletion endpoints (MFA-guarded, cleanup helpers) ──────────── + describe("data deletion endpoints", () => { + it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => { + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserChats).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => { + const res = await request(app) + .delete("/user/projects") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteUserProjects).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => { + const res = await request(app) + .delete("/user/tabular-reviews") + .set(...AUTH); + + expect(res.status).toBe(204); + expect(deleteAllUserTabularReviews).toHaveBeenCalledWith( + expect.anything(), + "u1", + ); + }); + + it("DELETE /user/account purges data then deletes the auth user (204)", async () => { + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(204); + // Account purge runs the cleanup helper with id + email. + expect(deleteUserAccountData).toHaveBeenCalledWith( + expect.anything(), + "u1", + "u1@test.local", + ); + }); + + it("DELETE /user/account returns 500 when the auth-user delete errors", async () => { + supabaseState.adminDeleteUser = { error: { message: "auth boom" } }; + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("auth boom"); + }); + + it("DELETE /user/chats returns 500 when cleanup throws", async () => { + deleteAllUserChats.mockRejectedValue(new Error("cascade failed")); + + const res = await request(app).delete("/user/chats").set(...AUTH); + + expect(res.status).toBe(500); + expect(res.body.detail).toBe("cascade failed"); + }); + + it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app).delete("/user/account").set(...AUTH); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + expect(deleteUserAccountData).not.toHaveBeenCalled(); + }); + }); + + // ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ──────── + describe("PATCH /user/security/mfa-login", () => { + it("returns 400 when enabling without a verified TOTP factor", async () => { + supabaseState.adminGetUserById = { + data: { user: { id: "u1", factors: [] } }, + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(400); + expect(res.body.detail).toContain("authenticator app"); + }); + + it("enables MFA-on-login when a verified TOTP factor exists", async () => { + supabaseState.adminGetUserById = { + data: { + user: { + id: "u1", + factors: [ + { factor_type: "totp", status: "verified" }, + ], + }, + }, + error: null, + }; + supabaseState.tables.user_profiles = { + data: profileRow({ mfa_on_login: true }), + error: null, + }; + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: true }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ mfaOnLogin: true }); + }); + + it("returns 400 on a non-boolean enabled field", async () => { + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: "yes" }); + + expect(res.status).toBe(400); + }); + + it("is rejected with 403 when MFA is unsatisfied", async () => { + requireMfaIfEnrolled.mockImplementation(rejectMfa); + + const res = await request(app) + .patch("/user/security/mfa-login") + .set(...AUTH) + .send({ enabled: false }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe("mfa_verification_required"); + }); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 00000000..19cb5c3a --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,161 @@ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { chatRouter } from "./routes/chat"; +import { projectsRouter } from "./routes/projects"; +import { projectChatRouter } from "./routes/projectChat"; +import { documentsRouter } from "./routes/documents"; +import { libraryRouter } from "./routes/library"; +import { tabularRouter } from "./routes/tabular"; +import { workflowsRouter } from "./routes/workflows"; +import { userRouter } from "./routes/user"; +import { downloadsRouter } from "./routes/downloads"; +import { caseLawRouter } from "./routes/caseLaw"; + +export const app = express(); +const isProduction = process.env.NODE_ENV === "production"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function minutes(value: number): number { + return value * 60 * 1000; +} + +function hours(value: number): number { + return minutes(value * 60); +} + +function makeLimiter(options: { + windowMs: number; + max: number; + message?: string; +}) { + return rateLimit({ + windowMs: options.windowMs, + max: options.max, + standardHeaders: true, + legacyHeaders: false, + skip: (req) => req.method === "OPTIONS", + message: { + detail: + options.message ?? "Too many requests. Please try again later.", + }, + }); +} + +const generalLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_GENERAL_MAX", 300), +}); + +const chatLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_MAX", 30), + message: "Too many chat requests. Please try again later.", +}); + +const chatCreateLimiter = makeLimiter({ + windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), + max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), +}); + +const uploadLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), + message: "Too many upload requests. Please try again later.", +}); + +const exportLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_EXPORT_MAX", 10), + message: "Too many export requests. Please try again later.", +}); + +const dataDeleteLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), + message: "Too many data deletion requests. Please try again later.", +}); + +function jsonLimitForPath(path: string): string { + return "50mb"; +} + +app.disable("x-powered-by"); +app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'none'"], + baseUri: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: isProduction + ? { + maxAge: 15552000, + includeSubDomains: true, + } + : false, + referrerPolicy: { policy: "no-referrer" }, + }), +); + +app.use( + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + credentials: true, + }), +); + +app.use(generalLimiter); + +app.post("/chat", chatLimiter); +app.post("/projects/:projectId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/chat", chatLimiter); +app.post("/tabular-review/:reviewId/generate", chatLimiter); +app.post("/chat/create", chatCreateLimiter); +app.post("/chat/:chatId/generate-title", chatCreateLimiter); +app.post("/single-documents", uploadLimiter); +app.post("/library/:kind/documents", uploadLimiter); +app.post("/single-documents/:documentId/versions", uploadLimiter); +app.put( + "/single-documents/:documentId/versions/:versionId/file", + uploadLimiter, +); +app.post("/projects/:projectId/documents", uploadLimiter); +app.get("/user/export", exportLimiter); +app.get("/user/chats/export", exportLimiter); +app.get("/user/tabular-reviews/export", exportLimiter); +app.delete("/user/account", dataDeleteLimiter); +app.delete("/user/chats", dataDeleteLimiter); +app.delete("/user/projects", dataDeleteLimiter); +app.delete("/user/tabular-reviews", dataDeleteLimiter); + +app.use((req, res, next) => + express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), +); + +app.use("/chat", chatRouter); +app.use("/projects", projectsRouter); +app.use("/projects/:projectId/chat", projectChatRouter); +app.use("/single-documents", documentsRouter); +app.use("/library", libraryRouter); +app.use("/tabular-review", tabularRouter); +app.use("/workflows", workflowsRouter); +app.use("/user", userRouter); +app.use("/users", userRouter); +app.use("/download", downloadsRouter); +app.use("/case-law", caseLawRouter); + +app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/index.ts b/backend/src/index.ts index b8d36cf0..ddf28c7f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,165 +1,6 @@ -import "dotenv/config"; -import express from "express"; -import cors from "cors"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import { chatRouter } from "./routes/chat"; -import { projectsRouter } from "./routes/projects"; -import { projectChatRouter } from "./routes/projectChat"; -import { documentsRouter } from "./routes/documents"; -import { libraryRouter } from "./routes/library"; -import { tabularRouter } from "./routes/tabular"; -import { workflowsRouter } from "./routes/workflows"; -import { userRouter } from "./routes/user"; -import { downloadsRouter } from "./routes/downloads"; -import { caseLawRouter } from "./routes/caseLaw"; +import { app } from "./app"; -const app = express(); const PORT = process.env.PORT ?? 3001; -const isProduction = process.env.NODE_ENV === "production"; - -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function minutes(value: number): number { - return value * 60 * 1000; -} - -function hours(value: number): number { - return minutes(value * 60); -} - -function makeLimiter(options: { - windowMs: number; - max: number; - message?: string; -}) { - return rateLimit({ - windowMs: options.windowMs, - max: options.max, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => req.method === "OPTIONS", - message: { - detail: - options.message ?? "Too many requests. Please try again later.", - }, - }); -} - -const generalLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_GENERAL_MAX", 300), -}); - -const chatLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_MAX", 30), - message: "Too many chat requests. Please try again later.", -}); - -const chatCreateLimiter = makeLimiter({ - windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)), - max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60), -}); - -const uploadLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_UPLOAD_MAX", 50), - message: "Too many upload requests. Please try again later.", -}); - -const exportLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_EXPORT_MAX", 10), - message: "Too many export requests. Please try again later.", -}); - -const dataDeleteLimiter = makeLimiter({ - windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), - max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), - message: "Too many data deletion requests. Please try again later.", -}); - -function jsonLimitForPath(path: string): string { - return "50mb"; -} - -app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); - -app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'none'"], - baseUri: ["'none'"], - frameAncestors: ["'none'"], - }, - }, - crossOriginEmbedderPolicy: false, - hsts: isProduction - ? { - maxAge: 15552000, - includeSubDomains: true, - } - : false, - referrerPolicy: { policy: "no-referrer" }, - }), -); - -app.use( - cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", - credentials: true, - }), -); - -app.use(generalLimiter); - -app.post("/chat", chatLimiter); -app.post("/projects/:projectId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/generate", chatLimiter); -app.post("/chat/create", chatCreateLimiter); -app.post("/chat/:chatId/generate-title", chatCreateLimiter); -app.post("/single-documents", uploadLimiter); -app.post("/library/:kind/documents", uploadLimiter); -app.post("/single-documents/:documentId/versions", uploadLimiter); -app.put( - "/single-documents/:documentId/versions/:versionId/file", - uploadLimiter, -); -app.post("/projects/:projectId/documents", uploadLimiter); -app.get("/user/export", exportLimiter); -app.get("/user/chats/export", exportLimiter); -app.get("/user/tabular-reviews/export", exportLimiter); -app.delete("/user/account", dataDeleteLimiter); -app.delete("/user/chats", dataDeleteLimiter); -app.delete("/user/projects", dataDeleteLimiter); -app.delete("/user/tabular-reviews", dataDeleteLimiter); - -app.use((req, res, next) => - express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), -); - -app.use("/chat", chatRouter); -app.use("/projects", projectsRouter); -app.use("/projects/:projectId/chat", projectChatRouter); -app.use("/single-documents", documentsRouter); -app.use("/library", libraryRouter); -app.use("/tabular-review", tabularRouter); -app.use("/workflows", workflowsRouter); -app.use("/user", userRouter); -app.use("/users", userRouter); -app.use("/download", downloadsRouter); -app.use("/case-law", caseLawRouter); - -app.get("/health", (_req, res) => res.json({ ok: true })); app.listen(PORT, () => { console.log(`Mike backend running on port ${PORT}`); diff --git a/docs/e2e-ci.md b/docs/e2e-ci.md index 91813217..2b7b9fb2 100644 --- a/docs/e2e-ci.md +++ b/docs/e2e-ci.md @@ -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 diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 84dce26f..14a6e76c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -3,6 +3,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + turbopack: { + root: __dirname, + }, async rewrites() { return [ { diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 107c5144..ffd5066d 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -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 ( -
-
-
- ); + return ; } if (!isAuthenticated) return null; diff --git a/frontend/src/app/components/providers.tsx b/frontend/src/app/components/providers.tsx index 039934b7..c5ef4442 100644 --- a/frontend/src/app/components/providers.tsx +++ b/frontend/src/app/components/providers.tsx @@ -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 ( - }> + }> {children} ); } - -function ProviderLoader() { - return ( -
-
-
- ); -} diff --git a/frontend/src/app/components/shared/FullScreenLoader.tsx b/frontend/src/app/components/shared/FullScreenLoader.tsx new file mode 100644 index 00000000..6b8b10d9 --- /dev/null +++ b/frontend/src/app/components/shared/FullScreenLoader.tsx @@ -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 ( +
+
+
+ ); +} diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx index 93718c6b..b3dd6770 100644 --- a/frontend/src/app/components/shared/MfaLoginGate.tsx +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -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} ) : ( - + ); } @@ -100,15 +101,15 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { return <>{children}; } if (gateState === "verified" && isVerifyPage) { - return ; + return ; } if (gateState === "verified") { return <>{children}; } if (gateState === "required" && !isVerifyPage) { - return ; + return ; } - return ; + return ; } return <>{children}; @@ -122,14 +123,6 @@ function safeNextPath(value: string | null) { return value; } -function FullScreenGateLoader() { - return ( -
-
-
- ); -} - export function markMfaVerifiedForGate() { window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now())); } diff --git a/package.json b/package.json index 7dfa4692..9a40f205 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/playwright.config.ts b/playwright.config.ts index 61133b9f..30dbe900 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/scripts/e2e-local-stack.sh b/scripts/e2e-local-stack.sh new file mode 100755 index 00000000..ba767d4f --- /dev/null +++ b/scripts/e2e-local-stack.sh @@ -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 "$@"