From ad27acad7e02b787185c972c710c73c867429c36 Mon Sep 17 00:00:00 2001
From: hectorzhao
Date: Fri, 28 Aug 2026 14:26:58 +0800
Subject: [PATCH] refactor: strengthen client boundaries and quality gates
---
.prettierignore | 7 +
.prettierrc.json | 5 +
api/jest.incremental.config.cjs | 17 +
api/package-lock.json | 206 +-
api/package.json | 7 +-
api/src/auth/auth.controller.ts | 80 +-
api/src/auth/auth.service.spec.ts | 141 +-
api/src/auth/auth.service.ts | 43 +-
api/src/auth/session.service.spec.ts | 109 +-
api/src/auth/session.service.ts | 103 +-
.../common/bounded-json-object.validator.ts | 55 +
api/src/common/client-write.dto.spec.ts | 37 +-
api/src/common/client-write.dto.ts | 57 +-
api/src/files/client-files.controller.ts | 22 +-
api/src/files/client-files.dto.spec.ts | 28 +
api/src/files/client-files.dto.ts | 13 +
api/src/metrics/metrics.service.ts | 54 +-
.../open-api/client-open-api.controller.ts | 64 +-
api/src/open-api/client-open-api.dto.spec.ts | 28 +
api/src/open-api/client-open-api.dto.ts | 35 +
api/src/open-api/open-api.service.spec.ts | 218 +-
api/src/open-api/open-api.service.ts | 668 ++-
.../client-operations.controller.ts | 51 +-
.../operations/client-operations.dto.spec.ts | 28 +
api/src/operations/client-operations.dto.ts | 10 +
.../client-sms-config.controller.ts | 139 +-
api/src/users/client-user.dto.spec.ts | 43 +
api/src/users/client-user.dto.ts | 30 +
api/src/users/users.controller.ts | 58 +-
.../brace-expansion-compat/package.json | 6 +-
...api-dependency-advisory-matrix-20260828.md | 31 +
...y-continuous-optimization-plan-20260828.md | 457 ++
docs/code-quality-reassessment-20260828-v2.md | 190 +
docs/testing-progress.md | 16 +
eslint.config.js | 26 +
package-lock.json | 3819 ++++++++++++++++-
package.json | 24 +-
src/api/client/client.api.ts | 433 +-
src/api/core/httpClient.test.ts | 221 +
src/api/core/httpClient.ts | 30 +-
src/api/types/common.ts | 15 +-
src/apps/LoginPage.test.tsx | 95 +
src/apps/LoginPage.tsx | 46 +-
src/apps/client/ClientUsersPage.test.tsx | 143 +
src/apps/client/ClientUsersPage.tsx | 325 +-
src/routes/RouteLoadBoundary.test.tsx | 29 +
src/test/setup.ts | 9 +
tools/quality/run-changed-code-quality.mjs | 31 +
tools/quality/verify-code-quality.mjs | 67 +-
.../verify-dependency-mitigations.mjs | 17 +-
vite.config.ts | 14 +-
51 files changed, 7703 insertions(+), 697 deletions(-)
create mode 100644 .prettierignore
create mode 100644 .prettierrc.json
create mode 100644 api/jest.incremental.config.cjs
create mode 100644 api/src/common/bounded-json-object.validator.ts
create mode 100644 api/src/files/client-files.dto.spec.ts
create mode 100644 api/src/files/client-files.dto.ts
create mode 100644 api/src/open-api/client-open-api.dto.spec.ts
create mode 100644 api/src/open-api/client-open-api.dto.ts
create mode 100644 api/src/operations/client-operations.dto.spec.ts
create mode 100644 api/src/operations/client-operations.dto.ts
create mode 100644 api/src/users/client-user.dto.spec.ts
create mode 100644 api/src/users/client-user.dto.ts
create mode 100644 docs/api-dependency-advisory-matrix-20260828.md
create mode 100644 docs/code-quality-continuous-optimization-plan-20260828.md
create mode 100644 docs/code-quality-reassessment-20260828-v2.md
create mode 100644 eslint.config.js
create mode 100644 src/api/core/httpClient.test.ts
create mode 100644 src/apps/LoginPage.test.tsx
create mode 100644 src/apps/client/ClientUsersPage.test.tsx
create mode 100644 src/routes/RouteLoadBoundary.test.tsx
create mode 100644 src/test/setup.ts
create mode 100644 tools/quality/run-changed-code-quality.mjs
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..b2b01dc
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+coverage
+api/dist
+api/vendor
+package-lock.json
+docs
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..4b9a2d9
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "printWidth": 120,
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/api/jest.incremental.config.cjs b/api/jest.incremental.config.cjs
new file mode 100644
index 0000000..adb91e9
--- /dev/null
+++ b/api/jest.incremental.config.cjs
@@ -0,0 +1,17 @@
+const base = require('./jest.config.cjs');
+
+module.exports = {
+ ...base,
+ collectCoverageFrom: [
+ 'src/auth/auth.service.ts',
+ 'src/auth/session.service.ts',
+ 'src/common/bounded-json-object.validator.ts',
+ 'src/files/client-files.dto.ts',
+ 'src/open-api/client-open-api.dto.ts',
+ 'src/operations/client-operations.dto.ts',
+ 'src/users/client-user.dto.ts',
+ ],
+ coverageThreshold: {
+ global: { statements: 80, branches: 70, functions: 80, lines: 80 },
+ },
+};
diff --git a/api/package-lock.json b/api/package-lock.json
index 707ed65..f7deaca 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -12,7 +12,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
- "@nestjs/swagger": "^11.2.3",
+ "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0",
"brace-expansion": "file:vendor/brace-expansion-compat",
@@ -67,6 +67,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -591,7 +592,8 @@
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
"integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
"devOptional": true,
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "peer": true
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.1.3",
@@ -616,33 +618,10 @@
"@electric-sql/pglite": "0.4.3"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
+ "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -732,30 +711,6 @@
"node": ">=8"
}
},
- "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
- "version": "3.15.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
- "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -1329,6 +1284,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz",
"integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"file-type": "21.3.4",
"iterare": "1.2.1",
@@ -1375,6 +1331,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz",
"integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-safe-stringify": "2.1.1",
"iterare": "1.2.1",
@@ -1434,6 +1391,7 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz",
"integrity": "sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
@@ -1451,20 +1409,20 @@
}
},
"node_modules/@nestjs/swagger": {
- "version": "11.4.5",
- "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
- "integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
+ "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
- "js-yaml": "4.3.0",
+ "js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
- "swagger-ui-dist": "5.32.8"
+ "swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
- "@fastify/static": "^8.0.0 || ^9.0.0",
+ "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -2177,6 +2135,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -2198,6 +2157,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2345,9 +2305,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2362,9 +2319,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2379,9 +2333,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2396,9 +2347,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2413,9 +2361,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2430,9 +2375,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2447,9 +2389,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2464,9 +2403,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2481,9 +2417,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2498,9 +2431,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2540,6 +2470,40 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
@@ -3263,9 +3227,9 @@
},
"node_modules/brace-expansion-safe": {
"name": "brace-expansion",
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
- "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3300,6 +3264,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
@@ -3649,13 +3614,15 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/class-validator": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz",
"integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -4510,20 +4477,6 @@
"node": ">=8"
}
},
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
- "license": "BSD-2-Clause",
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4742,9 +4695,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
- "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"devOptional": true,
"funding": [
{
@@ -5561,6 +5514,7 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -6170,9 +6124,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -7228,6 +7182,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
@@ -7465,6 +7420,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@prisma/config": "7.9.0",
"@prisma/dev": "0.24.14",
@@ -7740,7 +7696,8 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "peer": true
},
"node_modules/remeda": {
"version": "2.33.4",
@@ -7894,6 +7851,7 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -7973,8 +7931,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
@@ -8208,13 +8165,6 @@
"node": ">= 10.x"
}
},
- "node_modules/sprintf-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -8520,9 +8470,9 @@
}
},
"node_modules/swagger-ui-dist": {
- "version": "5.32.8",
- "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
- "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
+ "version": "5.32.13",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
+ "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -8750,6 +8700,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -8860,6 +8811,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9523,10 +9475,10 @@
"node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
- "version": "5.0.8-compat.1",
+ "version": "5.0.9-compat.1",
"license": "MIT",
"dependencies": {
- "brace-expansion-safe": "npm:brace-expansion@5.0.8"
+ "brace-expansion-safe": "npm:brace-expansion@5.0.9"
}
}
}
diff --git a/api/package.json b/api/package.json
index 5672326..356956d 100644
--- a/api/package.json
+++ b/api/package.json
@@ -7,6 +7,7 @@
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
+ "test:incremental-coverage": "jest --runInBand --coverage --config jest.incremental.config.cjs",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
@@ -19,7 +20,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
- "@nestjs/swagger": "^11.2.3",
+ "@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0",
"bullmq": "^5.79.2",
@@ -47,6 +48,8 @@
"exceljs": {
"uuid": "11.1.1"
},
- "find-my-way": "9.7.0"
+ "find-my-way": "9.7.0",
+ "fast-uri": "3.1.5",
+ "js-yaml": "4.3.1"
}
}
diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts
index 44e246e..e13f7cd 100644
--- a/api/src/auth/auth.controller.ts
+++ b/api/src/auth/auth.controller.ts
@@ -20,19 +20,29 @@ type CookieResponse = {
@ApiTags('auth')
@Controller()
export class AuthController {
- constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
+ constructor(
+ private readonly auth: AuthService,
+ private readonly users: UsersService,
+ private readonly sessions: SessionService,
+ private readonly prisma: PrismaService,
+ private readonly security: SecurityDetectionService,
+ ) {}
@Get('admin/auth/captcha')
- adminCaptcha() {
- return this.auth.createCaptcha();
+ adminCaptcha(@Req() request: SessionRequest) {
+ return this.auth.createCaptcha(this.sourceIp(request));
}
@Post('admin/auth/login')
@UsePipes(strictValidationPipe)
- async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
+ async adminLogin(
+ @Body() body: LoginDto,
+ @Req() request: SessionRequest,
+ @Res({ passthrough: true }) response: CookieResponse,
+ ) {
let result: Awaited>;
try {
- result = await this.auth.login(body, 'admin');
+ result = await this.auth.login(body, 'admin', this.sourceIp(request));
} catch (error) {
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
throw error;
@@ -41,16 +51,20 @@ export class AuthController {
}
@Get('client/auth/captcha')
- clientCaptcha() {
- return this.auth.createCaptcha();
+ clientCaptcha(@Req() request: SessionRequest) {
+ return this.auth.createCaptcha(this.sourceIp(request));
}
@Post('client/auth/login')
@UsePipes(strictValidationPipe)
- async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
+ async clientLogin(
+ @Body() body: LoginDto,
+ @Req() request: SessionRequest,
+ @Res({ passthrough: true }) response: CookieResponse,
+ ) {
let result: Awaited>;
try {
- result = await this.auth.login(body, 'client');
+ result = await this.auth.login(body, 'client', this.sourceIp(request));
} catch (error) {
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
throw error;
@@ -94,17 +108,23 @@ export class AuthController {
async lock(@Req() request: SessionRequest) {
this.assertSession(request);
const record = await this.sessions.lock(request.sessionToken!);
- if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
+ if (record)
+ await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
return { locked: Boolean(record) };
}
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
@UsePipes(strictValidationPipe)
- async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
+ async unlock(
+ @Req() request: SessionRequest,
+ @Body() body: PasswordVerificationDto,
+ @Res({ passthrough: true }) response: CookieResponse,
+ ) {
const { password } = body;
this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
- if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
+ if (result.status !== 'active' || !('token' in result))
+ throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
this.setCookie(response, result.record.portal, result.token);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
@@ -124,7 +144,8 @@ export class AuthController {
@Post(['admin/auth/logout', 'client/auth/logout'])
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
- if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
+ if (request.sessionUserId)
+ await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
this.clearCookie(response, request.authSession?.portal);
return { success: true };
}
@@ -136,17 +157,32 @@ export class AuthController {
return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
}
- private async finishLogin(result: Awaited>, request: SessionRequest, response: CookieResponse) {
+ private async finishLogin(
+ result: Awaited>,
+ request: SessionRequest,
+ response: CookieResponse,
+ ) {
this.setCookie(response, result.portal, result.sessionToken);
this.clearLegacyCookie(response);
await this.prisma.operationLog.create({
- data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
+ data: {
+ userId: result.user.id,
+ tenantId: result.user.tenantId,
+ action: 'auth.session_created',
+ resource: 'auth_session',
+ userAgent: request.header('user-agent'),
+ detail: { portal: result.portal },
+ },
});
const { sessionToken: _, ...publicResult } = result;
return publicResult;
}
- private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
+ private recordLoginFailure(
+ ruleCode: 'admin_login_failure' | 'client_login_failure',
+ account: string,
+ request: SessionRequest,
+ ) {
return this.security.recordEvent({
ruleCode,
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
@@ -157,9 +193,19 @@ export class AuthController {
});
}
+ private sourceIp(request: SessionRequest) {
+ return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
+ }
+
private writeLog(request: SessionRequest, action: string, detail: Record) {
return this.prisma.operationLog.create({
- data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
+ data: {
+ userId: request.sessionUserId,
+ action,
+ resource: 'auth_session',
+ userAgent: request.header('user-agent'),
+ detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue,
+ },
});
}
diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts
index 8074e03..e9574c8 100644
--- a/api/src/auth/auth.service.spec.ts
+++ b/api/src/auth/auth.service.spec.ts
@@ -23,6 +23,7 @@ function createUsersMock(roleCode: string, overrides: Record =
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
+ verifyCurrentPassword: jest.fn(),
};
}
@@ -30,54 +31,154 @@ function createSessionsMock() {
const captchas = new Map();
const failures = new Map();
const record = {
- userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
- lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
+ userId: 'user-1',
+ portal: 'admin',
+ sessionVersion: 0,
+ createdAt: 1,
+ lastActivityAt: 1,
+ lastAuthenticatedAt: 1,
+ absoluteExpiresAt: Date.now() + 1000,
};
return {
- storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
- consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
- isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
- recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
- clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
+ storeCaptcha: jest.fn(async (id: string, answer: string) => {
+ captchas.set(id, answer);
+ }),
+ consumeCaptcha: jest.fn(async (id: string) => {
+ const answer = captchas.get(id) ?? null;
+ captchas.delete(id);
+ return answer;
+ }),
+ assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
+ anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
+ recordAnonymousLoginFailure: jest.fn(async (login: string) => {
+ const count = (failures.get(login) ?? 0) + 1;
+ failures.set(login, count);
+ return [count, count, count];
+ }),
+ clearAnonymousLoginFailures: jest.fn(async (login: string) => {
+ failures.delete(login);
+ }),
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
- publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
+ publicSession: jest
+ .fn()
+ .mockReturnValue({
+ idleTimeoutSeconds: 3600,
+ absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
+ }),
+ unlock: jest.fn().mockResolvedValue({ status: 'active' }),
+ markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
};
}
+function createMetricsMock() {
+ return { recordAuthProtectionResult: jest.fn() };
+}
+
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
- const captcha = await service.createCaptcha();
- const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
- return service.login({
- login: 'user@example.com',
- password,
- captchaId: captcha.captchaId,
- captchaText: String(answer),
- }, portal);
+ const captcha = await service.createCaptcha('203.0.113.10');
+ const answer = captcha.challenge
+ .split('=')[0]
+ .split('+')
+ .map((part) => Number(part.trim()))
+ .reduce((sum, value) => sum + value, 0);
+ return service.login(
+ {
+ login: 'user@example.com',
+ password,
+ captchaId: captcha.captchaId,
+ captchaText: String(answer),
+ },
+ portal,
+ '203.0.113.10',
+ );
}
describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const sessions = createSessionsMock();
- const service = new AuthService(users as never, sessions as never);
- await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
+ const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
+ await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
+ expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
+ );
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
});
it('rejects enterprise admins on admin portal', async () => {
const users = createUsersMock('enterprise_admin');
- const service = new AuthService(users as never, createSessionsMock() as never);
+ const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
});
it('locks user after five failed password attempts', async () => {
const users = createUsersMock('platform_admin');
- const service = new AuthService(users as never, createSessionsMock() as never);
+ const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
for (let index = 0; index < 5; index += 1) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
}
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
});
+
+ it('rejects captcha bursts before allocating a captcha', async () => {
+ const sessions = createSessionsMock();
+ sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
+ const metrics = createMetricsMock();
+ const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
+ await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
+ expect(sessions.storeCaptcha).not.toHaveBeenCalled();
+ expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
+ });
+
+ it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
+ const service = new AuthService(
+ createUsersMock('enterprise_admin') as never,
+ createSessionsMock() as never,
+ createMetricsMock() as never,
+ );
+ await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
+ });
+
+ it('records anonymous failures without disclosing whether an account exists', async () => {
+ const users = createUsersMock('platform_admin');
+ users.findByLogin.mockResolvedValue(null);
+ const sessions = createSessionsMock();
+ const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
+ await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
+ expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
+ });
+
+ it('rejects expired and incorrect one-time captchas', async () => {
+ const sessions = createSessionsMock();
+ const service = new AuthService(
+ createUsersMock('platform_admin') as never,
+ sessions as never,
+ createMetricsMock() as never,
+ );
+ await expect(
+ service.login(
+ { login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
+ 'admin',
+ '203.0.113.10',
+ ),
+ ).rejects.toMatchObject({ status: 400 });
+ await sessions.storeCaptcha('captcha-wrong', '7');
+ await expect(
+ service.login(
+ { login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
+ 'admin',
+ '203.0.113.10',
+ ),
+ ).rejects.toMatchObject({ status: 400 });
+ });
+
+ it('delegates unlock and recent reauthentication to password and session services', async () => {
+ const users = createUsersMock('platform_admin');
+ const sessions = createSessionsMock();
+ const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
+ await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
+ await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
+ expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts
index e5cb802..545b822 100644
--- a/api/src/auth/auth.service.ts
+++ b/api/src/auth/auth.service.ts
@@ -1,16 +1,26 @@
import { randomUUID } from 'node:crypto';
-import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
+import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
+import { MetricsService } from '../metrics/metrics.service';
type LoginPortal = 'admin' | 'client';
@Injectable()
export class AuthService {
- constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
+ constructor(
+ private readonly users: UsersService,
+ private readonly sessions: SessionService,
+ private readonly metrics: MetricsService,
+ ) {}
- async createCaptcha() {
+ async createCaptcha(sourceIp: string) {
+ if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
+ this.metrics.recordAuthProtectionResult('captcha_rejected');
+ throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
+ }
+ this.metrics.recordAuthProtectionResult('captcha_allowed');
const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
@@ -22,43 +32,48 @@ export class AuthService {
};
}
- async login(data: LoginDto, portal: LoginPortal) {
+ async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
const login = data.login?.trim();
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
await this.verifyCaptcha(data.captchaId, data.captchaText);
- await this.assertAnonymousNotLocked(login);
+ await this.assertAnonymousNotLocked(login, sourceIp);
const user = await this.users.findByLogin(login);
if (!user) {
- await this.sessions.recordAnonymousLoginFailure(login);
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
if (user.status !== 'active' || user.deletedAt) {
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
- if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
+ if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
const roleCodes = user.roles.map((item) => item.role.code);
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only platform admins can login to admin portal');
}
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
+ await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
}
await this.users.recordLoginSuccess(user.id);
- await this.sessions.clearAnonymousLoginFailures(login);
+ await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
@@ -98,9 +113,15 @@ export class AuthService {
}
}
- private async assertAnonymousNotLocked(login: string) {
- if (await this.sessions.isAnonymousLoginLocked(login)) {
- throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
+ private async assertAnonymousNotLocked(login: string, sourceIp: string) {
+ const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
+ if (scope) {
+ this.metrics.recordAuthProtectionResult('login_locked', scope);
+ throw new UnauthorizedException(
+ scope === 'account'
+ ? 'User is locked for 24 hours after repeated failures'
+ : 'Too many login attempts from this source, try again later',
+ );
}
}
}
diff --git a/api/src/auth/session.service.spec.ts b/api/src/auth/session.service.spec.ts
index 08aa04f..eb33890 100644
--- a/api/src/auth/session.service.spec.ts
+++ b/api/src/auth/session.service.spec.ts
@@ -1,8 +1,36 @@
const values = new Map();
const redis = {
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
- set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
- del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
+ getdel: jest.fn((key: string) => {
+ const value = values.get(key) ?? null;
+ values.delete(key);
+ return Promise.resolve(value);
+ }),
+ mget: jest.fn((...keys: string[]) => Promise.resolve(keys.map((key) => values.get(key) ?? null))),
+ set: jest.fn((key: string, value: string) => {
+ values.set(key, value);
+ return Promise.resolve('OK');
+ }),
+ del: jest.fn((...keys: string[]) => {
+ keys.forEach((key) => values.delete(key));
+ return Promise.resolve(keys.length);
+ }),
+ eval: jest.fn((_script: string, keyCount: number, ...parts: Array) => {
+ const keys = parts.slice(0, keyCount).map(String);
+ const args = parts.slice(keyCount).map(Number);
+ if (keyCount === 1) {
+ const count = Number(values.get(keys[0]) ?? 0) + 1;
+ values.set(keys[0], String(count));
+ return Promise.resolve(count);
+ }
+ const counts = keys.slice(0, 3).map((key, index) => {
+ const count = Number(values.get(key) ?? 0) + 1;
+ values.set(key, String(count));
+ if (count >= args[index + 3]) values.set(keys[index + 3], '1');
+ return count;
+ });
+ return Promise.resolve(counts);
+ }),
disconnect: jest.fn(),
};
@@ -47,7 +75,9 @@ describe('SessionService', () => {
const service = new SessionService();
const created = await service.create('user-1', 'client', 2);
now += 90 * 60 * 1000;
- await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
+ await expect(service.validate(created.token, false)).resolves.toEqual(
+ expect.objectContaining({ status: 'active' }),
+ );
});
it('requires a full login after a session stays locked for four hours', async () => {
@@ -55,7 +85,10 @@ describe('SessionService', () => {
const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token);
now += 4 * 60 * 60 * 1000 + 1;
- await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
+ await expect(service.validate(created.token, false)).resolves.toEqual({
+ status: 'expired',
+ code: 'SESSION_LOCK_TIMEOUT',
+ });
});
it('rotates the opaque token when a password unlock succeeds', async () => {
@@ -66,8 +99,13 @@ describe('SessionService', () => {
expect(result.status).toBe('active');
if (result.status === 'active' && 'token' in result) {
expect(result.token).not.toBe(created.token);
- await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
- await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
+ await expect(service.validate(created.token, false)).resolves.toEqual({
+ status: 'expired',
+ code: 'SESSION_INVALID',
+ });
+ await expect(service.validate(result.token, false)).resolves.toEqual(
+ expect.objectContaining({ status: 'active' }),
+ );
}
});
@@ -78,4 +116,63 @@ describe('SessionService', () => {
expect(service.cookieName('client')).toBe('cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE;
});
+
+ it('stores and consumes a captcha exactly once', async () => {
+ const service = new SessionService();
+ await service.storeCaptcha('captcha-1', '9', 300);
+ await expect(service.consumeCaptcha('captcha-1')).resolves.toBe('9');
+ await expect(service.consumeCaptcha('captcha-1')).resolves.toBeNull();
+ });
+
+ it('expires an absolute session and supports touch plus recent authentication', async () => {
+ const service = new SessionService();
+ const expired = await service.create('expired-user', 'admin', 1);
+ now += 12 * 60 * 60 * 1000 + 1;
+ await expect(service.validate(expired.token, false)).resolves.toEqual({
+ status: 'expired',
+ code: 'SESSION_ABSOLUTE_TIMEOUT',
+ });
+
+ now = 1_700_000_000_000;
+ const active = await service.create('active-user', 'client', 1);
+ now += 31_000;
+ const touched = await service.touch(active.token);
+ expect(touched.status).toBe('active');
+ const reauthenticated = await service.markReauthenticated(active.token);
+ expect(reauthenticated.status).toBe('active');
+ if (reauthenticated.status === 'active') {
+ expect(service.isRecentlyAuthenticated(reauthenticated.record)).toBe(true);
+ expect(service.publicSession(reauthenticated.record)).toEqual(
+ expect.objectContaining({ idleTimeoutSeconds: 7200 }),
+ );
+ }
+ });
+
+ it('uses host-prefixed cookie names when secure cookies are enabled', () => {
+ process.env.SESSION_COOKIE_SECURE = 'true';
+ const service = new SessionService();
+ expect(service.cookieName('admin')).toBe('__Host-cmpp_admin_session');
+ expect(service.cookieName('client')).toBe('__Host-cmpp_client_session');
+ delete process.env.SESSION_COOKIE_SECURE;
+ });
+
+ it('rate limits captcha allocation by hashed source IP', async () => {
+ const service = new SessionService();
+ for (let index = 0; index < 30; index += 1) {
+ await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(true);
+ }
+ await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(false);
+ expect([...values.keys()].some((key) => key.includes('203.0.113.10'))).toBe(false);
+ });
+
+ it('locks anonymous failures independently by account, IP and account-IP pair', async () => {
+ const service = new SessionService();
+ for (let index = 0; index < 5; index += 1) {
+ await service.recordAnonymousLoginFailure('user@example.com', '203.0.113.10');
+ }
+ await expect(service.anonymousLoginLockScope('user@example.com', '198.51.100.7')).resolves.toBe('account');
+ await expect(service.anonymousLoginLockScope('other@example.com', '203.0.113.10')).resolves.toBeNull();
+ await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
+ await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
+ });
});
diff --git a/api/src/auth/session.service.ts b/api/src/auth/session.service.ts
index 8483bc2..b66c629 100644
--- a/api/src/auth/session.service.ts
+++ b/api/src/auth/session.service.ts
@@ -22,8 +22,13 @@ export type SessionValidationResult =
const SESSION_PREFIX = 'cmpp:auth:session:';
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
+const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
+const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:';
+const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:';
+const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:';
+const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
@@ -142,40 +147,87 @@ export class SessionService implements OnModuleDestroy {
}
}
- async isAnonymousLoginLocked(login: string) {
+ async assertCaptchaRequestAllowed(sourceIp: string) {
+ const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
try {
- return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
+ const count = Number(
+ await this.client.eval(
+ `local count = redis.call('INCR', KEYS[1])
+ if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
+ return count`,
+ 1,
+ key,
+ 5 * 60,
+ ),
+ );
+ return count <= 30;
+ } catch {
+ throw new ServiceUnavailableException('验证码服务暂不可用');
+ }
+ }
+
+ async anonymousLoginLockScope(login: string, sourceIp: string) {
+ const accountDigest = this.loginDigest(login);
+ const ipDigest = this.valueDigest(sourceIp);
+ const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
+ try {
+ const locks = await this.client.mget(
+ `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
+ `${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
+ `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
+ );
+ if (locks[0]) return 'account' as const;
+ if (locks[1]) return 'ip' as const;
+ if (locks[2]) return 'pair' as const;
+ return null;
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
- async recordAnonymousLoginFailure(login: string) {
- const digest = this.loginDigest(login);
- const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
- const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
+ async recordAnonymousLoginFailure(login: string, sourceIp: string) {
+ const accountDigest = this.loginDigest(login);
+ const ipDigest = this.valueDigest(sourceIp);
+ const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
try {
- const count = Number(await this.client.eval(
- `local count = redis.call('INCR', KEYS[1])
- if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
- if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
- return count`,
- 2,
- failureKey,
- lockKey,
+ const result = await this.client.eval(
+ `local counts = {}
+ for i = 1, 3 do
+ counts[i] = redis.call('INCR', KEYS[i])
+ if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end
+ if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end
+ end
+ return counts`,
+ 6,
+ `${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
+ `${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`,
+ `${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
+ `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
+ `${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
+ `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
+ 24 * 60 * 60,
+ 15 * 60,
24 * 60 * 60,
5,
- ));
- return count;
+ 30,
+ 5,
+ );
+ return (result as number[]).map(Number);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
- async clearAnonymousLoginFailures(login: string) {
- const digest = this.loginDigest(login);
+ async clearAnonymousLoginFailures(login: string, sourceIp: string) {
+ const accountDigest = this.loginDigest(login);
+ const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`);
try {
- await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
+ await this.client.del(
+ `${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
+ `${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
+ `${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
+ `${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
+ );
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
@@ -196,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
}
get cookieSecure() {
- return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
+ return (
+ process.env.SESSION_COOKIE_SECURE === 'true' ||
+ (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false')
+ );
}
cookieName(portal: SessionPortal) {
@@ -230,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
private async read(token: string): Promise {
try {
const value = await this.client.get(this.key(token));
- return value ? JSON.parse(value) as AuthSessionRecord : null;
+ return value ? (JSON.parse(value) as AuthSessionRecord) : null;
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
@@ -254,7 +309,11 @@ export class SessionService implements OnModuleDestroy {
}
private loginDigest(login: string) {
- return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
+ return this.valueDigest(login.trim().toLocaleLowerCase('en-US'));
+ }
+
+ private valueDigest(value: string) {
+ return createHash('sha256').update(value.trim()).digest('hex');
}
private get client() {
diff --git a/api/src/common/bounded-json-object.validator.ts b/api/src/common/bounded-json-object.validator.ts
new file mode 100644
index 0000000..fd3279c
--- /dev/null
+++ b/api/src/common/bounded-json-object.validator.ts
@@ -0,0 +1,55 @@
+import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator';
+
+type BoundedJsonOptions = {
+ maxDepth?: number;
+ maxKeys?: number;
+ maxStringLength?: number;
+};
+
+const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+
+export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) {
+ return (target: object, propertyName: string) =>
+ registerDecorator({
+ name: 'isBoundedJsonObject',
+ target: target.constructor,
+ propertyName,
+ constraints: [options],
+ options: validationOptions,
+ validator: {
+ validate(value: unknown, args: ValidationArguments) {
+ if (value === undefined || value === null) return true;
+ const [constraints] = args.constraints as [BoundedJsonOptions];
+ return isBoundedJsonValue(value, {
+ maxDepth: constraints.maxDepth ?? 4,
+ maxKeys: constraints.maxKeys ?? 100,
+ maxStringLength: constraints.maxStringLength ?? 2_000,
+ });
+ },
+ defaultMessage(args: ValidationArguments) {
+ return `${args.property} contains too many, too deeply nested, or unsafe values`;
+ },
+ },
+ });
+}
+
+function isBoundedJsonValue(value: unknown, limits: Required) {
+ let keyCount = 0;
+ const visit = (current: unknown, depth: number): boolean => {
+ if (depth > limits.maxDepth) return false;
+ if (current == null || typeof current === 'boolean' || typeof current === 'number') return true;
+ if (typeof current === 'string') return current.length <= limits.maxStringLength;
+ if (Array.isArray(current)) {
+ keyCount += current.length;
+ return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1));
+ }
+ if (typeof current !== 'object') return false;
+ const entries = Object.entries(current as Record);
+ keyCount += entries.length;
+ return (
+ keyCount <= limits.maxKeys &&
+ entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1))
+ );
+ };
+ return visit(value, 0);
+}
diff --git a/api/src/common/client-write.dto.spec.ts b/api/src/common/client-write.dto.spec.ts
index 9ddcb1e..f6d8bb2 100644
--- a/api/src/common/client-write.dto.spec.ts
+++ b/api/src/common/client-write.dto.spec.ts
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
-import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
+import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe';
function validate(metatype: new () => T, value: unknown) {
@@ -8,10 +8,12 @@ function validate(metatype: new () => T, value: unknown) {
describe('strict client write DTOs', () => {
it('accepts an import confirmation without a client-supplied phones array', async () => {
- await expect(validate(ClientImportConfirmDto, {
- content: '【测试】验证码 ${code}',
- importContent: 'phone,code\n13800000001,1234',
- })).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
+ await expect(
+ validate(ClientImportConfirmDto, {
+ content: '【测试】验证码 ${code}',
+ importContent: 'phone,code\n13800000001,1234',
+ }),
+ ).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
});
it('rejects a direct batch task without validated phone numbers', async () => {
@@ -19,7 +21,28 @@ describe('strict client write DTOs', () => {
});
it('rejects a client-supplied operator identity', async () => {
- await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
- .rejects.toBeInstanceOf(BadRequestException);
+ await expect(
+ validate(ClientDeleteResourceDto, { status: 'deleted', operatorId: 'another-user' }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('rejects a client-supplied tenant identity', async () => {
+ await expect(
+ validate(ClientBatchTaskDto, {
+ tenantId: 'other-tenant',
+ content: '【测试】通知',
+ phones: ['13800000001'],
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('rejects deeply nested or prototype-like dynamic values', async () => {
+ await expect(
+ validate(ClientBatchTaskDto, {
+ content: '【测试】通知',
+ phones: ['13800000001'],
+ variables: { safe: { nested: { too: { deep: { value: 'x' } } } } },
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
});
});
diff --git a/api/src/common/client-write.dto.ts b/api/src/common/client-write.dto.ts
index 8efd199..e49abc2 100644
--- a/api/src/common/client-write.dto.ts
+++ b/api/src/common/client-write.dto.ts
@@ -10,6 +10,7 @@ import {
IsOptional,
IsString,
IsUrl,
+ IsDateString,
Matches,
Max,
MaxLength,
@@ -17,26 +18,25 @@ import {
MinLength,
ValidateNested,
} from 'class-validator';
+import { IsBoundedJsonObject } from './bounded-json-object.validator';
export class ClientCertificationSubmissionDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
- @IsOptional() @IsObject() materials?: Record;
+ @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) materials?: Record;
}
export class ClientTaskBaseDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
- @IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
- @IsOptional() @IsObject() variables?: Record;
- @IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
+ @IsOptional() @IsDateString({ strict: true }) scheduledAt?: string;
+ @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record;
+ @IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
}
@@ -45,7 +45,6 @@ export class ClientBatchTaskDto extends ClientTaskBaseDto {
}
export class ClientImportPreviewDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
@@ -60,7 +59,6 @@ export class ClientImportConfirmDto extends ClientTaskBaseDto {
}
export class ClientBillingEstimateDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
@@ -69,7 +67,6 @@ export class ClientBillingEstimateDto {
}
export class ClientSmsApplicationDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) scene?: string;
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
@@ -93,11 +90,10 @@ export class ClientSmsApplicationDto {
}
export class ClientSmsSignatureDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
- @IsOptional() @IsObject() drainageInfo?: Record;
+ @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record;
}
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
@@ -108,7 +104,7 @@ export class ClientDrainageInfoDto {
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
- @IsOptional() @IsObject() reportValues?: Record;
+ @IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record;
}
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
@@ -127,13 +123,17 @@ class TemplateVariableDto {
}
export class ClientSmsTemplateDto {
- @IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MaxLength(64) applicationId!: string;
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
- @IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
+ @IsOptional()
+ @IsArray()
+ @ArrayMaxSize(100)
+ @ValidateNested({ each: true })
+ @Type(() => TemplateVariableDto)
+ variables?: TemplateVariableDto[];
}
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
@@ -152,3 +152,32 @@ export class ClientStatusChangeDto {
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
}
+
+export class ClientSecretResetDto {
+ @IsOptional() @IsString() @MaxLength(1000) reason?: string;
+ @IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
+ @IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
+}
+
+export class ClientApplicationStatusDto {
+ @IsOptional() @IsIn(['active', 'disabled', 'disabling', 'deleted']) status?: string;
+ @IsOptional() @IsString() @MaxLength(1000) reason?: string;
+ @IsOptional() @IsBoolean() force?: boolean;
+ @IsOptional() @IsString() @MaxLength(200) confirmName?: string;
+ @IsOptional() @IsString() @MaxLength(200) confirmText?: string;
+ @IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
+ @IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
+}
+
+export class ClientDeleteResourceDto {
+ @IsIn(['deleted']) status!: 'deleted';
+ @IsOptional() @IsString() @MaxLength(1000) reason?: string;
+ @IsOptional() @IsBoolean() force?: boolean;
+ @IsOptional() @IsString() @MaxLength(200) confirmName?: string;
+ @IsOptional() @IsString() @MaxLength(200) confirmText?: string;
+ @IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
+ @IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
+ @IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
+ @IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
+ @IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
+}
diff --git a/api/src/files/client-files.controller.ts b/api/src/files/client-files.controller.ts
index 5d2471a..e4bc28c 100644
--- a/api/src/files/client-files.controller.ts
+++ b/api/src/files/client-files.controller.ts
@@ -1,8 +1,22 @@
-import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Get,
+ Param,
+ Post,
+ Query,
+ Res,
+ UploadedFile,
+ UseInterceptors,
+ UsePipes,
+} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientFileUploadDto } from './client-files.dto';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@@ -14,14 +28,14 @@ export class ClientFilesController {
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
+ @UsePipes(strictValidationPipe)
upload(
@CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile,
- @Body('purpose') purpose: string,
- @Body('prefix') prefix?: string,
+ @Body() body: ClientFileUploadDto,
) {
if (!file) throw new BadRequestException('Upload file is required');
- return this.files.uploadForClient(userId, { purpose, prefix }, file);
+ return this.files.uploadForClient(userId, body, file);
}
@Get(':id/download')
diff --git a/api/src/files/client-files.dto.spec.ts b/api/src/files/client-files.dto.spec.ts
new file mode 100644
index 0000000..3a70d72
--- /dev/null
+++ b/api/src/files/client-files.dto.spec.ts
@@ -0,0 +1,28 @@
+import { BadRequestException } from '@nestjs/common';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientFileUploadDto } from './client-files.dto';
+
+describe('ClientFileUploadDto', () => {
+ it('accepts bounded client material paths and rejects traversal before storage', async () => {
+ await expect(
+ strictValidationPipe.transform(
+ { purpose: 'drainage_report_material', prefix: 'drainage-materials/item-1' },
+ {
+ type: 'body',
+ metatype: ClientFileUploadDto,
+ data: undefined,
+ },
+ ),
+ ).resolves.toEqual(expect.objectContaining({ purpose: 'drainage_report_material' }));
+ await expect(
+ strictValidationPipe.transform(
+ { purpose: 'enterprise_certification', prefix: '../admin' },
+ {
+ type: 'body',
+ metatype: ClientFileUploadDto,
+ data: undefined,
+ },
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
diff --git a/api/src/files/client-files.dto.ts b/api/src/files/client-files.dto.ts
new file mode 100644
index 0000000..bd3068d
--- /dev/null
+++ b/api/src/files/client-files.dto.ts
@@ -0,0 +1,13 @@
+import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
+
+export class ClientFileUploadDto {
+ @IsString()
+ @IsIn(['enterprise_certification', 'signature_report_material', 'drainage_report_material'])
+ purpose!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(256)
+ @Matches(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/)
+ prefix?: string;
+}
diff --git a/api/src/metrics/metrics.service.ts b/api/src/metrics/metrics.service.ts
index 9ad406e..fd3a510 100644
--- a/api/src/metrics/metrics.service.ts
+++ b/api/src/metrics/metrics.service.ts
@@ -54,7 +54,9 @@ function escapeLabel(value: string) {
function metricLine(name: string, value: number, labels?: Record) {
const suffix = labels
- ? `{${Object.entries(labels).map(([key, item]) => `${key}="${escapeLabel(item)}"`).join(',')}}`
+ ? `{${Object.entries(labels)
+ .map(([key, item]) => `${key}="${escapeLabel(item)}"`)
+ .join(',')}}`
: '';
return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`;
}
@@ -78,6 +80,7 @@ export class MetricsService implements OnModuleDestroy {
private inboundWorkflowConfiguredSlots = 0;
private inboundWorkflowInFlightSlots = 0;
private readonly inboundWorkflowResults = new Map();
+ private readonly authProtectionResults = new Map();
constructor() {
this.eventLoopDelay.enable();
@@ -176,6 +179,14 @@ export class MetricsService implements OnModuleDestroy {
this.inboundWorkflowResults.set(result, (this.inboundWorkflowResults.get(result) ?? 0) + 1);
}
+ recordAuthProtectionResult(
+ event: 'captcha_allowed' | 'captcha_rejected' | 'login_locked',
+ scope: 'none' | 'account' | 'ip' | 'pair' = 'none',
+ ) {
+ const key = `${event}\u0000${scope}`;
+ this.authProtectionResults.set(key, (this.authProtectionResults.get(key) ?? 0) + 1);
+ }
+
render() {
const memory = process.memoryUsage();
const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000;
@@ -194,7 +205,10 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_api_nodejs_heap_total_bytes', memory.heapTotal),
'# HELP cmpp_api_nodejs_event_loop_lag_p99_seconds Event loop delay p99 since the previous scrape.',
'# TYPE cmpp_api_nodejs_event_loop_lag_p99_seconds gauge',
- metricLine('cmpp_api_nodejs_event_loop_lag_p99_seconds', this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0),
+ metricLine(
+ 'cmpp_api_nodejs_event_loop_lag_p99_seconds',
+ this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0,
+ ),
'# HELP cmpp_api_http_requests_in_flight Current API requests in flight.',
'# TYPE cmpp_api_http_requests_in_flight gauge',
metricLine('cmpp_api_http_requests_in_flight', this.inFlight),
@@ -226,16 +240,26 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowInFlightSlots, { state: 'in_flight' }),
'# HELP cmpp_worker_inbound_workflow_oldest_pending_age_seconds Age of the oldest pending durable workflow.',
'# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge',
- metricLine('cmpp_worker_inbound_workflow_oldest_pending_age_seconds', this.inboundWorkflowOldestPendingAgeSeconds),
+ metricLine(
+ 'cmpp_worker_inbound_workflow_oldest_pending_age_seconds',
+ this.inboundWorkflowOldestPendingAgeSeconds,
+ ),
'# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.',
'# TYPE cmpp_worker_inbound_workflow_results_total counter',
+ '# HELP cmpp_api_auth_protection_events_total Authentication protection outcomes by bounded event and scope.',
+ '# TYPE cmpp_api_auth_protection_events_total counter',
];
for (const [key, metric] of this.http) {
const [method, route, status] = key.split('\u0000');
const labels = { method, route, status };
lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels));
HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
- lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
+ lines.push(
+ metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], {
+ ...labels,
+ le: String(bucket),
+ }),
+ );
});
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_api_http_request_duration_seconds_sum', metric.durationSum, labels));
@@ -245,9 +269,16 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000');
const labels = { stage, result };
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
- lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
+ lines.push(
+ metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], {
+ ...labels,
+ le: String(bucket),
+ }),
+ );
});
- lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
+ lines.push(
+ metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }),
+ );
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels));
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels));
}
@@ -255,7 +286,12 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000');
const labels = { stage, result };
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
- lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
+ lines.push(
+ metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], {
+ ...labels,
+ le: String(bucket),
+ }),
+ );
});
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels));
@@ -273,6 +309,10 @@ export class MetricsService implements OnModuleDestroy {
for (const [result, count] of this.inboundWorkflowResults) {
lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result }));
}
+ for (const [key, count] of this.authProtectionResults) {
+ const [event, scope] = key.split('\u0000');
+ lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope }));
+ }
this.eventLoopDelay.reset();
return `${lines.join('\n')}\n`;
}
diff --git a/api/src/open-api/client-open-api.controller.ts b/api/src/open-api/client-open-api.controller.ts
index 0c0e9db..e6a36f9 100644
--- a/api/src/open-api/client-open-api.controller.ts
+++ b/api/src/open-api/client-open-api.controller.ts
@@ -1,22 +1,66 @@
-import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
+import { Body, Controller, Get, Param, ParseEnumPipe, Post, Put, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { OpenApiService } from './open-api.service';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientHttpCredentialDto, ClientWebhookDto, ClientWebhookEventType } from './client-open-api.dto';
@ApiTags('client-http-open-api-management')
@Controller('client/applications/:applicationId/http-api')
export class ClientOpenApiController {
constructor(private readonly service: OpenApiService) {}
- @Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
- @Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); }
- @Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); }
- @Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
- @Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
- @Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
- @Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); }
- @Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
- @Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
+ @Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
+ return this.service.getConfig(applicationId, tenantId);
+ }
+ @Get('credentials') listCredentials(
+ @Param('applicationId') applicationId: string,
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.service.listCredentials(applicationId, tenantId);
+ }
+ @Post('credentials') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) createCredential(
+ @Param('applicationId') applicationId: string,
+ @Body() body: ClientHttpCredentialDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() createdById?: string,
+ ) {
+ return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true);
+ }
+ @Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(
+ @Param('applicationId') applicationId: string,
+ @Param('credentialId') credentialId: string,
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.service.revokeCredential(applicationId, credentialId, tenantId);
+ }
+ @Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
+ return this.service.getWebhookEndpoints(applicationId, tenantId);
+ }
+ @Put('webhooks/:eventType') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) upsertWebhook(
+ @Param('applicationId') applicationId: string,
+ @Param('eventType', new ParseEnumPipe(ClientWebhookEventType)) eventType: ClientWebhookEventType,
+ @Body() body: ClientWebhookDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId);
+ }
+ @Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
+ return this.service.listRequestLogs(applicationId, tenantId);
+ }
+ @Get('webhook-deliveries') listDeliveries(
+ @Param('applicationId') applicationId: string,
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.service.listWebhookDeliveries(applicationId, tenantId);
+ }
+ @Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(
+ @Param('applicationId') applicationId: string,
+ @Param('deliveryId') deliveryId: string,
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId);
+ }
}
diff --git a/api/src/open-api/client-open-api.dto.spec.ts b/api/src/open-api/client-open-api.dto.spec.ts
new file mode 100644
index 0000000..6a8ff29
--- /dev/null
+++ b/api/src/open-api/client-open-api.dto.spec.ts
@@ -0,0 +1,28 @@
+import { BadRequestException } from '@nestjs/common';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientHttpCredentialDto, ClientWebhookDto } from './client-open-api.dto';
+
+function validate(metatype: new () => T, value: unknown) {
+ return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
+}
+
+describe('client HTTP API DTOs', () => {
+ it('rejects client-supplied operator identity and malformed expiry dates', async () => {
+ await expect(validate(ClientHttpCredentialDto, { name: '凭据', createdById: 'spoofed' })).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ await expect(validate(ClientHttpCredentialDto, { expiresAt: 'tomorrow' })).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+
+ it('accepts a blank webhook URL for deletion and rejects unsafe fields', async () => {
+ await expect(validate(ClientWebhookDto, { url: ' ' })).resolves.toEqual(expect.objectContaining({ url: '' }));
+ await expect(validate(ClientWebhookDto, { url: 'javascript:alert(1)' })).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ await expect(
+ validate(ClientWebhookDto, { url: 'https://example.com/hook', status: 'approved' }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
diff --git a/api/src/open-api/client-open-api.dto.ts b/api/src/open-api/client-open-api.dto.ts
new file mode 100644
index 0000000..1a0341e
--- /dev/null
+++ b/api/src/open-api/client-open-api.dto.ts
@@ -0,0 +1,35 @@
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
+
+export enum ClientWebhookEventType {
+ Receipt = 'receipt',
+ Uplink = 'uplink',
+}
+
+export class ClientHttpCredentialDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(100)
+ name?: string;
+
+ @IsOptional()
+ @IsDateString({ strict: true })
+ expiresAt?: string;
+}
+
+export class ClientWebhookDto {
+ @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+ @IsString()
+ @MaxLength(2048)
+ @ValidateIf(({ url }) => url !== '')
+ @IsUrl({ require_protocol: true, require_tld: false, protocols: ['http', 'https'] })
+ url!: string;
+
+ @IsOptional()
+ @IsBoolean()
+ rotateSecret?: boolean;
+
+ @IsOptional()
+ @IsIn(['active', 'inactive'])
+ status?: 'active' | 'inactive';
+}
diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts
index 6c472cb..3c0a6c9 100644
--- a/api/src/open-api/open-api.service.spec.ts
+++ b/api/src/open-api/open-api.service.spec.ts
@@ -3,7 +3,9 @@ import { decryptSecret, encryptSecret } from './open-api.crypto';
import { OpenApiService } from './open-api.service';
describe('OpenApiService', () => {
- beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; });
+ beforeAll(() => {
+ process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters';
+ });
it('encrypts secrets with authenticated encryption', () => {
const encrypted = encryptSecret('customer-secret');
@@ -15,11 +17,15 @@ describe('OpenApiService', () => {
const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
const prisma = {
- smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
+ smsApplication: {
+ findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
+ },
};
try {
const service = new OpenApiService(prisma as never, {} as never);
- await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }));
+ await expect(service.getConfig('app-1')).resolves.toEqual(
+ expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }),
+ );
} finally {
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
@@ -32,13 +38,17 @@ describe('OpenApiService', () => {
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
const prisma = {
- smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
+ smsApplication: {
+ findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
+ },
};
try {
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true';
- await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }));
+ await expect(service.getConfig('app-1')).resolves.toEqual(
+ expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }),
+ );
} finally {
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
@@ -49,25 +59,62 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
- openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
+ openApiRequest: {
+ findUnique: jest
+ .fn()
+ .mockResolvedValue({
+ bodyHash: 'same',
+ status: 'completed',
+ responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
+ }),
+ },
};
const sendChain = { createHttpBatchTask: jest.fn() };
const service = new OpenApiService(prisma as never, sendChain as never);
- const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' });
+ const result = await service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '【测试】短信' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'same' },
+ );
expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' });
expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled();
});
it('rejects reuse of an idempotency key with a different body', async () => {
- const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } };
+ const prisma = {
+ openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) },
+ };
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
- await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException);
+ await expect(
+ service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '【测试】短信' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'new' },
+ ),
+ ).rejects.toBeInstanceOf(ConflictException);
});
it('replays the same persisted business rejection', async () => {
- const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } };
+ const prisma = {
+ openApiRequest: {
+ findUnique: jest
+ .fn()
+ .mockResolvedValue({
+ bodyHash: 'same',
+ status: 'failed',
+ httpStatus: 422,
+ responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
+ }),
+ },
+ };
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
- await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 });
+ await expect(
+ service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '【测试】短信' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'same' },
+ ),
+ ).rejects.toMatchObject({ status: 422 });
});
it('uses the real send chain and persists the accepted response', async () => {
@@ -79,47 +126,107 @@ describe('OpenApiService', () => {
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
- const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) };
+ const sendChain = {
+ createHttpBatchTask: jest
+ .fn()
+ .mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
+ };
const service = new OpenApiService(prisma as never, sendChain as never);
- const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' });
- expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }));
- expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }));
- expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) }));
+ const result = await service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
+ );
+ expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
+ expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
+ );
+ expect(result).toEqual(
+ expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
+ );
+ expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
+ }),
+ );
});
it('persists a 422 result when the real send chain rejects the business request', async () => {
const prisma = {
- openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) },
+ openApiRequest: {
+ findUnique: jest.fn().mockResolvedValue(null),
+ create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
+ update: jest.fn().mockResolvedValue({}),
+ },
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
- const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never);
- await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 });
- expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) }));
+ const service = new OpenApiService(
+ prisma as never,
+ { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never,
+ );
+ await expect(
+ service.sendMessage(
+ auth() as never,
+ { mobile: '18821203795', content: '未匹配模板' },
+ { idempotencyKey: 'idem-0001', bodyHash: 'hash' },
+ ),
+ ).rejects.toMatchObject({ status: 422 });
+ expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }),
+ }),
+ );
});
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = {
- smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
+ smsApplication: {
+ findUnique: jest
+ .fn()
+ .mockResolvedValue({
+ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
+ }),
+ },
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
};
const service = new OpenApiService(prisma as never, {} as never);
- const input = { tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt' as const, messageRecordId: 'record-1', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } };
+ const input = {
+ tenantId: 'tenant-1',
+ applicationId: 'app-1',
+ eventType: 'receipt' as const,
+ messageRecordId: 'record-1',
+ messageId: 'MSG-1',
+ payload: { receiptStatus: 'delivered' },
+ };
await service.queueWebhookEvent(input);
await service.queueWebhookEvent(input);
- expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({
- where: { eventId: 'evt_receipt_record-1' },
- }));
+ expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { eventId: 'evt_receipt_record-1' },
+ }),
+ );
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
- expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(expect.objectContaining({
- create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
- }));
+ expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
+ }),
+ );
});
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = {
- smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) },
+ smsApplication: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue({
+ id: 'app-1',
+ name: '应用A',
+ interfaceEnabled: true,
+ httpConfig: null,
+ httpIpAllowlist: [],
+ }),
+ },
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
$transaction: jest.fn((operations) => Promise.all(operations)),
@@ -128,19 +235,21 @@ describe('OpenApiService', () => {
await service.updateConfig('app-1', { enabled: true });
- expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
- create: expect.objectContaining({
- enabled: true,
- sendEnabled: true,
- messageQueryEnabled: true,
- receiptWebhookEnabled: true,
- uplinkWebhookEnabled: true,
- uplinkQueryEnabled: true,
- credentialSelfServiceEnabled: true,
- receiptDeliveryMode: 'both',
- uplinkDeliveryMode: 'both',
+ expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ create: expect.objectContaining({
+ enabled: true,
+ sendEnabled: true,
+ messageQueryEnabled: true,
+ receiptWebhookEnabled: true,
+ uplinkWebhookEnabled: true,
+ uplinkQueryEnabled: true,
+ credentialSelfServiceEnabled: true,
+ receiptDeliveryMode: 'both',
+ uplinkDeliveryMode: 'both',
+ }),
}),
- }));
+ );
});
it('removes a webhook endpoint when an operator saves a blank address', async () => {
@@ -160,12 +269,35 @@ describe('OpenApiService', () => {
};
const service = new OpenApiService(prisma as never, {} as never);
- await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' }))
- .resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }));
+ await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })).resolves.toEqual(
+ expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }),
+ );
expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({
where: { applicationId: 'app-1', eventType: 'receipt' },
});
});
+
+ it('rejects an already expired credential before writing a secret', async () => {
+ const prisma = {
+ smsApplication: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue({
+ id: 'app-1',
+ name: '应用A',
+ interfaceEnabled: true,
+ httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
+ httpIpAllowlist: [],
+ }),
+ },
+ httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
+ };
+ const service = new OpenApiService(prisma as never, {} as never);
+ await expect(
+ service.createCredential('app-1', { expiresAt: '2020-01-01T00:00:00.000Z' }, 'tenant-1', true),
+ ).rejects.toThrow('凭据过期时间必须晚于当前时间');
+ expect(prisma.httpApiCredential.create).not.toHaveBeenCalled();
+ });
});
function auth() {
diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts
index d76be11..bcd2c52 100644
--- a/api/src/open-api/open-api.service.ts
+++ b/api/src/open-api/open-api.service.ts
@@ -1,4 +1,17 @@
-import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, UnprocessableEntityException } from '@nestjs/common';
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ forwardRef,
+ HttpException,
+ Inject,
+ Injectable,
+ NotFoundException,
+ OnModuleDestroy,
+ OnModuleInit,
+ Optional,
+ UnprocessableEntityException,
+} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
@@ -59,7 +72,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
- this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
+ this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
+ connection,
+ concurrency: 10,
+ });
}
async onModuleDestroy() {
@@ -89,7 +105,13 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
update: data,
}),
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
- ...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []),
+ ...(ipAllowlist.length > 0
+ ? [
+ this.prisma.smsApplicationHttpIpAllowlist.createMany({
+ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })),
+ }),
+ ]
+ : []),
]);
return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist };
}
@@ -98,37 +120,69 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId);
return this.prisma.httpApiCredential.findMany({
where: { applicationId },
- select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, lastUsedAt: true, lastUsedIp: true, createdAt: true, revokedAt: true },
+ select: {
+ id: true,
+ name: true,
+ accessKey: true,
+ secretLast4: true,
+ status: true,
+ expiresAt: true,
+ lastUsedAt: true,
+ lastUsedIp: true,
+ createdAt: true,
+ revokedAt: true,
+ },
orderBy: { createdAt: 'desc' },
});
}
- async createCredential(applicationId: string, data: { name?: string; expiresAt?: string; createdById?: string }, tenantId?: string, selfService = false) {
+ async createCredential(
+ applicationId: string,
+ data: { name?: string; expiresAt?: string; createdById?: string },
+ tenantId?: string,
+ selfService = false,
+ ) {
const application = await this.requireApplication(applicationId, tenantId);
const config = application.httpConfig;
if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口');
- if (selfService && !config.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
+ if (selfService && !config.credentialSelfServiceEnabled)
+ throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } });
- if (activeCount >= config.maxCredentialCount) throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount} 个`);
+ if (activeCount >= config.maxCredentialCount)
+ throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount} 个`);
+ const expiresAt = data.expiresAt ? new Date(data.expiresAt) : undefined;
+ if (expiresAt && expiresAt.getTime() <= Date.now()) throw new BadRequestException('凭据过期时间必须晚于当前时间');
const secret = randomBytes(32).toString('base64url');
const credential = await this.prisma.httpApiCredential.create({
data: {
applicationId,
- name: String(data.name ?? '默认凭据').trim().slice(0, 100) || '默认凭据',
+ name:
+ String(data.name ?? '默认凭据')
+ .trim()
+ .slice(0, 100) || '默认凭据',
accessKey: `ak_${randomBytes(18).toString('base64url')}`,
secretEncrypted: encryptSecret(secret),
secretLast4: secret.slice(-4),
- expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
+ expiresAt,
createdById: data.createdById,
},
- select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, createdAt: true },
+ select: {
+ id: true,
+ name: true,
+ accessKey: true,
+ secretLast4: true,
+ status: true,
+ expiresAt: true,
+ createdAt: true,
+ },
});
return { ...credential, secret, secretShownOnce: true };
}
async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId);
- if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
+ if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled)
+ throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const result = await this.prisma.httpApiCredential.updateMany({
where: { id: credentialId, applicationId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
@@ -141,14 +195,29 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId);
return this.prisma.httpWebhookEndpoint.findMany({
where: { applicationId },
- select: { id: true, eventType: true, url: true, secretLast4: true, status: true, lastTestAt: true, lastTestStatus: true, updatedAt: true },
+ select: {
+ id: true,
+ eventType: true,
+ url: true,
+ secretLast4: true,
+ status: true,
+ lastTestAt: true,
+ lastTestStatus: true,
+ updatedAt: true,
+ },
orderBy: { eventType: 'asc' },
});
}
- async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) {
+ async upsertWebhookEndpoint(
+ applicationId: string,
+ eventType: string,
+ data: { url: string; rotateSecret?: boolean; status?: string },
+ tenantId?: string,
+ ) {
const application = await this.requireApplication(applicationId, tenantId);
- if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink');
+ if (!['receipt', 'uplink'].includes(eventType))
+ throw new BadRequestException('eventType only supports receipt or uplink');
if (!String(data.url ?? '').trim()) {
await this.prisma.httpWebhookEndpoint.deleteMany({ where: { applicationId, eventType } });
return {
@@ -162,49 +231,103 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
};
}
const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true);
- const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } });
+ const existing = await this.prisma.httpWebhookEndpoint.findUnique({
+ where: { applicationId_eventType: { applicationId, eventType } },
+ });
const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined;
const endpoint = await this.prisma.httpWebhookEndpoint.upsert({
where: { applicationId_eventType: { applicationId, eventType } },
- create: { applicationId, eventType, url, status: data.status ?? 'active', secretEncrypted: encryptSecret(secret!), secretLast4: secret!.slice(-4) },
- update: { url, status: data.status ?? existing?.status ?? 'active', ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}) },
+ create: {
+ applicationId,
+ eventType,
+ url,
+ status: data.status ?? 'active',
+ secretEncrypted: encryptSecret(secret!),
+ secretLast4: secret!.slice(-4),
+ },
+ update: {
+ url,
+ status: data.status ?? existing?.status ?? 'active',
+ ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}),
+ },
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true },
});
return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) };
}
- async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) {
- if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
+ async sendMessage(
+ auth: OpenApiAuthContext,
+ input: { mobile?: string; content?: string; clientMessageId?: string },
+ meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string },
+ ) {
+ if (!auth.config.sendEnabled)
+ throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim();
const content = String(input.content ?? '');
- if (!/^1[3-9]\d{9}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
+ if (!/^1[3-9]\d{9}$/.test(mobile))
+ throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
- if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'Idempotency-Key 必填且长度为8至128位' });
- const existing = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
+ if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
+ throw new BadRequestException({
+ code: 'IDEMPOTENCY_KEY_INVALID',
+ message: 'Idempotency-Key 必填且长度为8至128位',
+ });
+ const existing = await this.prisma.openApiRequest.findUnique({
+ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
+ });
if (existing) {
- if (existing.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
+ if (existing.bodyHash !== meta.bodyHash)
+ throw new ConflictException({
+ code: 'IDEMPOTENCY_CONFLICT',
+ message: '同一Idempotency-Key对应的请求内容不一致',
+ });
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
- if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record, existing.httpStatus);
+ if (existing.status === 'failed' && existing.responseBody && existing.httpStatus)
+ throw new HttpException(existing.responseBody as Record, existing.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
if (input.clientMessageId) {
- const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId }, select: { messageId: true } });
- if (duplicateClientMessage) throw new ConflictException({ code: 'CLIENT_MESSAGE_ID_CONFLICT', message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}` });
+ const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({
+ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId },
+ select: { messageId: true },
+ });
+ if (duplicateClientMessage)
+ throw new ConflictException({
+ code: 'CLIENT_MESSAGE_ID_CONFLICT',
+ message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}`,
+ });
}
const requestId = `req_${randomUUID()}`;
const startedAt = Date.now();
let request;
try {
request = await this.prisma.openApiRequest.create({
- data: { tenantId: auth.application.tenantId, applicationId: auth.application.id, credentialId: auth.credentialId, requestId, idempotencyKey, bodyHash: meta.bodyHash, clientMessageId: input.clientMessageId, sourceIp: auth.sourceIp, userAgent: meta.userAgent },
+ data: {
+ tenantId: auth.application.tenantId,
+ applicationId: auth.application.id,
+ credentialId: auth.credentialId,
+ requestId,
+ idempotencyKey,
+ bodyHash: meta.bodyHash,
+ clientMessageId: input.clientMessageId,
+ sourceIp: auth.sourceIp,
+ userAgent: meta.userAgent,
+ },
});
} catch (error) {
if ((error as { code?: string }).code === 'P2002') {
- const raced = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
- if (raced?.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
+ const raced = await this.prisma.openApiRequest.findUnique({
+ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
+ });
+ if (raced?.bodyHash !== meta.bodyHash)
+ throw new ConflictException({
+ code: 'IDEMPOTENCY_CONFLICT',
+ message: '同一Idempotency-Key对应的请求内容不一致',
+ });
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
- if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record, raced.httpStatus);
+ if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus)
+ throw new HttpException(raced.responseBody as Record, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
throw error;
@@ -221,10 +344,31 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
});
const message = task.messages?.[0];
if (task.status === 'rejected' || message?.status === 'rejected') {
- throw new UnprocessableEntityException({ code: 'SEND_REJECTED', message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验' });
+ throw new UnprocessableEntityException({
+ code: 'SEND_REJECTED',
+ message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
+ });
}
- const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() };
- await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } });
+ const response = {
+ code: 'ACCEPTED',
+ requestId,
+ messageId: message?.messageId,
+ clientMessageId: input.clientMessageId ?? null,
+ status: message?.status ?? task.status,
+ acceptedAt: new Date().toISOString(),
+ };
+ await this.prisma.openApiRequest.update({
+ where: { id: request.id },
+ data: {
+ status: 'completed',
+ httpStatus: 202,
+ businessCode: 'ACCEPTED',
+ responseBody: response,
+ messageRecordId: message?.id,
+ durationMs: Date.now() - startedAt,
+ completedAt: new Date(),
+ },
+ });
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
@@ -245,11 +389,24 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse();
- const message = typeof response === 'object' && response && 'message' in response ? (response as { message: unknown }).message : error.message;
+ const message =
+ typeof response === 'object' && response && 'message' in response
+ ? (response as { message: unknown }).message
+ : error.message;
outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message });
}
const failure = normalizeOpenApiFailure(outwardError);
- await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } });
+ await this.prisma.openApiRequest.update({
+ where: { id: request.id },
+ data: {
+ status: 'failed',
+ httpStatus: failure.httpStatus,
+ businessCode: failure.code,
+ responseBody: failure.responseBody,
+ durationMs: Date.now() - startedAt,
+ completedAt: new Date(),
+ },
+ });
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
@@ -268,21 +425,41 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async getMessage(auth: OpenApiAuthContext, messageId: string) {
- if (!auth.config.messageQueryEnabled) throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
+ if (!auth.config.messageQueryEnabled)
+ throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
const message = await this.prisma.smsMessageRecord.findFirst({
where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] },
- select: { messageId: true, clientMessageId: true, phoneNumber: true, status: true, submitStatus: true, receiptStatus: true, errorCode: true, errorMessage: true, queuedAt: true, submittedAt: true, deliveredAt: true, updatedAt: true },
+ select: {
+ messageId: true,
+ clientMessageId: true,
+ phoneNumber: true,
+ status: true,
+ submitStatus: true,
+ receiptStatus: true,
+ errorCode: true,
+ errorMessage: true,
+ queuedAt: true,
+ submittedAt: true,
+ deliveredAt: true,
+ updatedAt: true,
+ },
});
if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' });
return message;
}
async listUplinks(auth: OpenApiAuthContext, query: Record) {
- if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
+ if (!auth.config.uplinkQueryEnabled)
+ throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
- if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
- if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) throw new BadRequestException({ code: 'TIME_RANGE_TOO_LARGE', message: `单次查询不能超过${auth.config.maxQueryRangeDays}天` });
+ if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
+ throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
+ if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000)
+ throw new BadRequestException({
+ code: 'TIME_RANGE_TOO_LARGE',
+ message: `单次查询不能超过${auth.config.maxQueryRangeDays}天`,
+ });
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({
@@ -293,9 +470,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: query.mobile,
destId: query.accessNumber,
content: query.keyword ? { contains: query.keyword } : undefined,
- ...(cursor ? { OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }] } : {}),
+ ...(cursor
+ ? {
+ OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }],
+ }
+ : {}),
+ },
+ select: {
+ id: true,
+ messageId: true,
+ phoneNumber: true,
+ destId: true,
+ content: true,
+ matchStatus: true,
+ matchReason: true,
+ receivedAt: true,
},
- select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, matchStatus: true, matchReason: true, receivedAt: true },
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
});
@@ -306,29 +496,55 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async getUplink(auth: OpenApiAuthContext, uplinkId: string) {
- if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
- const row = await this.prisma.smsUplinkMessage.findFirst({ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' } });
+ if (!auth.config.uplinkQueryEnabled)
+ throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
+ const row = await this.prisma.smsUplinkMessage.findFirst({
+ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
+ });
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row;
}
- async queueWebhookEvent(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; uplinkMessageId?: string | null; eventType: 'receipt' | 'uplink'; payload: Record }) {
+ async queueWebhookEvent(data: {
+ tenantId: string;
+ applicationId?: string | null;
+ messageRecordId?: string | null;
+ messageId?: string | null;
+ uplinkMessageId?: string | null;
+ eventType: 'receipt' | 'uplink';
+ payload: Record;
+ }) {
if (!data.applicationId) return null;
- const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } });
+ const application = await this.prisma.smsApplication.findUnique({
+ where: { id: data.applicationId },
+ include: { httpConfig: true },
+ });
const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
if (!config?.enabled || !enabled) return null;
- const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } });
+ const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({
+ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } },
+ });
if (!endpoint || endpoint.status !== 'active') return null;
- const eventId = data.eventType === 'receipt' && data.messageRecordId
- ? `evt_receipt_${data.messageRecordId}`
- : data.eventType === 'uplink' && data.uplinkMessageId
- ? `evt_uplink_${data.uplinkMessageId}`
- : `evt_${randomUUID()}`;
+ const eventId =
+ data.eventType === 'receipt' && data.messageRecordId
+ ? `evt_receipt_${data.messageRecordId}`
+ : data.eventType === 'uplink' && data.uplinkMessageId
+ ? `evt_uplink_${data.uplinkMessageId}`
+ : `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({
where: { eventId },
update: {},
- create: { eventId, tenantId: data.tenantId, applicationId: data.applicationId, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue },
+ create: {
+ eventId,
+ tenantId: data.tenantId,
+ applicationId: data.applicationId,
+ eventType: data.eventType,
+ messageRecordId: data.messageRecordId,
+ messageId: data.messageId,
+ uplinkMessageId: data.uplinkMessageId,
+ payload: data.payload as Prisma.InputJsonValue,
+ },
});
const delivery = await this.prisma.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
@@ -336,57 +552,135 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
create: { eventId: event.id, endpointId: endpoint.id },
});
if (delivery.status === 'delivered') return delivery;
- await this.queue?.add('deliver', { deliveryId: delivery.id }, { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 });
+ await this.queue?.add(
+ 'deliver',
+ { deliveryId: delivery.id },
+ { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 },
+ );
return delivery;
}
async listRequestLogs(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId);
- return this.prisma.openApiRequest.findMany({ where: { applicationId }, select: { id: true, requestId: true, clientMessageId: true, sourceIp: true, httpStatus: true, businessCode: true, status: true, durationMs: true, createdAt: true, completedAt: true }, orderBy: { createdAt: 'desc' }, take: 100 });
+ return this.prisma.openApiRequest.findMany({
+ where: { applicationId },
+ select: {
+ id: true,
+ requestId: true,
+ clientMessageId: true,
+ sourceIp: true,
+ httpStatus: true,
+ businessCode: true,
+ status: true,
+ durationMs: true,
+ createdAt: true,
+ completedAt: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 100,
+ });
}
async listWebhookDeliveries(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId);
- return this.prisma.httpWebhookDelivery.findMany({ where: { event: { applicationId } }, include: { event: true, endpoint: { select: { eventType: true, url: true } }, attempts: { orderBy: { attemptNo: 'desc' }, take: 5 } }, orderBy: { createdAt: 'desc' }, take: 100 });
+ return this.prisma.httpWebhookDelivery.findMany({
+ where: { event: { applicationId } },
+ include: {
+ event: true,
+ endpoint: { select: { eventType: true, url: true } },
+ attempts: { orderBy: { attemptNo: 'desc' }, take: 5 },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 100,
+ });
}
async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId);
- if (tenantId && !application.httpConfig?.allowClientManualRetry) throw new ForbiddenException('该应用未开通客户端手动重投');
- const delivery = await this.prisma.httpWebhookDelivery.findFirst({ where: { id: deliveryId, event: { applicationId } } });
+ if (tenantId && !application.httpConfig?.allowClientManualRetry)
+ throw new ForbiddenException('该应用未开通客户端手动重投');
+ const delivery = await this.prisma.httpWebhookDelivery.findFirst({
+ where: { id: deliveryId, event: { applicationId } },
+ });
if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
- await this.prisma.httpWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', nextRetryAt: null, lastError: null } });
- await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 });
+ await this.prisma.httpWebhookDelivery.update({
+ where: { id: delivery.id },
+ data: { status: 'pending', nextRetryAt: null, lastError: null },
+ });
+ await this.queue?.add(
+ 'deliver',
+ { deliveryId },
+ { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 },
+ );
return { id: deliveryId, status: 'pending' };
}
private async deliverWebhook(deliveryId: string) {
- const delivery = await this.prisma.httpWebhookDelivery.findUnique({ where: { id: deliveryId }, include: { event: true, endpoint: true } });
+ const delivery = await this.prisma.httpWebhookDelivery.findUnique({
+ where: { id: deliveryId },
+ include: { event: true, endpoint: true },
+ });
if (!delivery || delivery.status === 'delivered') return;
- const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId } });
+ const config = await this.prisma.smsApplicationHttpConfig.findUnique({
+ where: { applicationId: delivery.event.applicationId },
+ });
if (!config) return;
const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000));
- const body = JSON.stringify({ eventId: delivery.event.eventId, eventType: delivery.event.eventType, occurredAt: delivery.event.createdAt.toISOString(), data: delivery.event.payload });
- const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted)).update(`${timestamp}\n${body}`).digest('hex');
+ const body = JSON.stringify({
+ eventId: delivery.event.eventId,
+ eventType: delivery.event.eventType,
+ occurredAt: delivery.event.createdAt.toISOString(),
+ data: delivery.event.payload,
+ });
+ const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted))
+ .update(`${timestamp}\n${body}`)
+ .digest('hex');
const startedAt = Date.now();
let responseStatus: number | undefined;
let responseSummary: string | undefined;
let errorMessage: string | undefined;
try {
- const response = await postWebhook(delivery.endpoint.url, body, {
- 'content-type': 'application/json',
- 'x-event-id': delivery.event.eventId,
- 'x-event-type': delivery.event.eventType,
- 'x-timestamp': timestamp,
- 'x-signature': `sha256=${signature}`,
- }, config.webhookTimeoutSeconds * 1000, config.requireHttps);
+ const response = await postWebhook(
+ delivery.endpoint.url,
+ body,
+ {
+ 'content-type': 'application/json',
+ 'x-event-id': delivery.event.eventId,
+ 'x-event-type': delivery.event.eventType,
+ 'x-timestamp': timestamp,
+ 'x-signature': `sha256=${signature}`,
+ },
+ config.webhookTimeoutSeconds * 1000,
+ config.requireHttps,
+ );
responseStatus = response.status;
responseSummary = response.body;
- } catch (error) { errorMessage = error instanceof Error ? error.message : 'Webhook request failed'; }
+ } catch (error) {
+ errorMessage = error instanceof Error ? error.message : 'Webhook request failed';
+ }
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
- const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500);
- await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } });
+ const retryable =
+ errorMessage !== undefined ||
+ responseStatus === 408 ||
+ responseStatus === 429 ||
+ (responseStatus !== undefined && responseStatus >= 500);
+ await this.prisma.httpWebhookAttempt.create({
+ data: {
+ deliveryId,
+ attemptNo,
+ responseStatus,
+ responseSummary,
+ errorMessage,
+ durationMs: Date.now() - startedAt,
+ requestHeaders: {
+ 'x-event-id': delivery.event.eventId,
+ 'x-event-type': delivery.event.eventType,
+ 'x-timestamp': timestamp,
+ 'x-signature': 'sha256=***',
+ },
+ },
+ });
this.protocolLogs?.record({
protocol: 'http',
direction: 'platform_to_client',
@@ -403,22 +697,62 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
detail: { deliveryId, attemptNo, error: errorMessage },
});
if (success) {
- await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } });
+ await this.prisma.httpWebhookDelivery.update({
+ where: { id: deliveryId },
+ data: {
+ status: 'delivered',
+ attemptCount: attemptNo,
+ lastHttpStatus: responseStatus,
+ lastError: null,
+ deliveredAt: new Date(),
+ nextRetryAt: null,
+ },
+ });
return;
}
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
- await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'retrying', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt } });
- await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:${attemptNo + 1}`, delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000 });
+ await this.prisma.httpWebhookDelivery.update({
+ where: { id: deliveryId },
+ data: {
+ status: 'retrying',
+ attemptCount: attemptNo,
+ lastHttpStatus: responseStatus,
+ lastError: errorMessage ?? `HTTP ${responseStatus}`,
+ nextRetryAt,
+ },
+ });
+ await this.queue?.add(
+ 'deliver',
+ { deliveryId },
+ {
+ jobId: `${deliveryId}:${attemptNo + 1}`,
+ delay: delaySeconds * 1000,
+ removeOnComplete: 1000,
+ removeOnFail: 1000,
+ },
+ );
return;
}
- await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt: null } });
+ await this.prisma.httpWebhookDelivery.update({
+ where: { id: deliveryId },
+ data: {
+ status: 'failed',
+ attemptCount: attemptNo,
+ lastHttpStatus: responseStatus,
+ lastError: errorMessage ?? `HTTP ${responseStatus}`,
+ nextRetryAt: null,
+ },
+ });
}
private async requireApplication(applicationId: string, tenantId?: string) {
- const application = await this.prisma.smsApplication.findFirst({ where: { id: applicationId, tenantId }, include: { httpConfig: true, httpIpAllowlist: true } });
+ const application = await this.prisma.smsApplication.findFirst({
+ where: { id: applicationId, tenantId },
+ include: { httpConfig: true, httpIpAllowlist: true },
+ });
if (!application) throw new NotFoundException('企业应用不存在');
return application;
}
@@ -428,12 +762,22 @@ function httpApiPublicOrigin() {
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
if (!configured) return undefined;
const url = new URL(configured);
- const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
- if ((url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
+ const insecureHttpExplicitlyAllowed =
+ process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
+ if (
+ (url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) ||
+ url.username ||
+ url.password ||
+ url.pathname !== '/' ||
+ url.search ||
+ url.hash
+ ) {
// This value is copied into customer integration parameters, so fail closed instead of
// publishing an insecure or path-dependent endpoint unless an isolated test environment
// has explicitly opted into plain HTTP.
- throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN');
+ throw new Error(
+ 'HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN',
+ );
}
return url.origin;
}
@@ -441,15 +785,22 @@ function httpApiPublicOrigin() {
function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) {
const value = error.getResponse();
- const object = typeof value === 'object' && value ? value as Record : {};
+ const object = typeof value === 'object' && value ? (value as Record) : {};
const rawMessage = object.message ?? error.message;
return {
httpStatus: error.getStatus(),
code: String(object.code ?? 'SEND_REJECTED'),
- responseBody: { code: String(object.code ?? 'SEND_REJECTED'), message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage) } as Prisma.InputJsonValue,
+ responseBody: {
+ code: String(object.code ?? 'SEND_REJECTED'),
+ message: Array.isArray(rawMessage) ? rawMessage.join(';') : String(rawMessage),
+ } as Prisma.InputJsonValue,
};
}
- return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue };
+ return {
+ httpStatus: 500,
+ code: 'INTERNAL_ERROR',
+ responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
+ };
}
function normalizeConfig(
@@ -458,15 +809,17 @@ function normalizeConfig(
cmppEnabled: boolean,
) {
const enabling = input.enabled === true && existing?.enabled !== true;
- const effective = enabling ? {
- sendEnabled: true,
- messageQueryEnabled: true,
- receiptWebhookEnabled: true,
- uplinkWebhookEnabled: true,
- uplinkQueryEnabled: true,
- credentialSelfServiceEnabled: true,
- ...input,
- } : input;
+ const effective = enabling
+ ? {
+ sendEnabled: true,
+ messageQueryEnabled: true,
+ receiptWebhookEnabled: true,
+ uplinkWebhookEnabled: true,
+ uplinkQueryEnabled: true,
+ credentialSelfServiceEnabled: true,
+ ...input,
+ }
+ : input;
const httpEnabled = effective.enabled ?? existing?.enabled ?? false;
const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled);
return {
@@ -496,22 +849,31 @@ function normalizeConfig(
function bounded(value: number | undefined, min: number, max: number, label: string) {
if (value === undefined) return undefined;
- if (!Number.isInteger(value) || value < min || value > max) throw new BadRequestException(`${label}必须在${min}至${max}之间`);
+ if (!Number.isInteger(value) || value < min || value > max)
+ throw new BadRequestException(`${label}必须在${min}至${max}之间`);
return value;
}
function normalizeIpAllowlist(values?: string[]) {
- return [...new Set((values ?? []).map((item) => item.trim()).filter(Boolean).map((item) => {
- const [ip, prefix] = item.split('/');
- const version = isIP(ip);
- if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
- if (prefix !== undefined) {
- const bits = Number(prefix);
- const max = version === 4 ? 32 : 128;
- if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`);
- }
- return item;
- }))];
+ return [
+ ...new Set(
+ (values ?? [])
+ .map((item) => item.trim())
+ .filter(Boolean)
+ .map((item) => {
+ const [ip, prefix] = item.split('/');
+ const version = isIP(ip);
+ if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
+ if (prefix !== undefined) {
+ const bits = Number(prefix);
+ const max = version === 4 ? 32 : 128;
+ if (!Number.isInteger(bits) || bits < 0 || bits > max)
+ throw new BadRequestException(`CIDR格式非法:${item}`);
+ }
+ return item;
+ }),
+ ),
+ ];
}
async function validateWebhookUrl(value: string, requireHttps: boolean) {
@@ -520,37 +882,54 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) {
async function resolveWebhookTarget(value: string, requireHttps: boolean) {
let url: URL;
- try { url = new URL(String(value ?? '').trim()); } catch { throw new BadRequestException('Webhook URL格式非法'); }
+ try {
+ url = new URL(String(value ?? '').trim());
+ } catch {
+ throw new BadRequestException('Webhook URL格式非法');
+ }
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
- if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
+ if (addresses.some(({ address }) => isPrivateAddress(address)))
+ throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
const selected = addresses[0];
if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址');
return { url, address: selected.address, family: isIP(selected.address) };
}
-async function postWebhook(urlText: string, body: string, headers: Record, timeoutMs: number, requireHttps: boolean) {
+async function postWebhook(
+ urlText: string,
+ body: string,
+ headers: Record,
+ timeoutMs: number,
+ requireHttps: boolean,
+) {
const target = await resolveWebhookTarget(urlText, requireHttps);
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest;
- const request = requestFn(target.url, {
- method: 'POST',
- headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
- lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
- }, (response) => {
- const chunks: Buffer[] = [];
- let size = 0;
- response.on('data', (chunk: Buffer) => {
- if (size < 1000) {
- const buffer = Buffer.from(chunk);
- chunks.push(buffer.subarray(0, 1000 - size));
- size += buffer.length;
- }
- });
- response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
- });
+ const request = requestFn(
+ target.url,
+ {
+ method: 'POST',
+ headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
+ lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
+ },
+ (response) => {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ response.on('data', (chunk: Buffer) => {
+ if (size < 1000) {
+ const buffer = Buffer.from(chunk);
+ chunks.push(buffer.subarray(0, 1000 - size));
+ size += buffer.length;
+ }
+ });
+ response.on('end', () =>
+ resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
+ );
+ },
+ );
request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out')));
request.on('error', reject);
request.end(body);
@@ -559,13 +938,33 @@ async function postWebhook(urlText: string, body: string, headers: Record= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
+ return (
+ a === 10 ||
+ a === 127 ||
+ a === 0 ||
+ (a === 169 && b === 254) ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168) ||
+ (a === 100 && b >= 64 && b <= 127)
+ );
}
-function encodeCursor(receivedAt: Date, id: string) { return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url'); }
+function encodeCursor(receivedAt: Date, id: string) {
+ return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url');
+}
function decodeCursor(value?: string) {
if (!value) return null;
try {
@@ -573,10 +972,19 @@ function decodeCursor(value?: string) {
const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id };
- } catch { throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' }); }
+ } catch {
+ throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' });
+ }
}
function bullmqConnection() {
const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
- return { host: url.hostname, port: Number(url.port || 6379), username: url.username || undefined, password: url.password || undefined, db: Number(url.pathname.slice(1) || 0), maxRetriesPerRequest: null as null };
+ return {
+ host: url.hostname,
+ port: Number(url.port || 6379),
+ username: url.username || undefined,
+ password: url.password || undefined,
+ db: Number(url.pathname.slice(1) || 0),
+ maxRetriesPerRequest: null as null,
+ };
}
diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts
index 6fd3cf8..56f271e 100644
--- a/api/src/operations/client-operations.controller.ts
+++ b/api/src/operations/client-operations.controller.ts
@@ -1,8 +1,10 @@
-import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { OperationsService } from './operations.service';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientSystemLogExportDto } from './client-operations.dto';
@ApiTags('client-operations')
@Controller('client/operations')
@@ -15,7 +17,11 @@ export class ClientOperationsController {
}
@Get('batch-tasks/:id/messages')
- listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
+ listTaskMessages(
+ @CurrentTenantId() tenantId: string,
+ @Param('id') taskId: string,
+ @Query('phoneNumber') phoneNumber?: string,
+ ) {
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
}
@@ -49,9 +55,31 @@ export class ClientOperationsController {
}
@Get('uplink-messages')
- listUplinkMessages(@CurrentTenantId() tenantId: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
+ listUplinkMessages(
+ @CurrentTenantId() tenantId: string,
+ @Query('channelId') channelId?: string,
+ @Query('applicationId') applicationId?: string,
+ @Query('phoneNumber') phoneNumber?: string,
+ @Query('keyword') keyword?: string,
+ @Query('startTime') startTime?: string,
+ @Query('endTime') endTime?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
return page || pageSize
- ? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
+ ? this.operations.listUplinkMessagesPage(
+ {
+ tenantId,
+ applicationId,
+ phoneNumber,
+ keyword,
+ startTime,
+ endTime,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ },
+ true,
+ )
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
}
@@ -72,14 +100,25 @@ export class ClientOperationsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
- return this.operations.systemLogs({ tenantId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
+ return this.operations.systemLogs({
+ tenantId,
+ keyword,
+ level,
+ module,
+ range,
+ createdAtFrom,
+ createdAtTo,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ });
}
@Post('system-logs/exports')
+ @UsePipes(strictValidationPipe)
exportSystemLogs(
@CurrentSessionUserId() userId: string | undefined,
@CurrentTenantId() tenantId: string,
- @Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string },
+ @Body() body: ClientSystemLogExportDto,
) {
return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
}
diff --git a/api/src/operations/client-operations.dto.spec.ts b/api/src/operations/client-operations.dto.spec.ts
new file mode 100644
index 0000000..86da0e4
--- /dev/null
+++ b/api/src/operations/client-operations.dto.spec.ts
@@ -0,0 +1,28 @@
+import { BadRequestException } from '@nestjs/common';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientSystemLogExportDto } from './client-operations.dto';
+
+describe('ClientSystemLogExportDto', () => {
+ it('accepts the supported date range and rejects extra or malformed fields', async () => {
+ await expect(
+ strictValidationPipe.transform(
+ { createdAtFrom: '2026-08-21', createdAtTo: '2026-08-28', level: 'error' },
+ {
+ type: 'body',
+ metatype: ClientSystemLogExportDto,
+ data: undefined,
+ },
+ ),
+ ).resolves.toEqual(expect.objectContaining({ level: 'error' }));
+ await expect(
+ strictValidationPipe.transform(
+ { createdAtFrom: 'last-week', tenantId: 'spoofed' },
+ {
+ type: 'body',
+ metatype: ClientSystemLogExportDto,
+ data: undefined,
+ },
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
diff --git a/api/src/operations/client-operations.dto.ts b/api/src/operations/client-operations.dto.ts
new file mode 100644
index 0000000..a202aac
--- /dev/null
+++ b/api/src/operations/client-operations.dto.ts
@@ -0,0 +1,10 @@
+import { IsDateString, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
+
+export class ClientSystemLogExportDto {
+ @IsOptional() @IsString() @MaxLength(200) keyword?: string;
+ @IsOptional() @IsIn(['all', 'debug', 'info', 'warning', 'error']) level?: string;
+ @IsOptional() @IsString() @MaxLength(100) module?: string;
+ @IsOptional() @IsIn(['7d', '30d']) range?: string;
+ @IsOptional() @IsDateString({ strict: true }) createdAtFrom?: string;
+ @IsOptional() @IsDateString({ strict: true }) createdAtTo?: string;
+}
diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts
index 32c1a8f..8995943 100644
--- a/api/src/sms-config/client-sms-config.controller.ts
+++ b/api/src/sms-config/client-sms-config.controller.ts
@@ -3,7 +3,19 @@ import { ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
-import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
+import {
+ ClientApplicationStatusDto,
+ ClientDeleteResourceDto,
+ ClientDrainageInfoDto,
+ ClientDrainageInfoUpdateDto,
+ ClientSecretResetDto,
+ ClientSignatureMaterialDto,
+ ClientSmsApplicationDto,
+ ClientSmsSignatureDto,
+ ClientSmsSignatureUpdateDto,
+ ClientSmsTemplateDto,
+ ClientSmsTemplateUpdateDto,
+} from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { SmsConfigService } from './sms-config.service';
@@ -11,10 +23,17 @@ import { SmsConfigService } from './sms-config.service';
@ApiTags('client-sms-config')
@Controller('client')
export class ClientSmsConfigController {
- constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
+ constructor(
+ private readonly smsConfig: SmsConfigService,
+ private readonly deletions: DeletionGovernanceService,
+ ) {}
@Get('applications')
- listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
+ listApplications(
+ @CurrentTenantId() tenantId: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
return page || pageSize
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listApplications(tenantId);
@@ -37,8 +56,14 @@ export class ClientSmsConfigController {
}
@Get('applications/:id/report-fields')
- getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @CurrentTenantId() tenantId: string) {
- return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
+ getApplicationReportFields(
+ @Param('id') applicationId: string,
+ @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage',
+ @CurrentTenantId() tenantId: string,
+ ) {
+ return this.smsConfig
+ .getApplication(applicationId, tenantId)
+ .then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
}
@Get('report-fields/common')
@@ -49,14 +74,24 @@ export class ClientSmsConfigController {
@Post('applications/:id/secret/reset')
@RequireRecentAuthentication()
@UsePipes(strictValidationPipe)
- resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
+ resetApplicationSecret(
+ @Param('id') applicationId: string,
+ @Body() body: ClientSecretResetDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
}
@Post('applications/:id/status')
@RequireRecentAuthentication()
@UsePipes(strictValidationPipe)
- changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
+ changeApplicationStatus(
+ @Param('id') applicationId: string,
+ @Body() body: ClientApplicationStatusDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
}
@@ -71,8 +106,21 @@ export class ClientSmsConfigController {
}
@Get('signatures-workspace')
- getSignatureWorkspace(@CurrentTenantId() tenantId: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
- return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) });
+ getSignatureWorkspace(
+ @CurrentTenantId() tenantId: string,
+ @Query('keyword') keyword?: string,
+ @Query('applicationId') applicationId?: string,
+ @Query('status') status?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
+ return this.smsConfig.getClientSignatureWorkspace(tenantId, {
+ keyword,
+ applicationId,
+ status,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ });
}
@Post('signatures')
@@ -84,14 +132,22 @@ export class ClientSmsConfigController {
@Put('signatures/:id')
@UsePipes(strictValidationPipe)
- async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) {
+ async updateSignature(
+ @Param('id') signatureId: string,
+ @Body() body: ClientSmsSignatureUpdateDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/materials')
@UsePipes(strictValidationPipe)
- createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
+ createSignatureMaterial(
+ @Param('id') signatureId: string,
+ @Body() body: ClientSignatureMaterialDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
}
@@ -102,21 +158,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/drainage-infos')
@UsePipes(strictValidationPipe)
- async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) {
+ async createDrainageInfo(
+ @Param('id') signatureId: string,
+ @Body() body: ClientDrainageInfoDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
}
@Put('drainage-infos/:id')
@UsePipes(strictValidationPipe)
- async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) {
+ async updateDrainageInfo(
+ @Param('id') itemId: string,
+ @Body() body: ClientDrainageInfoUpdateDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('drainage-infos/:id/status')
@UsePipes(strictValidationPipe)
- async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
+ async changeDrainageInfoStatus(
+ @Param('id') itemId: string,
+ @Body() body: ClientDeleteResourceDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
@@ -130,16 +199,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/status')
@UsePipes(strictValidationPipe)
- async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
- if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
+ async changeSignatureStatus(
+ @Param('id') signatureId: string,
+ @Body() body: ClientDeleteResourceDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
+ if (body.status === 'deleted')
+ return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Get('templates')
- listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
+ listTemplates(
+ @CurrentTenantId() tenantId: string,
+ @Query('includeHistory') includeHistory?: string,
+ @Query('keyword') keyword?: string,
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ ) {
return page || pageSize
- ? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
+ ? this.smsConfig.listTemplatesPage({
+ tenantId,
+ status: includeHistory === 'true' ? 'all' : 'approved',
+ keyword,
+ page: Number(page),
+ pageSize: Number(pageSize),
+ })
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
}
@@ -151,7 +238,11 @@ export class ClientSmsConfigController {
@Put('templates/:id')
@UsePipes(strictValidationPipe)
- updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) {
+ updateTemplate(
+ @Param('id') templateId: string,
+ @Body() body: ClientSmsTemplateUpdateDto,
+ @CurrentTenantId() tenantId: string,
+ ) {
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
}
@@ -162,8 +253,14 @@ export class ClientSmsConfigController {
@Post('templates/:id/status')
@UsePipes(strictValidationPipe)
- changeTemplateStatus(@Param('id') templateId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
- if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
+ changeTemplateStatus(
+ @Param('id') templateId: string,
+ @Body() body: ClientDeleteResourceDto,
+ @CurrentTenantId() tenantId: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
+ if (body.status === 'deleted')
+ return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId);
}
}
diff --git a/api/src/users/client-user.dto.spec.ts b/api/src/users/client-user.dto.spec.ts
new file mode 100644
index 0000000..137d50e
--- /dev/null
+++ b/api/src/users/client-user.dto.spec.ts
@@ -0,0 +1,43 @@
+import { BadRequestException } from '@nestjs/common';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import { ClientCreateUserDto, ClientUserPasswordDto } from './client-user.dto';
+
+describe('client user DTOs', () => {
+ it('rejects tenant, role and operator identity supplied by a client', async () => {
+ await expect(
+ strictValidationPipe.transform(
+ {
+ displayName: '测试用户',
+ email: 'user@example.com',
+ password: 'StrongPass-2026!',
+ tenantId: 'other',
+ roleCode: 'platform_admin',
+ operatorId: 'other-user',
+ },
+ { type: 'body', metatype: ClientCreateUserDto, data: undefined },
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('requires a bounded password', async () => {
+ await expect(
+ strictValidationPipe.transform(
+ { password: '1234567' },
+ {
+ type: 'body',
+ metatype: ClientUserPasswordDto,
+ data: undefined,
+ },
+ ),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('normalizes optional blank login fields without rejecting the existing client form', async () => {
+ await expect(
+ strictValidationPipe.transform(
+ { displayName: '测试用户', username: ' user ', email: ' ', phone: '', password: 'StrongPass-2026!' },
+ { type: 'body', metatype: ClientCreateUserDto, data: undefined },
+ ),
+ ).resolves.toEqual(expect.objectContaining({ username: 'user', email: undefined, phone: undefined }));
+ });
+});
diff --git a/api/src/users/client-user.dto.ts b/api/src/users/client-user.dto.ts
new file mode 100644
index 0000000..f8594f0
--- /dev/null
+++ b/api/src/users/client-user.dto.ts
@@ -0,0 +1,30 @@
+import { Transform } from 'class-transformer';
+import { IsEmail, IsIn, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
+
+const emptyToUndefined = ({ value }: { value: unknown }) =>
+ typeof value === 'string' ? value.trim() || undefined : value;
+
+export class ClientCreateUserDto {
+ @Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
+ @Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
+ @Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
+ @IsString() @MinLength(1) @MaxLength(100) displayName!: string;
+ @IsString() @MinLength(8) @MaxLength(128) password!: string;
+ @IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
+}
+
+export class ClientUpdateUserDto {
+ @Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
+ @Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
+ @Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
+ @IsOptional() @IsString() @MinLength(1) @MaxLength(100) displayName?: string;
+ @IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
+}
+
+export class ClientUserStatusDto {
+ @IsIn(['active', 'disabled']) status!: 'active' | 'disabled';
+}
+
+export class ClientUserPasswordDto {
+ @IsString() @MinLength(8) @MaxLength(128) password!: string;
+}
diff --git a/api/src/users/users.controller.ts b/api/src/users/users.controller.ts
index cdcd5ba..01400ab 100644
--- a/api/src/users/users.controller.ts
+++ b/api/src/users/users.controller.ts
@@ -1,9 +1,16 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UsePipes } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
+import { strictValidationPipe } from '../common/strict-validation.pipe';
+import {
+ ClientCreateUserDto,
+ ClientUpdateUserDto,
+ ClientUserPasswordDto,
+ ClientUserStatusDto,
+} from './client-user.dto';
import {
AssignPermissionDto,
AssignRoleDto,
@@ -53,13 +60,21 @@ export class UsersController {
@Post('admin/users/:id/status')
@RequireRecentAuthentication()
- changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
+ changeStatus(
+ @Param('id') id: string,
+ @Body() body: ChangeUserStatusDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.changeStatus(id, { ...body, operatorId }, undefined, operatorId);
}
@Post('admin/users/:id/password')
@RequireRecentAuthentication()
- changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
+ changePassword(
+ @Param('id') id: string,
+ @Body() body: ChangePasswordDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.changePassword(id, { ...body, operatorId });
}
@@ -82,31 +97,58 @@ export class UsersController {
@Post('client/users')
@RequireRecentAuthentication()
- createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
+ @UsePipes(strictValidationPipe)
+ createClient(
+ @CurrentTenantId() tenantId: string,
+ @Body() body: ClientCreateUserDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Put('client/users/:id')
@RequireRecentAuthentication()
- updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
+ @UsePipes(strictValidationPipe)
+ updateClient(
+ @CurrentTenantId() tenantId: string,
+ @Param('id') id: string,
+ @Body() body: ClientUpdateUserDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Post('client/users/:id/status')
@RequireRecentAuthentication()
- changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
+ @UsePipes(strictValidationPipe)
+ changeClientStatus(
+ @CurrentTenantId() tenantId: string,
+ @Param('id') id: string,
+ @Body() body: ClientUserStatusDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
}
@Post('client/users/:id/password')
@RequireRecentAuthentication()
- changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
+ @UsePipes(strictValidationPipe)
+ changeClientPassword(
+ @CurrentTenantId() tenantId: string,
+ @Param('id') id: string,
+ @Body() body: ClientUserPasswordDto,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
}
@Delete('client/users/:id')
@RequireRecentAuthentication()
- removeClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
+ removeClient(
+ @CurrentTenantId() tenantId: string,
+ @Param('id') id: string,
+ @CurrentSessionUserId() operatorId?: string,
+ ) {
return this.users.remove(id, operatorId, tenantId);
}
diff --git a/api/vendor/brace-expansion-compat/package.json b/api/vendor/brace-expansion-compat/package.json
index 62d889a..1267c82 100644
--- a/api/vendor/brace-expansion-compat/package.json
+++ b/api/vendor/brace-expansion-compat/package.json
@@ -1,11 +1,11 @@
{
"name": "brace-expansion",
- "version": "5.0.8-compat.1",
+ "version": "5.0.9-compat.1",
"private": true,
- "description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.8 implementation",
+ "description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.9 implementation",
"main": "index.cjs",
"license": "MIT",
"dependencies": {
- "brace-expansion-safe": "npm:brace-expansion@5.0.8"
+ "brace-expansion-safe": "npm:brace-expansion@5.0.9"
}
}
diff --git a/docs/api-dependency-advisory-matrix-20260828.md b/docs/api-dependency-advisory-matrix-20260828.md
new file mode 100644
index 0000000..641593e
--- /dev/null
+++ b/docs/api-dependency-advisory-matrix-20260828.md
@@ -0,0 +1,31 @@
+# API 依赖公告治理矩阵(2026-08-28)
+
+## 结论
+
+本轮重新执行 `npm audit --omit=dev --json`,审计对象从 9 项降至 5 项:Swagger、`js-yaml`、`fast-uri` 和 `brace-expansion` 的可安全升级路径已完成;剩余 5 项全部属于 Prisma CLI 的同一条开发/迁移工具链,不进入 API 运行时请求处理路径,且审计给出的唯一自动修复是把 Prisma 7.9.0 降级到 6.12.0,属于破坏性主版本变更,因此本轮不执行 `audit fix --force`。
+
+## 已修复公告
+
+| 公告/包 | 原路径 | 处理 | 回归要求 |
+|---|---|---|---|
+| `js-yaml` / GHSA-5p4m-2wfm-xmqj | `@nestjs/swagger -> js-yaml < 4.3.1` | 升级 `@nestjs/swagger` 至 11.4.7,并锁定 `js-yaml` 4.3.1 | API build、Swagger 启动、全量 Jest |
+| `fast-uri` / GHSA-7p8r-x3mc-p8w7 | OpenAPI/JSON schema 依赖树 | override 至 3.1.5 | API build、全量 Jest |
+| `brace-expansion` / GHSA-rgw5-rvv9-x895 | Excel/归档工具的 minimatch 兼容链 | 兼容适配器升级到有界实现 5.0.9 | `security:verify` 中真实 require 与 legacy minimatch 验证 |
+| `@nestjs/swagger` 聚合项 | 由 `js-yaml` 引起 | 随上述升级关闭 | 同上 |
+
+## 暂留公告
+
+| 审计项 | 实际路径 | 运行时暴露面 | 决策 |
+|---|---|---|---|
+| `prisma` | 根开发依赖 `prisma@7.9.0` | 仅生成客户端和执行迁移;API 运行时使用 `@prisma/client` | 暂留,等待 Prisma 7/8 提供无破坏升级 |
+| `@prisma/config` | `prisma -> @prisma/config -> deepmerge-ts` | CLI 解析受控本地配置,不接受 HTTP 用户递归对象 | 暂留并监控上游 |
+| `deepmerge-ts` / GHSA-ggr8-5vv4-36mx | 同上 | 不在 API 请求路径调用 | 暂留;禁止用不可信配置执行 Prisma CLI |
+| `@prisma/dev` | `prisma -> @prisma/dev -> valibot` | Prisma CLI 开发工具链 | 暂留并监控上游 |
+| `valibot` / GHSA-5qjj-4xww-7phc | 同上 | 不在 API 请求路径调用 | 暂留并监控上游 |
+
+## 门禁与复核
+
+- `tools/security/verify-dependency-mitigations.mjs` 固定验证修复版本及 brace CommonJS 兼容行为。
+- 部署仍使用受控 `package-lock.json` 和 `npm ci`,不执行 `npm audit fix --force`。
+- 每次依赖升级重新运行两份 lock 的审计、API/前端构建、API 全量测试和真实测试环境健康检查。
+- Prisma 上游出现保持主版本兼容的修复后,优先在独立分支验证 `prisma generate`、95 项迁移、PostgreSQL 连接和全量回归,再关闭暂留项。
diff --git a/docs/code-quality-continuous-optimization-plan-20260828.md b/docs/code-quality-continuous-optimization-plan-20260828.md
new file mode 100644
index 0000000..c5a6ce3
--- /dev/null
+++ b/docs/code-quality-continuous-optimization-plan-20260828.md
@@ -0,0 +1,457 @@
+# CMPP 平台代码质量持续优化整改方案
+
+- 方案日期:2026-08-28
+- 依据报告:`docs/code-quality-reassessment-20260828-v2.md`
+- 当前代码基线:`3af145abe5eb8cae567bb18e29116b4940889258`
+- 适用范围:React/Vite 客户端、NestJS API、依赖治理、测试与工程门禁
+- 实施边界:先在本地完成代码和自动化验证;如需部署,只允许部署到测试环境 `100.93.204.60`,且部署前必须重新建立并校验恢复资产
+- 禁止范围:未经新的明确授权,不得访问、部署、覆盖或回退预生产环境
+
+## 1. 背景与当前结论
+
+V2 复评确认上一轮两个 P0 已关闭,代码质量从 58 分提升至 82 分。当前不存在新的运行态 P0,但仍有以下持续优化空间:
+
+1. 少量客户端写接口仍使用内联 TypeScript 类型,缺少运行时严格校验。
+2. 前端没有自动化测试,登录、权限、懒加载失败和页面异常状态主要依赖人工验证。
+3. API 全源覆盖率刚刚超过门槛,余量不足。
+4. 客户端仍发送 `x-tenant-id` 并保留默认租户回退,与服务端可信会话租户的最终设计不一致。
+5. 登录保护只有账号维度的主要限流,需要补充 IP 和验证码请求维度。
+6. API 依赖树存在审计公告,需要按真实依赖路径和运行时可利用面逐项治理。
+7. 当前 `lint` 不是完整 ESLint,格式检查也只依赖 `git diff --check`。
+8. 两个 SendChain Service 仍然过大,但直接拆分具有较高业务回归风险。
+9. 工作区存在双锁文件口径,可能继续误导安装和依赖审计。
+
+本方案遵循“先补小范围安全缺口和测试,再治理供应链和工程门禁,最后拆分发送链”的顺序。
+
+## 2. 整改目标
+
+本轮持续优化完成后应达到:
+
+- 所有客户端写接口具有运行时 DTO 校验,不再使用裸内联 body 类型。
+- 客户端租户授权完全依赖服务端认证会话,浏览器不再负责选择或回退租户。
+- 建立可持续运行的前端测试框架,并覆盖核心登录、权限、异步路由和异常状态。
+- API 全源覆盖率形成合理缓冲,新增代码不能依靠全局低门槛掩盖未测试分支。
+- 登录防护具备账号、IP、IP+账号和验证码请求多维度限制与指标。
+- API 依赖公告具有逐项路径、影响、处理方式和回归证据。
+- `lint` 和 `format:check` 成为真实、稳定、可逐步扩展的工程门禁。
+- SendChain 重构有行为锁定测试,拆分过程中不改变事务、锁、幂等和队列语义。
+- 仓库只保留一种正式包管理器和一种正式锁文件。
+
+## 3. 优先级和最小充分范围
+
+| 批次 | 内容 | 风险 | 预估工作量 | 是否建议下一轮立即实施 |
+|---|---|---:|---:|---|
+| C1 | 剩余客户端 DTO 和负向测试 | 低 | 0.5~1.5 人日 | 是 |
+| C2 | 删除客户端租户头和默认租户回退 | 中低 | 0.5~1 人日 | 是 |
+| C3 | 最小前端测试框架和核心用例 | 中 | 1~3 人日 | 是 |
+| C4 | 覆盖率缓冲和增量覆盖门禁 | 低 | 0.5~1 人日 | 是,依赖 C1/C3 |
+| C5 | IP/账号/验证码多维登录保护 | 中 | 1~2 人日 | 建议 |
+| C6 | API 依赖公告治理矩阵 | 中 | 1~3 人日 | 建议先诊断后升级 |
+| C7 | ESLint、Prettier 和真实格式门禁 | 中 | 1~2 人日 | 建议分阶段启用 |
+| C8 | SendChain Service 职责拆分 | 高 | 5~10 人日 | 单独专项 |
+| C9 | 唯一包管理器和锁文件 | 中 | 0.5~1 人日 | 需先确认文件归属 |
+
+下一轮最小充分范围为 C1~C4。它们投入较小,能够实质关闭 `CQ-API-001` 的客户端部分并改善 `CQ-TEST-001`,不需要数据库迁移,也不需要修改发送链业务规则。
+
+## 4. C1:补齐客户端写接口 DTO
+
+### 4.1 当前缺口
+
+首批至少覆盖:
+
+- `api/src/open-api/client-open-api.controller.ts`
+ - 创建 HTTP API 凭据。
+ - 新增或更新 Webhook。
+- `api/src/operations/client-operations.controller.ts`
+ - 系统日志导出。
+- `api/src/files/client-files.controller.ts`
+ - Multipart 上传的 `purpose` 和 `prefix`。
+
+### 4.2 实施要求
+
+1. 新增独立 DTO class,不再使用运行时会被擦除的内联 TypeScript 类型。
+2. 对上述接口启用现有 `strictValidationPipe`。
+3. DTO 规则至少包含:
+ - 凭据名称长度和空白处理。
+ - `expiresAt` 使用 ISO 日期时间校验,并拒绝已过期时间。
+ - Webhook `eventType` 只允许平台支持的事件。
+ - Webhook URL 限制协议、长度和格式;继续由 Service 执行 SSRF 与 HTTPS 策略。
+ - Webhook `status` 使用明确枚举。
+ - 日志级别、模块、日期区间使用明确格式和长度限制。
+ - 上传 `purpose` 使用白名单;`prefix` 限制长度、字符集和路径穿越。
+4. 将当前过宽的 `ClientStatusChangeDto` 按应用、签名、模板和引流信息拆成更窄的 DTO,避免无关字段被不同接口接受。
+5. 对 `scheduledAt`、`requestedAt` 等时间字段使用日期格式校验。
+6. 对 `variables`、`materials`、`reportValues` 等对象补充:
+ - 最大键数量。
+ - 最大嵌套深度。
+ - 键名和字符串值长度。
+ - 禁止原型污染相关键。
+
+### 4.3 自动化测试
+
+每个接口至少覆盖:
+
+- 合法最小请求成功。
+- 额外字段返回 400。
+- 超长字符串返回 400。
+- 非法 URL、日期和状态返回 400。
+- 客户端提交 `createdById/operatorId/tenantId` 等身份字段不能覆盖服务端身份。
+- 校验失败时 Service 不被调用。
+- 文件 purpose/prefix 校验失败时 MinIO 不发生写入。
+
+### 4.4 出口标准
+
+- 客户端 POST/PUT/PATCH 中不再存在未评审的裸 `@Body()` 内联类型。
+- 对应控制器和 DTO 负向测试通过。
+- 错误请求稳定返回 400,不返回 500。
+- 不改变现有合法前端请求协议。
+
+## 5. C2:移除客户端租户头和默认租户回退
+
+### 5.1 目标设计
+
+客户端租户范围只能来自服务端认证会话:
+
+```text
+客户端请求
+ -> session cookie
+ -> 服务端查询会话用户
+ -> request.sessionTenantId
+ -> CurrentTenantId
+ -> 数据库 tenantId 复合条件
+```
+
+浏览器不再通过 localStorage、默认常量或 `x-tenant-id` 参与客户端授权。
+
+### 5.2 实施内容
+
+1. 修改客户端 HTTP 请求封装,客户端路由默认不发送 `x-tenant-id`。
+2. 删除 `getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID` 的客户端回退路径。
+3. 清理客户端 API 方法中仅为请求头服务的 `tenantId` 参数。
+4. 管理端明确跨企业查询所需的租户参数继续保留,并与客户端请求封装分离。
+5. 服务端暂时保留“收到客户端租户头时做一致性检查”的兼容逻辑,经过一个测试版本确认没有旧客户端后再决定移除。
+6. 质量门禁增加:
+ - `src/api/client/**` 不得设置 `x-tenant-id`。
+ - 客户端 API 不得引用 `DEFAULT_CLIENT_TENANT_ID`。
+ - 管理端的显式租户参数不受此规则影响。
+
+### 5.3 必测用例
+
+- 不带租户头的客户端全部核心页面和接口正常。
+- 浏览器 localStorage 中 tenantId 缺失、错误或过期,不影响服务端租户识别。
+- 运营端跨租户查询仍按权限正常工作。
+- 旧客户端携带正确租户头仍兼容;错误租户头仍返回 403 并记录安全事件。
+- 双租户真实 API 隔离矩阵继续通过。
+
+### 5.4 出口标准
+
+- 客户端代码不再选择租户授权范围。
+- 不出现登录后初始化阶段因默认租户错误导致的 403。
+- 服务端可信租户门禁继续通过。
+
+## 6. C3:建立最小前端测试集
+
+### 6.1 技术方案
+
+建议采用:
+
+- Vitest:测试运行器。
+- React Testing Library:组件和页面行为测试。
+- jsdom:DOM 环境。
+- MSW:模拟真实 HTTP 协议的成功、空、失败和延迟响应。
+
+测试必须围绕用户可见行为和真实请求契约,不以大量快照替代断言。
+
+### 6.2 首批核心用例
+
+1. 客户端登录:成功、密码错误、验证码错误、接口不可用。
+2. 未登录访问客户端深层路由:跳转登录页。
+3. 权限不足:显示稳定提示,不白屏。
+4. `RouteLoadBoundary`:异步 chunk 加载失败、重试和重新加载。
+5. 列表页:loading、empty、error、成功、分页和筛选。
+6. 高风险操作:删除、重置密钥、创建凭据等确认流程。
+7. API 返回 500:页面显示业务化错误,不直接显示裸 `Internal server error`。
+8. 会话失效:401 后清理展示会话并回登录页。
+
+### 6.3 工程门禁
+
+新增命令建议:
+
+```text
+npm run test:frontend
+npm run test:frontend:coverage
+```
+
+并将 `test:frontend` 纳入 `verify:quality`。首轮覆盖率门槛以核心模块为主,不为追求全局数字测试纯展示组件。
+
+### 6.4 出口标准
+
+- 至少 6~10 个核心行为用例稳定通过。
+- 登录、权限、路由失败和列表异常状态均有自动化覆盖。
+- 测试可以在全新依赖安装后重复执行。
+- CI/质量门禁中前端测试失败会阻止合并。
+
+## 7. C4:覆盖率缓冲和增量门禁
+
+### 7.1 当前问题
+
+当前全源覆盖率虽然通过,但接近门槛:
+
+- statements 59.84%,门槛 59%。
+- branches 51.29%,门槛 50%。
+- functions 60.15%,门槛 60%。
+- lines 62.60%,门槛 62%。
+
+functions 仅有 0.15 个百分点余量,新增少量未测试函数就可能失败。
+
+### 7.2 实施内容
+
+1. 先通过 C1 和 C3 增加有业务价值的测试,不立即盲目提高门槛。
+2. 建立修改文件或新增文件覆盖率门禁,建议初始目标:
+ - statements/lines 不低于 80%。
+ - branches 不低于 70%。
+ - functions 不低于 80%。
+3. 全源门槛在覆盖率稳定后分批上调,每次不超过 2~3 个百分点。
+4. 覆盖率报告明确使用全源 `collectCoverageFrom` 口径,避免再次混用“已加载源码”和“全部源码”。
+
+### 7.3 出口标准
+
+- 新增 DTO、校验器和安全逻辑具有负向分支测试。
+- 修改文件覆盖率门禁能够阻止新增无测试逻辑。
+- 文档和质量门禁使用同一统计口径。
+
+## 8. C5:多维登录和验证码保护
+
+### 8.1 实施内容
+
+在现有 Redis 原子计数基础上增加:
+
+- 账号维度失败窗口。
+- 来源 IP 维度失败窗口。
+- IP+账号组合维度。
+- 验证码获取频率限制。
+- 单 IP 随机账号扫描保护。
+- key 数量、失败次数、锁定次数和 Redis 异常指标。
+
+来源 IP 必须基于受信 Nginx/代理链解析,不能直接信任任意客户端 `X-Forwarded-For`。
+
+### 8.2 验证
+
+- 多 API 实例共享相同锁定状态。
+- 同账号换 IP、同 IP 换账号均能触发对应保护。
+- 正常用户偶发输错不会被过度锁定。
+- Redis 不可用继续 fail closed,并产生监控告警。
+- 随机账号和验证码请求压测后 Redis key 数量能随 TTL 回落。
+
+## 9. C6:API 依赖公告治理
+
+### 9.1 原则
+
+依赖审计公告不直接等于业务可利用漏洞。不得直接执行 `npm audit fix --force`。应先建立矩阵:
+
+| 字段 | 内容 |
+|---|---|
+| 公告编号 | GHSA/CVE |
+| 依赖路径 | 直接依赖到受影响包的完整路径 |
+| 实际锁定版本 | 以 `api/package-lock.json` 为准 |
+| 环境 | 生产运行时、构建期、开发期、可选工具 |
+| 不可信输入 | 是否处理客户输入、文件、URL、模板或命令行参数 |
+| 当前缓解 | override、功能未启用、输入边界、兼容适配 |
+| 修复方案 | 补丁升级、依赖替换、隔离或书面风险接受 |
+| 回归范围 | 构建、Excel 导出、Swagger、Prisma、数据库和部署 |
+
+### 9.2 优先顺序
+
+1. 直接生产运行时且处理不可信输入的路径。
+2. Excel 导出、压缩、YAML/Swagger 等数据处理路径。
+3. Prisma 的生产客户端路径。
+4. 仅 CLI、Studio、构建或可选工具链路径。
+
+### 9.3 出口标准
+
+- 7 项公告均有明确依赖路径和处置结论。
+- 可安全升级的依赖完成升级并通过全量回归。
+- 暂不能升级的项目有代码级缓解门禁和明确复查日期。
+- 根项目和 API 子项目审计口径分别记录,不互相替代。
+
+## 10. C7:真实 lint 和格式门禁
+
+### 10.1 分阶段实施
+
+第一阶段只检查新增和修改文件,启用高价值规则:
+
+- 未使用变量和导入。
+- 未处理 Promise。
+- 不安全的 `any` 和类型断言。
+- React Hooks 依赖和调用规则。
+- 无效条件、重复分支和不可达代码。
+- Node/NestJS 常见异步错误。
+
+第二阶段再逐目录修复历史问题并扩大到全仓库。
+
+Prettier 首轮只执行 `check`,不得在同一提交中格式化全部历史文件。大规模格式化必须独立提交,避免掩盖业务改动。
+
+### 10.2 脚本语义
+
+- `lint`:真实 ESLint。
+- `typecheck`:前端 TypeScript。
+- `quality:verify`:结构安全门禁。
+- `format:check`:Prettier check + `git diff --check`。
+
+不要继续用 `lint` 名称包装结构检查和 TypeScript,使开发者误判实际门禁能力。
+
+## 11. C8:SendChain 超大服务专项拆分
+
+### 11.1 前置条件
+
+拆分前必须补齐行为锁定测试:
+
+- 事务边界。
+- advisory lock 和锁顺序。
+- 消息、Submit 和 Outbox 幂等键。
+- 主备路由和补发。
+- 计费冻结、扣费、退款和释放。
+- Inbox claim、租约、超时和恢复。
+- Redis Stream 发布及重复消费。
+- 回执、上行和下游投递。
+
+### 11.2 建议拆分方向
+
+`send-inbound-entry.service.ts`:
+
+- 入站协议数据转换。
+- Inbox 持久化和 claim。
+- 长短信聚合。
+- 企业分组微批。
+- 业务校验和消息创建。
+
+`send-gateway-submit.service.ts`:
+
+- 路由选择。
+- Gateway 命令构造。
+- Submit Outbox。
+- Submit 结果处理。
+- 重试和恢复。
+
+### 11.3 实施规则
+
+- 每次只移动一个职责。
+- 先机械提取,再优化逻辑。
+- 不同时修改事务、SQL、幂等键或状态语义。
+- 每个拆分提交都运行 API 全量、Gateway 全量、真实 PostgreSQL/Redis 契约和队列排空检查。
+
+### 11.4 出口标准
+
+- 原 Service 只保留编排职责。
+- 行为测试、全链契约和性能基线不下降。
+- 不新增重复查询、跨事务状态漂移或锁顺序变化。
+
+## 12. C9:唯一包管理器和锁文件
+
+当前正式跟踪文件为根目录和 API 的 `package-lock.json`,工作区另有未跟踪 `pnpm-lock.yaml`。该文件属于受保护的既有工作区内容,本方案不授权删除。
+
+需要文件所有者明确选择:
+
+### 方案 A:统一 npm(建议)
+
+- 保留两个正式 `package-lock.json`。
+- 确认后删除或归档旧 `pnpm-lock.yaml`。
+- 部署、审计和 CI 全部使用 `npm ci`。
+- 门禁禁止新增 pnpm/yarn 锁文件。
+
+### 方案 B:统一 pnpm
+
+- 重新生成受控 pnpm 锁文件。
+- 重写本地、CI、部署和审计命令。
+- 在全新目录验证安装、构建、Prisma、测试和生产部署。
+- 完成后再移除 npm 锁文件。
+
+不得长期同时维护两种锁文件,也不得只改锁文件而不改部署流程。
+
+## 13. 不建议在下一轮实施的动作
+
+- 不一次性为全部 API 开启全局严格 `ValidationPipe`。
+- 不直接执行 `npm audit fix --force`。
+- 不为了 Vite 的 500 KiB 原始体积警告替换整个 ECharts;当前 gzip 包体仍在预算内。
+- 不在安全补丁提交中同时拆分 SendChain。
+- 不以大量快照测试提高前端覆盖率。
+- 不擅自删除 `=`, `pnpm-lock.yaml` 或其他侧边任务文件。
+- 不把测试环境健康检查等同于登录后页面和 CMPP 全链验收。
+
+## 14. 验证矩阵
+
+| 层级 | 必须验证 |
+|---|---|
+| 静态 | TypeScript、ESLint、Prettier、结构安全门禁、`git diff --check` |
+| API | DTO 正负向、租户隔离、密码迁移、登录限流、账务和发送幂等 |
+| 前端 | 登录、权限、懒加载、loading/empty/error、确认操作、401/500 |
+| 数据库 | Prisma validate、95 项 migration 一致性、真实 PostgreSQL 关键查询 |
+| Redis | 验证码 TTL、登录锁定、多实例、Stream pending/lag |
+| Gateway | `go test ./... -count=1`、`go vet ./...`、5 份队列契约 |
+| 浏览器 | 登录后页面、控制台、请求、刷新、深层链接和移动端 |
+| 发布后 | 部署 commit、服务、端口、健康、日志、队列和临时数据清理 |
+
+## 15. 测试环境发布要求
+
+如下一轮包含部署,必须:
+
+1. 明确目标是测试环境 `100.93.204.60`,显式绕过 Clash/系统代理进行健康探测。
+2. 发布前重新建立:
+ - PostgreSQL custom dump。
+ - 当前运行目录归档。
+ - 环境、systemd 和 Nginx 配置。
+ - Redis RDB 和关键 Stream/队列状态。
+ - MinIO 数据或业务对象清单。
+ - 当前部署 commit 和 95 项 migration 状态。
+3. 校验 `pg_restore --list`、tar 目录、Redis RDB 和 SHA-256。
+4. 发布后核对所有相关服务、API/Gateway health、Redis pending/lag 和 error 日志。
+5. 清理临时测试账号、凭据、文件和脚本。
+6. 测试环境通过不自动授权预生产发布。
+
+## 16. 建议提交拆分
+
+1. `security: validate remaining client write payloads`
+2. `test: cover client dto rejection paths`
+3. `security: remove client-controlled tenant headers`
+4. `test: add frontend auth and route failure baseline`
+5. `test: enforce changed-file coverage`
+6. `security: add ip-aware login throttling`
+7. `build: document and remediate api audit paths`
+8. `build: add eslint and prettier checks`
+9. `refactor: extract inbound workflow responsibilities`
+10. `refactor: extract gateway submit responsibilities`
+11. `chore: enforce the selected package manager`
+
+每个提交只处理一个主题,不混入测试环境部署资产、历史文档改写或其他会话的工作区文件。
+
+## 17. 最终关闭标准
+
+| 项目 | 关闭证据 |
+|---|---|
+| 客户端 DTO | 所有写接口使用 class DTO 和严格管道;负向测试通过 |
+| 租户头清理 | 客户端不再发送/回退 tenantId;双租户矩阵通过 |
+| 前端测试 | 核心用例进入质量门禁并稳定运行 |
+| 覆盖率 | 全源口径稳定且修改文件满足增量门槛 |
+| 登录保护 | 账号/IP/组合/验证码限流和指标通过多实例测试 |
+| 依赖公告 | 每项有路径、影响、处理和回归证据 |
+| lint/format | ESLint 和 Prettier 真实执行,不再仅使用脚本别名 |
+| SendChain | 行为锁定后完成职责拆分,全链语义和性能不下降 |
+| 锁文件 | 唯一包管理器、唯一正式锁文件、全新安装可重复 |
+| 测试环境 | 恢复资产、真实后端、浏览器和发布后状态证据齐全 |
+
+## 18. 建议执行顺序
+
+```text
+C1 客户端 DTO
+ -> C2 租户头清理
+ -> C3 前端测试
+ -> C4 覆盖率缓冲
+ -> C5 登录保护
+ -> C6 依赖治理
+ -> C7 lint/format
+ -> C8 SendChain 专项拆分
+ -> C9 锁文件统一
+ -> 测试环境完整验收
+```
+
+其中 C1~C4 可作为下一轮独立交付;C8 必须保持为单独的高风险重构任务。
diff --git a/docs/code-quality-reassessment-20260828-v2.md b/docs/code-quality-reassessment-20260828-v2.md
new file mode 100644
index 0000000..ac92e35
--- /dev/null
+++ b/docs/code-quality-reassessment-20260828-v2.md
@@ -0,0 +1,190 @@
+# CMPP 平台代码质量复评报告 V2
+
+- 报告日期:2026-08-28
+- 复评时间:2026-08-28 12:38~12:57(Asia/Shanghai)
+- 复评基线:`3af145abe5eb8cae567bb18e29116b4940889258`
+- 对比基线:`171c7d38e8f17de0dd83570603da316d47c0d015`
+- 分支状态:`main`,本地 `HEAD` 与 `origin/main` 一致
+- 变更规模:58 个文件,新增 1,953 行、删除 487 行
+- 复评范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis、Go Gateway、自动化测试、依赖与仓库门禁、测试环境只读健康探测
+- 明确未执行:提交、推送、部署、服务重启、数据库写入、短信发送、压力测试、MinIO 业务对象写入、预生产或生产环境操作
+
+## 1. 复评结论
+
+本次采用两个相互独立的结论,避免把“代码整改完成”和“环境可发布”混为一谈:
+
+1. **代码整改:有条件通过。** 上一版的两个 P0 已在源码、自动化测试和结构门禁层面关闭;前端分包、覆盖率门槛、Redis 状态存储、数据库配置 fail closed、mock 解耦等整改有效。
+2. **发布就绪:待完整验收。** 2026-08-28 12:50 在 Clash 虚拟网卡/代理开启状态下探测测试环境时曾连续返回 HTTP 502;关闭 Clash 后使用 `curl --noproxy '*'` 绕过代理直连,12:56 连续 3 次访问 `/api/health` 均返回 HTTP 200、`status=ok`,TCP 12026 端口可达,连接耗时约 10 ms、总响应约 19~28 ms。此前 502 判定属于本地网络代理干扰,不是测试环境运行故障。
+
+当前代码质量综合参考分:**82/100**,较上一版 **58/100** 提升 24 分。该分数只表示仓库代码治理水平,不等价于功能验收通过率。
+
+测试环境外部 API 健康探测已经恢复为通过;但在重新核对部署版本、内部服务/队列状态并完成真实后端与登录后浏览器复验前,**仍不建议进入预生产发布**。
+
+## 2. 质量评级
+
+| 维度 | 上版 | 本次 | 复评结论 |
+|---|---:|---:|---|
+| 业务正确性与并发设计 | B | B+ | API/Gateway 回归通过,核心幂等与队列逻辑保持;未重跑真实 CMPP 全链。 |
+| 安全性 | D | B+ | 可信租户上下文和强密码哈希已落地;重点写接口校验仍非全覆盖。 |
+| 自动化测试 | C | B- | API 47 套 543 项通过并建立覆盖率阈值;前端测试仍为 0。 |
+| 前端性能 | C- | B+ | 路由懒加载和 ECharts 按需加载生效,入口 gzip 降至 107.38 KiB。 |
+| 可维护性 | C | C+ | mock 解耦和页面辅助逻辑拆分完成;两个 SendChain Service 仍超大,未建立 ESLint/Prettier。 |
+| 数据库工程 | B | B+ | Prisma 校验通过,生产/测试缺少 `DATABASE_URL` 时 fail closed;本轮未做真实数据执行计划复测。 |
+| 依赖与仓库卫生 | C- | B- | 根正式锁文件审计为 0;API 依赖树仍有 7 项公告,且未跟踪的旧 pnpm 锁文件会造成错误审计口径。 |
+| 运行与发布就绪 | 未评 | B- | 绕过本地代理后测试环境外部 `/api/health` 连续 3 次返回 200;本次未独立核对内部服务、队列和登录后业务链。 |
+
+## 3. 上一版问题关闭状态
+
+| 编号 | 原级别 | 本次状态 | 判定依据 |
+|---|---:|---|---|
+| CQ-SEC-001 客户端租户可伪造 | P0 | **已关闭(代码级)** | 会话中间件从数据库用户写入 `sessionTenantId`;客户端装饰器只读取可信上下文;伪造 header 返回 `CLIENT_TENANT_MISMATCH`;相关租户隔离测试通过。 |
+| CQ-SEC-002 无盐 SHA-256 密码 | P0 | **已关闭(代码级)** | 使用随机 salt 的版本化 scrypt、恒定时间比较、参数上限解析;旧 SHA-256 只读兼容并在正确登录后透明升级;写路径和工具门禁已覆盖。 |
+| CQ-API-001 缺少运行时输入校验 | P1 | **部分关闭** | 已新增严格 `ValidationPipe` 和重点客户端写 DTO;但未全局注册,部分凭据、Webhook、日志导出等写接口仍使用内联类型和裸 `@Body()`。 |
+| CQ-AUTH-001 验证码/失败计数进程内 Map | P1 | **基本关闭** | 已迁移 Redis TTL、`GETDEL` 和 Lua 原子计数,Redis 异常 fail closed;仍建议补 IP+账号双维度限流与容量指标。 |
+| CQ-FE-001 主包过大、无路由分包 | P1 | **已关闭** | 全部业务页采用懒加载;ECharts 按需注册;入口 gzip 107.38 KiB,低于 250 KiB 门槛,最大异步图表包 181.64 KiB,低于 190 KiB 门槛。 |
+| CQ-TEST-001 测试与验收层级不足 | P1 | **部分关闭** | API 覆盖率阈值已建立且本轮通过;Gateway 测试通过;前端仍无测试,登录后浏览器和真实全链未完成。 |
+| CQ-DEP-001 两个 high advisory | P2 | **原问题已关闭** | 正式 `package-lock.json` 使用 React Router 7.18.2、NanoID 3.3.18;按该锁文件重建审计结果为 0。 |
+| CQ-MAINT-001 超大文件、无静态风格门禁 | P2 | **部分关闭** | 页面辅助逻辑已拆分,增加结构质量脚本;两个 SendChain Service 仍为 86,253/53,421 字节,`lint` 实际为结构检查加 TypeScript,不是 ESLint/格式门禁。 |
+| CQ-CONFIG-001 数据库默认凭据 | P2 | **已关闭** | 非 development/test 环境缺少 `DATABASE_URL` 时启动失败,不再静默使用默认生产连接。 |
+| CQ-ARCH-001 生产代码依赖 mock | P2 | **已关闭** | 类型已迁至 `src/api/types`,结构门禁禁止生产页面重新导入 mock。 |
+| CQ-REPO-001 仓库产物与审计基线不稳定 | P2 | **部分关闭** | `*.tsbuildinfo` 已停止跟踪并加入忽略;但工作区仍有未跟踪的 `=` 和过期 `pnpm-lock.yaml`。 |
+
+## 4. 关键整改证据
+
+### 4.1 可信租户上下文
+
+- `api/src/auth/session-validation.middleware.ts:70-72`:客户端请求携带的租户头与登录用户租户不一致时返回 403,并将数据库用户租户写入 `request.sessionTenantId`。
+- `api/src/auth/current-tenant-id.decorator.ts:10-13`:只允许 client portal 读取可信租户上下文,不再回退到请求头。
+- 客户端认证、短信配置、账务、发送链、风险复核、用户等控制器已改用 `@CurrentTenantId()`。
+- 管理端 `@TenantId()` 仍存在于 `admin/files`、`admin/operation-logs`、`admin/billing` 等管理路由中,不属于客户端授权依据;结构门禁已禁止客户端控制器回退使用旧装饰器。
+
+判定:原 P0 的根因已消除。测试环境真实双租户结果在整改记录中有描述;本次只复核外部健康接口,没有重跑双租户矩阵,因此仍按代码级关闭记录。
+
+### 4.2 密码存储与迁移
+
+- `api/src/auth/password-hasher.ts:1-82`:版本化 scrypt、随机 salt、`timingSafeEqual`、参数边界和旧 SHA-256 识别均已实现。
+- 旧哈希兼容写窗口被限制为最长 2 小时;正常写路径默认生成新格式。
+- 登录成功后通过条件更新透明迁移旧哈希,错误密码不迁移。
+- 单测覆盖随机盐、错误密码、旧哈希验证/迁移、畸形参数和兼容窗口边界。
+
+判定:满足上一版“强哈希、版本参数、恒定时间比较、透明迁移”的最小关闭标准。测试环境仍保留的历史 SHA-256 数量来自整改记录,本次未直接查询数据库复核。
+
+### 4.3 登录状态与输入校验
+
+- `api/src/auth/session.service.ts:129-163`:验证码使用 Redis TTL,消费使用 `GETDEL`;失败计数使用 Redis Lua 原子逻辑。
+- `api/src/common/strict-validation.pipe.ts`:启用 transform、whitelist 和 forbidNonWhitelisted。
+- 重点客户端写接口已增加 class-validator DTO 和严格管道。
+- `api/src/open-api/client-open-api.controller.ts:15-18` 的凭据/Webhook 写入,以及 `api/src/operations/client-operations.controller.ts:78-84` 的日志导出仍使用内联 body,未套用严格校验管道。
+
+判定:验证码多实例一致性问题已关闭;输入校验只能判为阶段性完成,不能写成全 API 闭环。
+
+### 4.4 前端性能
+
+- `src/routes/AppRoutes.tsx:10-74`:业务页面统一路由级懒加载。
+- `src/components/ui/Chart.tsx:3-8`:只注册实际使用的 ECharts 图表、组件和 Canvas renderer。
+- 本次生产构建:入口 `index-*.js` 370.54 KiB,gzip 107.38 KiB;图表异步包 553.06 KiB,gzip 181.64 KiB。
+- 包体预算脚本通过:入口不高于 250 KiB gzip,异步包不高于 190 KiB gzip。
+
+判定:原主包 626.18 KiB gzip 的问题已实质关闭。Vite 仍提示图表 chunk 的未压缩体积超过 500 KiB,但其 gzip 体积在当前预算内,作为后续优化项而非阻断项。
+
+### 4.5 数据库与工程配置
+
+- API 正式构建通过。
+- Prisma Schema validate 通过;仓库存在 95 个 migration 目录。
+- 非开发/测试环境缺少 `DATABASE_URL` 时 fail closed。
+- 本轮没有连接真实 PostgreSQL 执行 `EXPLAIN (ANALYZE, BUFFERS)`,也没有独立核对测试库 95/95 migration;相关运行态数据仅见整改记录。
+
+## 5. 本次独立复测结果
+
+| 检查项 | 本次结果 |
+|---|---|
+| Git 基线 | `HEAD == origin/main == 3af145a...` |
+| 工作区 | 仅发现既有未跟踪 `=`、`pnpm-lock.yaml`;未修改或删除 |
+| 前端 TypeScript | 通过 |
+| Vite production build | 通过 |
+| Bundle budget | 通过;入口 107.38 KiB gzip,最大异步包 181.64 KiB gzip |
+| API TypeScript build | 通过 |
+| Prisma validate | 通过 |
+| API Jest | 47/47 套、543/543 项通过 |
+| API 全源覆盖率 | statements 59.84%,branches 51.29%,functions 60.15%,lines 62.60% |
+| API 覆盖率门槛 | 通过;门槛分别为 59%、50%、60%、62% |
+| Gateway `go test ./... -count=1` | 通过 |
+| Gateway `go vet ./...` | 通过 |
+| Gateway 队列契约 | 5/5 通过 |
+| 代码质量结构检查 | 通过 |
+| 依赖缓解/安全部署检查 | 通过 |
+| 根正式锁文件生产依赖审计 | 0 个公告 |
+| API 正式锁文件生产依赖审计 | 7 个公告:2 moderate、5 high |
+| 前端自动化测试 | 0 个测试文件,未通过验收出口标准 |
+| 测试环境 `/api/health` | **通过;关闭 Clash 后绕过代理直连,连续 3 次 HTTP 200、`status=ok`** |
+| 测试环境 TCP 12026 | **通过;可连接** |
+| 测试环境 SSH 只读核验 | 未完成;当前凭据认证失败 |
+
+覆盖率说明:整改结果文档记录的 69.77%/53.47%/71.08%/73.05% 使用 Jest 默认“已加载源码”口径;本报告显式使用 `collectCoverageFrom=src/**/*.ts` 的全源口径,得到 59.84%/51.29%/60.15%/62.60%。两个结果不是同一统计口径,不应直接比较。本次全源结果仍满足已设门槛,并略高于上一版全源基线。
+
+依赖审计说明:工作区未跟踪的 `pnpm-lock.yaml` 固定了旧 React Router 7.18.1 和 NanoID 3.3.16,直接运行 pnpm audit 会错误报出上一版两个 high。按正式、已跟踪的 `package-lock.json` 在临时目录重建锁文件后,根项目审计为 0。该旧锁文件仍应由文件所有者确认后清理或更新,避免 CI/开发者误用。
+
+## 6. 当前阻断项与剩余风险
+
+### 运行探测校正:此前 502 为本地代理干扰,不构成缺陷
+
+**证据链**
+
+- 2026-08-28 12:50,在 Clash 虚拟网卡/代理开启状态下,PowerShell Web 请求曾连续返回 HTTP 502。
+- 用户关闭 Clash 后,首次 PowerShell `-NoProxy` 请求出现一次 10 秒超时,随后请求开始恢复。
+- 2026-08-28 12:56,使用 `curl --noproxy '*'` 明确绕过代理连续请求 3 次,均返回 HTTP 200 和 `{"status":"ok","service":"cmpp-platform-api"}`。
+- 三次直连总响应时间分别约 28 ms、23 ms、19 ms,TCP 12026 检测为可连接。
+
+**校正结论**
+
+此前新增的 `CQ-OPS-001 / P0` 撤销,不计入缺陷和发布阻断。测试环境外部 API 当前可用,原整改记录中的 API 健康结论没有被本次复评推翻。
+
+**仍保留的验证边界**
+
+本次 SSH 凭据认证失败,因此没有独立读取部署标记、11 项服务状态、内部 Gateway health、Redis Stream pending/lag、migration 或服务器日志。这些项目继续引用整改发布记录,预生产前仍应在可用只读凭据下重新核对。后续私网健康探测应显式绕过系统代理,避免 Clash 等本地网络工具造成误判。
+
+### CQ-API-001 / P1:严格输入校验尚未覆盖全部写接口
+
+当前采取“重点接口逐步接入”而非全局管道。凭据创建、Webhook 更新和日志导出等入口仍使用运行时会被擦除的内联 TypeScript 类型。建议先覆盖所有 client POST/PUT/PATCH/DELETE,再评估 admin 高风险写接口;为字段长度、URL、枚举、数组大小和额外字段补负向测试。
+
+### CQ-TEST-001 / P1:前端与真实环境验收未闭环
+
+前端测试文件仍为 0;本轮没有可用登录态,无法验证登录后页面、控制台、请求、加载/空/错误/刷新状态。本次仅确认外部 API health,未重新执行真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 联动复验。
+
+### CQ-DEP-002 / P1:API 依赖树仍有 7 项生产审计公告
+
+按 `api/package-lock.json` 重建审计后为 2 moderate、5 high,涉及 ExcelJS 间接 UUID、Prisma 可选工具链、brace-expansion、Swagger 间接 js-yaml 等。公告并不等于当前业务路径全部可利用,但应建立“依赖路径、运行时是否打包/调用、可升级版本、兼容回归”的矩阵,不能只以根项目审计为 0 宣告供应链闭环。
+
+### CQ-MAINT-001 / P2:超大服务和风格门禁仍未治理
+
+`send-inbound-entry.service.ts` 约 84.2 KiB,`send-gateway-submit.service.ts` 约 52.2 KiB;职责仍集中。建议先补行为锁定测试,再按入站持久化、关联/去重、重试路由、下游投递和计费拆分。另应增加 ESLint 与 format check;当前 `npm run lint` 名称实际只执行结构脚本和 TypeScript 检查。
+
+### CQ-REPO-001 / P2:双锁文件口径仍可能误导审计
+
+正式锁文件是 `package-lock.json`,但未跟踪 `pnpm-lock.yaml` 仍存在且版本过期。应先确认其归属,再选择唯一包管理器和唯一锁文件;本报告没有擅自删除或更新用户保留文件。
+
+## 7. 发布建议
+
+当前建议为:**代码可进入下一轮验证,但暂停预生产发布。**
+
+最小充分关闭顺序:
+
+1. 使用显式绕过系统代理的方式复核测试环境,并在可用只读凭据下核对实际部署标记、内部服务和队列状态。
+2. 在测试环境重跑双租户隔离和旧密码透明迁移,不发送真实短信。
+3. 补齐剩余客户端写接口 DTO/严格校验,并增加负向测试。
+4. 建立最小前端测试集:登录、权限、路由懒加载失败、列表加载/空/错误、关键确认操作。
+5. 对 API 7 项公告建立升级兼容矩阵;优先处理直接运行时路径。
+6. 在单独任务中治理 SendChain 超大服务、ESLint/格式门禁和唯一锁文件。
+7. 只有在真实 PostgreSQL、Redis、MinIO、Gateway/CMPP 模拟器和登录后浏览器证据齐全后,再申请预生产发布授权。
+
+## 8. 最终判定
+
+- 原 P0 代码问题:**2/2 已关闭**。
+- 原 P1:**2 项关闭或基本关闭,2 项部分关闭**。
+- 原 P2:**3 项关闭,2 项部分关闭**。
+- 新增运行态阻断:**0 项**;此前 502 已确认是本地 Clash 代理干扰并撤销。
+- 自动化门禁:除 API 子树依赖审计和前端测试缺失外,其余本次执行项通过。
+- 代码质量:**82/100,有条件通过**。
+- 当前发布就绪:**待完整验收;无 502 运行态阻断**。
+
+本报告是对固定提交的独立复评和一次当前测试环境只读探测,不替代完整功能验收、生产安全评估或预生产发布审批。
diff --git a/docs/testing-progress.md b/docs/testing-progress.md
index 23dcf12..152cac3 100644
--- a/docs/testing-progress.md
+++ b/docs/testing-progress.md
@@ -4104,3 +4104,19 @@ git diff --check
- 采用两阶段密码安全发布并在每次覆盖前重新建恢复资产:`code-quality-before-d85ff859-20260828T035007Z`、`code-quality-compatible-d85ff859-20260828T035426Z`、`code-quality-before-fc4a6a7-20260828T040150Z`。三份均含PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO数据、状态快照、回退说明和SHA-256;恢复清单、pg_restore、tar和Redis校验通过。
- 最终收尾为95/95迁移、11项服务active、入口HTTP 200、API/Gateway健康、Redis PONG、`gateway.submit.results pending=0/lag=0`,发布后7项核心服务error级日志均0。浏览器真实登录页完整且控制台error/warning为0;未自动输入账号密码/验证码,登录后深层页面仍保留为人工浏览器验收边界。
- 本轮没有发送短信、压测、修改余额/应用/通道或写入MinIO业务对象,也没有执行新的CMPP模拟器全链;两个SendChain超大Service未在安全发布中做高风险重构。完整证据和保留项见`docs/code-quality-remediation-result-20260828.md`。只操作测试环境`100.93.204.60`,预生产未访问或修改。
+
+## 2026-08-28 代码质量 V2 复评与测试环境健康探测校正
+
+- 固定`3af145abe5eb8cae567bb18e29116b4940889258`基线重新复评,原两个P0在代码、单测和结构门禁层面均已关闭;API 47套543项、全源覆盖率59.84/51.29/60.15/62.60、前后端构建、bundle budget、Gateway全包测试/go vet、Prisma及队列契约通过。正式报告为`docs/code-quality-reassessment-20260828-v2.md`。
+- 首次外部健康探测时本机开启Clash虚拟网卡/代理,`100.93.204.60:12026/api/health`曾连续返回HTTP 502。关闭Clash后使用`curl --noproxy '*'`明确绕过代理,连续3次均返回HTTP 200、`status=ok`,总响应约19~28ms,TCP 12026可连接;因此撤销“测试环境502”P0判定,确认为本地代理干扰,不是服务端故障。
+- 本轮SSH只读认证未成功,未独立读取部署标记、内部服务、Redis Stream、migration和服务器日志;这些运行态项目继续保留为预生产前复核边界。未部署、未重启服务、未写库、未发送短信、未压测,预生产未访问或修改。
+
+## 2026-08-28 代码质量持续优化执行
+
+- 按`docs/code-quality-continuous-optimization-plan-20260828.md`实施非“暂不建议投入”项:补齐客户端凭据、Webhook、导出、文件、用户等运行时DTO与负向校验;浏览器端不再为客户端请求发送或回退租户ID,服务端继续只从认证会话取得客户端租户。
+- 登录保护扩展为账号、来源IP和账号+IP三维失败计数,并增加IP维度验证码频率限制;Redis键只保存规范化值的SHA-256摘要。账号锁定仍为24小时,IP阈值为15分钟窗口30次,组合阈值为24小时5次;Prometheus新增有界事件/范围指标。
+- 建立Vitest、Testing Library、jsdom、MSW前端测试基座,覆盖登录错误中文化、异步路由异常、客户端租户头禁止、401/403/500、文件/表单请求、用户列表loading/empty/error及删除确认。33项前端测试通过,纳入覆盖范围的核心模块为statements 88.19%、branches 84.51%、functions 84%、lines 87.94%,门禁为80/70/80/80。
+- API增量覆盖门禁覆盖本轮新增DTO、动态JSON校验及登录安全逻辑,28项定向测试通过,覆盖率为86.78/78.29/95.83/89.84。API全量51套563项通过,全源覆盖率为70.44/53.77/71.76/73.61,较V2复评继续形成缓冲。
+- 引入ESLint flat config、Prettier和实际增量文件门禁;`packageManager`固定为npm 11.6.2,正式提交仍只维护根目录/API两份`package-lock.json`。历史受保护的未跟踪`pnpm-lock.yaml`和空文件`=`保持原状且不纳入提交。
+- 重新审计API依赖并将9项公告降至5项:Swagger、`js-yaml`、`fast-uri`和`brace-expansion`安全升级完成;剩余5项属于Prisma CLI同一开发/迁移链,唯一自动方案为破坏性降级Prisma 7至6,未执行`audit fix --force`。逐项路径和运行时判断见`docs/api-dependency-advisory-matrix-20260828.md`。
+- 本地前后端TypeScript、Vite生产构建、bundle budget、Gateway全包测试与`go vet`、95项Prisma schema、代码质量、安全部署及依赖缓解门禁均通过。按方案明确排除全局ValidationPipe一次性切换、强制audit修复、ECharts替换和SendChain高风险重构;发布与真实环境证据在后续条目补齐。
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..cef8e54
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,26 @@
+import eslint from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+ { ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', 'api/vendor/**'] },
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ['src/**/*.{ts,tsx}', 'tools/**/*.mjs'],
+ languageOptions: { globals: { ...globals.browser, ...globals.node } },
+ plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ '@typescript-eslint/no-explicit-any': 'warn',
+ },
+ },
+ {
+ files: ['api/src/**/*.ts'],
+ languageOptions: { globals: { ...globals.node, ...globals.jest } },
+ rules: { '@typescript-eslint/no-explicit-any': 'warn' },
+ },
+);
diff --git a/package-lock.json b/package-lock.json
index b20e1fb..1ba4622 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,10 +21,518 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "typescript": "^6.0.3"
+ "@vitest/coverage-v8": "^4.1.11",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "jsdom": "^30.0.1",
+ "msw": "^2.15.0",
+ "prettier": "^3.9.6",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.68.0",
+ "vitest": "^4.1.11"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
+ "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.3.0",
+ "@csstools/css-color-parser": "^4.1.10",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
+ "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz",
+ "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz",
+ "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
@@ -79,12 +587,390 @@
"license": "0BSD",
"optional": true
},
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
+ "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.0",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@inquirer/ansi": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
+ "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz",
+ "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^12.0.1",
+ "@inquirer/type": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz",
+ "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^2.0.7",
+ "@inquirer/figures": "^2.0.8",
+ "@inquirer/type": "^4.1.0",
+ "cli-width": "^4.1.0",
+ "fast-wrap-ansi": "^0.2.0",
+ "mute-stream": "^3.0.0",
+ "signal-exit": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz",
+ "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz",
+ "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@ioredis/commands": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
"integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
"license": "MIT"
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
@@ -163,6 +1049,31 @@
"win32"
]
},
+ "node_modules/@mswjs/interceptors": {
+ "version": "0.41.9",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
+ "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@open-draft/deferred-promise": "^2.2.0",
+ "@open-draft/logger": "^0.3.0",
+ "@open-draft/until": "^2.0.0",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "strict-event-emitter": "^0.5.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
+ "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -181,6 +1092,31 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@open-draft/deferred-promise": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
+ "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@open-draft/logger": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
+ "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.0"
+ }
+ },
+ "node_modules/@open-draft/until": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
+ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -438,6 +1374,112 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
+ "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -455,6 +1497,45 @@
"license": "0BSD",
"optional": true
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
@@ -483,10 +1564,311 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/set-cookie-parser": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz",
+ "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/statuses": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
+ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
+ "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/type-utils": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.68.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
+ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
+ "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.68.0",
+ "@typescript-eslint/types": "^8.68.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
+ "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
+ "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
+ "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
+ "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
+ "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.68.0",
+ "@typescript-eslint/tsconfig-utils": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
+ "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
+ "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
@@ -512,6 +1894,340 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.11",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
+ "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
"node_modules/bullmq": {
"version": "5.79.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz",
@@ -573,6 +2289,89 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/cluster-key-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
@@ -582,6 +2381,40 @@
"node": ">=0.10.0"
}
},
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -607,12 +2440,77 @@
"node": ">=12.0.0"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
@@ -636,6 +2534,20 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -645,6 +2557,16 @@
"node": ">=0.10"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -654,6 +2576,13 @@
"node": ">=8"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
@@ -664,6 +2593,317 @@
"zrender": "6.1.0"
}
},
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.415",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz",
+ "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -681,6 +2921,57 @@
}
}
},
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -695,6 +2986,174 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
+ "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graphql": {
+ "version": "16.14.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
+ "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/headers-polyfill": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz",
+ "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/set-cookie-parser": "^2.4.10",
+ "set-cookie-parser": "^3.0.1"
+ }
+ },
+ "node_modules/headers-polyfill/node_modules/set-cookie-parser": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
+ "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ioredis": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
@@ -717,6 +3176,251 @@
"url": "https://opencollective.com/ioredis"
}
},
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-node-process": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
+ "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
+ "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^6.0.5",
+ "@asamuzakjp/dom-selector": "^8.3.0",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.9.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -966,6 +3670,22 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -978,6 +3698,23 @@
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
"node_modules/lucide-react": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
@@ -996,6 +3733,84 @@
"node": ">=12"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz",
+ "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1033,6 +3848,62 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
+ "node_modules/msw": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz",
+ "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@inquirer/confirm": "^6.0.11",
+ "@mswjs/interceptors": "^0.41.3",
+ "@open-draft/deferred-promise": "^3.0.0",
+ "@types/statuses": "^2.0.6",
+ "cookie": "^1.1.1",
+ "graphql": "^16.13.2",
+ "headers-polyfill": "^5.0.1",
+ "is-node-process": "^1.2.0",
+ "outvariant": "^1.4.3",
+ "path-to-regexp": "^6.3.0",
+ "picocolors": "^1.1.1",
+ "rettime": "^0.11.11",
+ "statuses": "^2.0.2",
+ "strict-event-emitter": "^0.5.1",
+ "tough-cookie": "^6.0.1",
+ "type-fest": "^5.5.0",
+ "until-async": "^3.0.2",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "msw": "cli/index.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mswjs"
+ },
+ "peerDependencies": {
+ "typescript": ">= 4.8.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
+ "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -1051,6 +3922,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
@@ -1072,6 +3950,147 @@
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
+ "node_modules/node-releases": {
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/outvariant": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
+ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1083,7 +4102,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1119,6 +4137,70 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1142,6 +4224,13 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
@@ -1180,6 +4269,20 @@
"react-dom": ">=18"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@ -1201,6 +4304,43 @@
"node": ">=4"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rettime": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
+ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
@@ -1234,6 +4374,19 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1258,6 +4411,49 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1267,12 +4463,147 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strict-event-emitter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
+ "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1289,18 +4620,117 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz",
+ "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.11"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.11",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz",
+ "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.8.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+ "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1309,6 +4739,40 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
+ "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.68.0",
+ "@typescript-eslint/parser": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
+ "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -1316,6 +4780,57 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/until-async": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz",
+ "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
@@ -1394,6 +4909,306 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
+ "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ },
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
diff --git a/package.json b/package.json
index d046178..5eb25e5 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "cmpp-platform-frontend",
"version": "0.1.0",
"private": true,
+ "packageManager": "npm@11.6.2",
"type": "module",
"scripts": {
"dev": "npm run build && vite preview --host 0.0.0.0",
@@ -17,9 +18,11 @@
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
"test:api": "npm --prefix api test",
+ "test:frontend": "vitest run",
+ "test:frontend:coverage": "vitest run --coverage",
"test:gateway": "npm run spike:gateway",
- "lint": "npm run quality:verify && tsc --noEmit",
- "format:check": "git diff --check",
+ "lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit",
+ "format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format",
"quality:verify": "node tools/quality/verify-code-quality.mjs",
"bundle:verify": "node tools/quality/verify-bundle-budget.mjs",
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
@@ -30,7 +33,7 @@
"verify:phase6": "npm run verify:phase5",
"verify:phase7": "npm run verify:phase6",
"verify:phase8": "npm run verify:phase7",
- "verify:quality": "npm run lint && npm run format:check && npm run build && npm run bundle:verify && npm --prefix api run test:coverage"
+ "verify:quality": "npm run lint && npm run format:check && npm run test:frontend:coverage && npm run build && npm run bundle:verify && npm --prefix api run test:incremental-coverage && npm --prefix api run test:coverage"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
@@ -46,10 +49,23 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.6",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "typescript": "^6.0.3"
+ "@vitest/coverage-v8": "^4.1.11",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "jsdom": "^30.0.1",
+ "msw": "^2.15.0",
+ "prettier": "^3.9.6",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.68.0",
+ "vitest": "^4.1.11"
},
"overrides": {
"nanoid": "3.3.18",
diff --git a/src/api/client/client.api.ts b/src/api/client/client.api.ts
index 485846b..a7b2cdf 100644
--- a/src/api/client/client.api.ts
+++ b/src/api/client/client.api.ts
@@ -1,10 +1,45 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
-import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types';
+import type {
+ ApplicationCmppParams,
+ CaptchaResponse,
+ ClientApplicationReportField,
+ ClientSignatureWorkspace,
+ ClientSmsApplication,
+ ClientSmsSignatureView,
+ ClientSmsTemplate,
+ DashboardResponse,
+ DeleteTargetRequest,
+ DeletionPreflight,
+ DeletionResult,
+ DeletionTargetType,
+ EnterpriseCertification,
+ FileObject,
+ HttpApiConfigResponse,
+ HttpApiCredential,
+ HttpApiRequestLog,
+ HttpWebhookDelivery,
+ HttpWebhookEndpoint,
+ ImportPreviewResponse,
+ ManagedUser,
+ OperationLogResponse,
+ PagedResult,
+ RechargeOrder,
+ SmsBatchTask,
+ SmsDrainageInfo,
+ SmsMessageRecord,
+ SmsUplinkMessage,
+ SystemLogExportResult,
+} from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload';
import type { LoginSession } from '../session';
-import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session';
+import {
+ clearSession,
+ dispatchSessionEvent,
+ hasRecentUserActivity,
+ readSession,
+ redirectToPortalLogin,
+} from '../session';
import { readErrorBody } from '../core/httpClient';
-import { DEFAULT_CLIENT_TENANT_ID } from '../types';
// Client methods moved intact during R1; tenant and session behavior still flows
// through the shared HTTP client and the existing upload path below.
@@ -12,126 +47,277 @@ export const clientApi = {
getCaptcha: () => request('/client/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
- listUsers: (
- query: { displayName?: string; login?: string; status?: string } = {},
- tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID,
- ) => request(withQuery('/client/users', query), { tenantId }),
- createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
- updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
- changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
- deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
- changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
- getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/operations/dashboard', { tenantId }),
- listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/enterprise-certification', { tenantId }),
- submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
+ listUsers: (query: { displayName?: string; login?: string; status?: string } = {}) =>
+ request(withQuery('/client/users', query)),
+ createUser: (body: {
+ username?: string;
+ email?: string;
+ phone?: string;
+ displayName: string;
+ password: string;
+ status?: string;
+ }) => request('/client/users', { method: 'POST', body: JSON.stringify(body) }),
+ updateUser: (
+ id: string,
+ body: { username?: string; email?: string; phone?: string; displayName?: string; status?: string },
+ ) => request(`/client/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
+ changeUserStatus: (id: string, status: string) =>
+ request(`/client/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
+ deleteUser: (id: string) => request(`/client/users/${id}`, { method: 'DELETE' }),
+ changeUserPassword: (id: string, password: string) =>
+ request(`/client/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
+ getDashboard: () => request('/client/operations/dashboard'),
+ listEnterpriseCertifications: () => request('/client/enterprise-certification'),
+ submitEnterpriseCertification: (body: {
+ companyName: string;
+ licenseNo?: string;
+ contactName?: string;
+ contactPhone?: string;
+ materials?: Record;
+ }) =>
request('/client/enterprise-certification', {
method: 'POST',
- tenantId,
- body: JSON.stringify({ ...body, tenantId }),
+ body: JSON.stringify(body),
}),
- listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/operations/system-logs', query), { tenantId }),
- exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
- request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
- listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/billing/orders', { tenantId }),
- listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/billing/orders', query), { tenantId }),
- listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/applications', { tenantId }),
- listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/applications', query), { tenantId }),
- listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/application-options', { tenantId }),
- getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
- getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api`, { tenantId }),
- listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }),
- createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
- revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
- listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }),
- saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
- listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/requests`, { tenantId }),
- listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }),
- retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
- listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
- listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
- listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/signatures', { tenantId }),
- listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/signature-options', { tenantId }),
- getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/signatures-workspace', query), { tenantId }),
- createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
- updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
- submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
- changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
- createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
- createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
- updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
- changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
- listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/templates', {
- status: query.status,
- keyword: query.keyword,
- includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
- }), { tenantId }),
- listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/templates', {
- keyword: query.keyword,
- includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
- page: query.page,
- pageSize: query.pageSize,
- }), { tenantId }),
- createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
- updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
- submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
- changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
- getDeletionPreflight: (type: Exclude, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
- deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
- listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/send/batch-tasks', query), { tenantId }),
- listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/send/batch-tasks', query), { tenantId }),
- cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
- createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
- previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
- confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
- listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
- listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/operations/messages', query), { tenantId }),
- listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request(withQuery('/client/operations/uplink-messages', query), { tenantId }),
- listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
- request>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
+ listSystemLogs: (query: {
+ keyword?: string;
+ level?: string;
+ module?: string;
+ range?: string;
+ createdAtFrom?: string;
+ createdAtTo?: string;
+ page?: number;
+ pageSize?: number;
+ }) => request(withQuery('/client/operations/system-logs', query)),
+ exportSystemLogs: (query: {
+ keyword?: string;
+ level?: string;
+ module?: string;
+ range?: string;
+ createdAtFrom?: string;
+ createdAtTo?: string;
+ }) =>
+ request('/client/operations/system-logs/exports', {
+ method: 'POST',
+ body: JSON.stringify(query),
+ }),
+ listOrders: () => request('/client/billing/orders'),
+ listOrdersPage: (query: { page: number; pageSize: number }) =>
+ request>(withQuery('/client/billing/orders', query)),
+ listApplications: () => request('/client/applications'),
+ listApplicationsPage: (query: { page: number; pageSize: number }) =>
+ request>(withQuery('/client/applications', query)),
+ listApplicationOptions: () => request('/client/application-options'),
+ getApplicationCmppParams: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/cmpp-params`),
+ getApplicationHttpApiConfig: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/http-api`),
+ listHttpApiCredentials: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/http-api/credentials`),
+ createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }) =>
+ request(`/client/applications/${applicationId}/http-api/credentials`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ revokeHttpApiCredential: (applicationId: string, credentialId: string) =>
+ request<{ id: string; status: string }>(
+ `/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`,
+ { method: 'POST', body: JSON.stringify({}) },
+ ),
+ listHttpWebhooks: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/http-api/webhooks`),
+ saveHttpWebhook: (
+ applicationId: string,
+ eventType: 'receipt' | 'uplink',
+ body: { url: string; rotateSecret?: boolean; status?: string },
+ ) =>
+ request(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ }),
+ listHttpApiRequests: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/http-api/requests`),
+ listHttpWebhookDeliveries: (applicationId: string) =>
+ request(`/client/applications/${applicationId}/http-api/webhook-deliveries`),
+ retryHttpWebhookDelivery: (applicationId: string, deliveryId: string) =>
+ request<{ id: string; status: string }>(
+ `/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`,
+ { method: 'POST', body: JSON.stringify({}) },
+ ),
+ listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage') =>
+ request(
+ withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }),
+ ),
+ listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
+ request(withQuery('/client/report-fields/common', { reportType })),
+ listSignatures: () => request('/client/signatures'),
+ listSignatureOptions: () => request('/client/signature-options'),
+ getSignatureWorkspace: (
+ query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
+ ) => request(withQuery('/client/signatures-workspace', query)),
+ createSignature: (body: {
+ applicationId?: string;
+ name: string;
+ purpose?: string;
+ drainageInfo?: Record;
+ }) => request('/client/signatures', { method: 'POST', body: JSON.stringify(body) }),
+ updateSignature: (
+ id: string,
+ body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record },
+ ) => request(`/client/signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
+ submitSignature: (id: string) =>
+ request(`/client/signatures/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
+ changeSignatureStatus: (id: string, status: string) =>
+ request(`/client/signatures/${id}/status`, {
+ method: 'POST',
+ body: JSON.stringify({ status }),
+ }),
+ createSignatureMaterial: (
+ id: string,
+ body: { fileObjectId?: string; materialType: string; title: string; description?: string },
+ ) =>
+ request>(`/client/signatures/${id}/materials`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ createDrainageInfo: (
+ signatureId: string,
+ body: { siteName: string; url: string; remark?: string; reportValues?: Record },
+ ) =>
+ request(`/client/signatures/${signatureId}/drainage-infos`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ updateDrainageInfo: (
+ id: string,
+ body: { siteName?: string; url?: string; remark?: string; reportValues?: Record },
+ ) => request(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
+ changeDrainageInfoStatus: (id: string, status: string) =>
+ request(`/client/drainage-infos/${id}/status`, {
+ method: 'POST',
+ body: JSON.stringify({ status }),
+ }),
+ listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}) =>
+ request(
+ withQuery('/client/templates', {
+ status: query.status,
+ keyword: query.keyword,
+ includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
+ }),
+ ),
+ listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }) =>
+ request>(
+ withQuery('/client/templates', {
+ keyword: query.keyword,
+ includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
+ page: query.page,
+ pageSize: query.pageSize,
+ }),
+ ),
+ createTemplate: (body: {
+ applicationId: string;
+ signatureId?: string;
+ name: string;
+ content: string;
+ category?: string;
+ variables?: Array<{ name: string; example?: string; required?: boolean }>;
+ }) => request('/client/templates', { method: 'POST', body: JSON.stringify(body) }),
+ updateTemplate: (
+ id: string,
+ body: {
+ applicationId?: string;
+ signatureId?: string | null;
+ name?: string;
+ content?: string;
+ category?: string;
+ auditStatus?: string;
+ variables?: Array<{ name: string; example?: string; required?: boolean }>;
+ },
+ ) => request(`/client/templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
+ submitTemplate: (id: string) =>
+ request(`/client/templates/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
+ changeTemplateStatus: (id: string, status: string) =>
+ request(`/client/templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
+ getDeletionPreflight: (type: Exclude, id: string) =>
+ request(`/client/deletions/${type}/${id}/preflight`),
+ deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest) =>
+ request(`/client/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
+ listBatchTasks: (query: { status?: string } = {}) =>
+ request(withQuery('/client/send/batch-tasks', query)),
+ listBatchTasksPage: (query: {
+ status?: string;
+ keyword?: string;
+ applicationKeyword?: string;
+ createdAtFrom?: string;
+ createdAtTo?: string;
+ page: number;
+ pageSize: number;
+ }) => request>(withQuery('/client/send/batch-tasks', query)),
+ cancelBatchTask: (id: string) =>
+ request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', body: JSON.stringify({}) }),
+ createBatchTask: (body: {
+ applicationId?: string;
+ templateId?: string;
+ content: string;
+ category?: string;
+ phones: string[];
+ sendMode?: 'immediate' | 'scheduled';
+ scheduledAt?: string;
+ variables?: Record;
+ }) => request('/client/send/batch-tasks', { method: 'POST', body: JSON.stringify(body) }),
+ previewImport: (body: {
+ applicationId?: string;
+ content: string;
+ fileName?: string;
+ delimiter?: ',' | '\t';
+ requiredVariables?: string[];
+ }) => request('/client/send/imports/preview', { method: 'POST', body: JSON.stringify(body) }),
+ confirmImport: (body: {
+ applicationId?: string;
+ templateId?: string;
+ content: string;
+ category?: string;
+ importContent: string;
+ sendMode?: 'immediate' | 'scheduled';
+ scheduledAt?: string;
+ requiredVariables?: string[];
+ variables?: Record;
+ }) => request('/client/send/imports/confirm', { method: 'POST', body: JSON.stringify(body) }),
+ listBatchTaskMessages: (id: string) => request(`/client/send/batch-tasks/${id}/messages`),
+ listMessages: (
+ query: {
+ applicationId?: string;
+ taskId?: string;
+ messageId?: string;
+ phoneNumber?: string;
+ contentKeyword?: string;
+ status?: string;
+ queuedAtFrom?: string;
+ queuedAtTo?: string;
+ page?: number;
+ pageSize?: number;
+ } = {},
+ ) => request>(withQuery('/client/operations/messages', query)),
+ listUplinkMessages: (
+ query: {
+ channelId?: string;
+ applicationId?: string;
+ phoneNumber?: string;
+ keyword?: string;
+ startTime?: string;
+ endTime?: string;
+ } = {},
+ ) => request(withQuery('/client/operations/uplink-messages', query)),
+ listUplinkMessagesPage: (query: {
+ channelId?: string;
+ applicationId?: string;
+ phoneNumber?: string;
+ keyword?: string;
+ startTime?: string;
+ endTime?: string;
+ page: number;
+ pageSize: number;
+ }) => request>(withQuery('/client/operations/uplink-messages', query)),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
assertUploadFileSize(file);
const form = new FormData();
@@ -143,7 +329,12 @@ export const clientApi = {
const headers = new Headers();
const session = readSession('client');
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
- const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
+ const response = await fetch('/api/client/files/upload', {
+ method: 'POST',
+ headers,
+ body: form,
+ credentials: 'same-origin',
+ });
if (response.status === 401 && session) {
const error = await readErrorBody(response.clone());
if (error.code === 'SESSION_LOCKED') {
diff --git a/src/api/core/httpClient.test.ts b/src/api/core/httpClient.test.ts
new file mode 100644
index 0000000..088d919
--- /dev/null
+++ b/src/api/core/httpClient.test.ts
@@ -0,0 +1,221 @@
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
+import { setReauthenticationHandler, writeSession } from '../session';
+
+let receivedTenantHeader: string | null = null;
+const server = setupServer(
+ http.get('http://localhost/api/client/users', ({ request: incoming }) => {
+ receivedTenantHeader = incoming.headers.get('x-tenant-id');
+ return HttpResponse.json([]);
+ }),
+ http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
+ receivedTenantHeader = incoming.headers.get('x-tenant-id');
+ return HttpResponse.json([]);
+ }),
+ http.get('http://localhost/api/client/failure', () =>
+ HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
+ ),
+);
+
+const nativeFetch = globalThis.fetch;
+
+beforeAll(() => {
+ server.listen({ onUnhandledRequest: 'error' });
+ const interceptedFetch = globalThis.fetch;
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
+ interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
+});
+afterEach(() => {
+ receivedTenantHeader = null;
+ server.resetHandlers();
+});
+afterAll(() => {
+ server.close();
+ globalThis.fetch = nativeFetch;
+ setReauthenticationHandler();
+});
+
+describe('request tenant and error boundaries', () => {
+ it('never forwards an explicit tenant header to a client route', async () => {
+ await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
+ expect(receivedTenantHeader).toBeNull();
+ });
+
+ it('keeps explicit tenant selection for non-client administrative routes', async () => {
+ await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
+ expect(receivedTenantHeader).toBe('tenant-admin-selected');
+ });
+
+ it('surfaces a backend JSON error message instead of a generic status', async () => {
+ await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
+ });
+
+ it.each([
+ [HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
+ [HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
+ [new HttpResponse(null, { status: 503 }), '请求失败(503)'],
+ ])('normalizes additional server error body shapes', async (response, expected) => {
+ server.use(http.get('http://localhost/api/client/error-shape', () => response));
+ await expect(request('/client/error-shape')).rejects.toThrow(expected);
+ });
+
+ it('parses empty, JSON and plain-text error bodies safely', async () => {
+ await expect(readErrorBody(new Response())).resolves.toEqual({});
+ await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
+ code: 'BAD',
+ message: ['字段一', '字段二'],
+ });
+ await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
+ });
+
+ it('does not redirect a rejected login attempt', async () => {
+ server.use(
+ http.post('http://localhost/api/client/auth/login', () =>
+ HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
+ ),
+ );
+ await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
+ 'Invalid login or password',
+ );
+ });
+
+ it('can suppress a session redirect for a background probe', async () => {
+ server.use(
+ http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
+ );
+ await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
+ });
+
+ it('raises the server lock message for an authenticated session', async () => {
+ writeSession({
+ portal: 'client',
+ user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
+ idleTimeoutSeconds: 1,
+ lockRecoverySeconds: 1,
+ absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
+ lastActivityAt: new Date().toISOString(),
+ recentAuthenticationExpiresAt: new Date().toISOString(),
+ });
+ server.use(
+ http.get('http://localhost/api/client/locked', () =>
+ HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
+ ),
+ );
+ await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
+ });
+
+ it('reauthenticates once and retries a protected request', async () => {
+ writeSession({
+ portal: 'client',
+ user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
+ idleTimeoutSeconds: 7200,
+ lockRecoverySeconds: 14400,
+ absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
+ lastActivityAt: new Date().toISOString(),
+ recentAuthenticationExpiresAt: new Date().toISOString(),
+ });
+ let attempts = 0;
+ server.use(
+ http.post('http://localhost/api/client/protected', () => {
+ attempts += 1;
+ return attempts === 1
+ ? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
+ : HttpResponse.json({ success: true });
+ }),
+ );
+ const reauthenticate = vi.fn().mockResolvedValue(undefined);
+ setReauthenticationHandler(reauthenticate);
+ await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
+ expect(reauthenticate).toHaveBeenCalledOnce();
+ });
+
+ it('downloads blobs and preserves server text errors', async () => {
+ server.use(
+ http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
+ http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
+ );
+ await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
+ await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
+ });
+
+ it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
+ writeSession({
+ portal: 'admin',
+ user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
+ idleTimeoutSeconds: 1,
+ lockRecoverySeconds: 1,
+ absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
+ lastActivityAt: new Date().toISOString(),
+ recentAuthenticationExpiresAt: new Date().toISOString(),
+ });
+ let attempts = 0;
+ server.use(
+ http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
+ attempts += 1;
+ receivedTenantHeader = incoming.headers.get('x-tenant-id');
+ return attempts === 1
+ ? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
+ : new HttpResponse('csv');
+ }),
+ );
+ setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
+ await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
+ expect(receivedTenantHeader).toBe('tenant-1');
+ });
+
+ it('submits multipart forms without forcing a JSON content type', async () => {
+ let contentType = '';
+ server.use(
+ http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
+ contentType = incoming.headers.get('content-type') ?? '';
+ return HttpResponse.json({ id: 'file-1' });
+ }),
+ );
+ const form = new FormData();
+ form.set('file', new Blob(['data']), 'data.txt');
+ await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
+ expect(contentType).toContain('multipart/form-data; boundary=');
+ });
+
+ it('retries multipart forms after recent authentication and reports failures', async () => {
+ writeSession({
+ portal: 'client',
+ user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
+ idleTimeoutSeconds: 1,
+ lockRecoverySeconds: 1,
+ absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
+ lastActivityAt: new Date().toISOString(),
+ recentAuthenticationExpiresAt: new Date().toISOString(),
+ });
+ let attempts = 0;
+ server.use(
+ http.post('http://localhost/api/client/form-protected', () => {
+ attempts += 1;
+ return attempts === 1
+ ? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
+ : HttpResponse.json({ ok: true });
+ }),
+ );
+ setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
+ await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
+ server.use(
+ http.post('http://localhost/api/client/form-fail', () =>
+ HttpResponse.json({ error: '上传失败' }, { status: 400 }),
+ ),
+ );
+ await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
+ });
+
+ it('builds bounded queries and encoded download URLs', () => {
+ expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
+ '/client/messages?page=2&level=warn',
+ );
+ expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
+ '/api/client/files/folder%2Ffile%201/download?disposition=inline',
+ );
+ expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
+ expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
+ });
+});
diff --git a/src/api/core/httpClient.ts b/src/api/core/httpClient.ts
index 6e5e772..373c88e 100644
--- a/src/api/core/httpClient.ts
+++ b/src/api/core/httpClient.ts
@@ -2,7 +2,6 @@ import {
clearSession,
currentRouteForPortal,
dispatchSessionEvent,
- getSessionTenantId,
hasRecentUserActivity,
portalFromPath,
readSession,
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
suppressSessionRedirect?: boolean;
};
-
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
export async function readErrorBody(response: Response): Promise {
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise {
}
}
-
-export type SessionTiming = Pick;
+export type SessionTiming = Pick<
+ LoginSession,
+ | 'idleTimeoutSeconds'
+ | 'lockRecoverySeconds'
+ | 'absoluteExpiresAt'
+ | 'lastActivityAt'
+ | 'recentAuthenticationExpiresAt'
+>;
// Authentication failures are handled centrally so every domain API keeps the
// same lock, recovery and redirect behavior as the original adminApi facade.
@@ -87,9 +91,8 @@ export async function request(path: string, options: RequestOptions = {}): Pr
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
- const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
- if (tenantId) {
- headers.set('x-tenant-id', tenantId);
+ if (options.tenantId && !path.startsWith('/client')) {
+ headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
- const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
- if (tenantId) {
- headers.set('x-tenant-id', tenantId);
+ if (options.tenantId && !path.startsWith('/client')) {
+ headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401) {
@@ -156,7 +158,6 @@ export async function requestForm(path: string, form: FormData, reauthenticat
return response.json() as Promise;
}
-
export function withQuery(path: string, query: Record) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record ({ getCaptcha: vi.fn(), login: vi.fn() }));
+
+vi.mock('@/api/adminApi', () => ({
+ adminApi: { getCaptcha, login },
+ clientApi: { getCaptcha, login },
+}));
+
+vi.mock('@/components/ui', () => ({
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => (
+
+ ),
+ ClientLoginCanvas: () => ,
+ Input: ({ label, ...props }: React.InputHTMLAttributes & { label: string }) => (
+
+ ),
+ Modal: ({ children, open, title }: { children: React.ReactNode; open: boolean; title: string }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+}));
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ getCaptcha
+ .mockReset()
+ .mockResolvedValue({ captchaId: 'captcha-1', challenge: '12 + 3 = ?', expiresInSeconds: 300 });
+ login.mockReset();
+ });
+
+ it('renders client-only website return action and loads a captcha', async () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('link', { name: '返回官网' })).toHaveAttribute('href', 'https://www.lisglo.com');
+ expect(await screen.findByRole('button', { name: '12 + 3 = ?' })).toBeEnabled();
+ });
+
+ it('maps backend login errors to Chinese and refreshes the one-time captcha', async () => {
+ login.mockRejectedValue(new Error('Invalid login or password'));
+ render(
+
+
+ ,
+ );
+ await screen.findByRole('button', { name: '12 + 3 = ?' });
+ fireEvent.change(screen.getByLabelText('用户名/登录账号'), { target: { value: 'bad-user' } });
+ fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'bad-password' } });
+ fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: '15' } });
+ fireEvent.click(screen.getByRole('button', { name: '登录' }));
+ await waitFor(() => expect(screen.getAllByText('用户名或密码错误')).toHaveLength(2));
+ expect(getCaptcha).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not render client artwork or the website link for admin login', async () => {
+ render(
+
+
+ ,
+ );
+ await screen.findByRole('button', { name: '12 + 3 = ?' });
+ expect(screen.queryByTestId('client-canvas')).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: '返回官网' })).not.toBeInTheDocument();
+ expect(screen.getByText('运营端登录')).toBeVisible();
+ });
+
+ it.each([
+ ['login and password are required', '请输入用户名和密码'],
+ ['Captcha expired', '验证码已过期,请刷新后重试'],
+ ['Captcha is incorrect', '验证码错误,请重新输入'],
+ ['User is locked', '账号已锁定,请 24 小时后再试'],
+ ['Too many login attempts from this source', '登录尝试过于频繁,请稍后再试'],
+ ['User is disabled or deleted', '账号已停用或已删除'],
+ ['Only platform admins can login to admin portal', '该账号不是运营端管理员'],
+ ['Only enterprise admins linked to a tenant can login to client portal', '该账号不是已绑定企业的客户端管理员'],
+ ['unknown backend error', 'unknown backend error'],
+ ])('maps backend error %s to a stable user message', (backend, expected) => {
+ expect(loginErrorMessage(new Error(backend))).toBe(expected);
+ });
+
+ it('uses a safe fallback for non-error failures', () => {
+ expect(loginErrorMessage(null)).toBe('登录失败,请检查账号信息后重试');
+ });
+});
diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx
index 345055d..6d26522 100644
--- a/src/apps/LoginPage.tsx
+++ b/src/apps/LoginPage.tsx
@@ -1,23 +1,31 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
-import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
+import {
+ consumeSessionRecovery,
+ markUserActivity,
+ readSessionRecovery,
+ writeSession,
+ type Portal,
+} from '@/api/session';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
};
-function loginErrorMessage(err: unknown) {
+export function loginErrorMessage(err: unknown) {
const message = err instanceof Error ? err.message : '';
if (message.includes('Invalid login or password')) return '用户名或密码错误';
if (message.includes('login and password are required')) return '请输入用户名和密码';
if (message.includes('Captcha expired')) return '验证码已过期,请刷新后重试';
if (message.includes('Captcha is incorrect')) return '验证码错误,请重新输入';
if (message.includes('User is locked')) return '账号已锁定,请 24 小时后再试';
+ if (message.includes('Too many login attempts')) return '登录尝试过于频繁,请稍后再试';
if (message.includes('User is disabled or deleted')) return '账号已停用或已删除';
if (message.includes('Only platform admins can login to admin portal')) return '该账号不是运营端管理员';
- if (message.includes('Only enterprise admins linked to a tenant can login to client portal')) return '该账号不是已绑定企业的客户端管理员';
+ if (message.includes('Only enterprise admins linked to a tenant can login to client portal'))
+ return '该账号不是已绑定企业的客户端管理员';
return message || '登录失败,请检查账号信息后重试';
}
@@ -90,17 +98,39 @@ export function LoginPage({ portal }: LoginPageProps) {
{recovery.message ?? '登录会话已失效,请重新登录。'} 登录成功后将返回之前访问的页面。
) : null}
- setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
- setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
+ setLogin(event.target.value)}
+ placeholder="请输入用户名、邮箱或手机号"
+ value={login}
+ />
+ setPassword(event.target.value)}
+ placeholder="请输入密码"
+ type="password"
+ value={password}
+ />
- setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
+ setCaptchaText(event.target.value)}
+ placeholder="请输入计算结果"
+ value={captchaText}
+ />
{error ? {error}
: null}
-
- {!isAdmin ? 返回官网 : null}
+
+ {!isAdmin ? (
+
+ 返回官网
+
+ ) : null}
({
+ clientApi: {
+ listUsers: vi.fn(),
+ deleteUser: vi.fn(),
+ changeUserStatus: vi.fn(),
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ changeUserPassword: vi.fn(),
+ },
+}));
+
+vi.mock('@/api/adminApi', () => ({ clientApi }));
+vi.mock('@/components/ui', () => ({
+ Button: ({
+ children,
+ icon: _icon,
+ ...props
+ }: React.ButtonHTMLAttributes & { icon?: React.ReactNode }) => (
+
+ ),
+ Input: ({ label, ...props }: React.InputHTMLAttributes & { label: string }) => (
+
+ ),
+ Select: ({
+ label,
+ options,
+ ...props
+ }: React.SelectHTMLAttributes & {
+ label: string;
+ options: Array<{ label: string; value: string }>;
+ }) => (
+
+ ),
+ Tag: ({ children }: { children: React.ReactNode }) => {children},
+ Table: ({
+ columns,
+ data,
+ emptyText,
+ }: {
+ columns: Array<{ key: string; render?: (record: never) => React.ReactNode }>;
+ data: never[];
+ emptyText: string;
+ }) =>
+ data.length ? (
+
+ {data.map((row: never, index: number) => (
+
+ {columns.map((column) => (
+ {column.render?.(row)}
+ ))}
+
+ ))}
+
+ ) : (
+ {emptyText}
+ ),
+ Modal: ({
+ children,
+ footer,
+ open,
+ title,
+ }: {
+ children: React.ReactNode;
+ footer: React.ReactNode;
+ open: boolean;
+ title: string;
+ }) =>
+ open ? (
+
+ {children}
+ {footer}
+
+ ) : null,
+}));
+
+const user = {
+ id: 'user-1',
+ displayName: '截图用户',
+ username: 'screenshot',
+ email: 'user@example.com',
+ phone: '13800000000',
+ status: 'active',
+ roles: ['enterprise_admin'],
+};
+
+describe('ClientUsersPage states', () => {
+ beforeEach(() => {
+ Object.values(clientApi).forEach((mock) => mock.mockReset());
+ });
+
+ it('renders the empty state from a successful real API-shaped response', async () => {
+ clientApi.listUsers.mockResolvedValue([]);
+ render();
+ expect(await screen.findByText('暂无用户')).toBeVisible();
+ });
+
+ it('renders a backend loading error', async () => {
+ clientApi.listUsers.mockRejectedValue(new Error('用户服务暂不可用'));
+ render();
+ expect(await screen.findByText('用户服务暂不可用')).toBeVisible();
+ });
+
+ it('shows query loading and requires confirmation before deletion', async () => {
+ let resolveQuery: (value: (typeof user)[]) => void = () => undefined;
+ clientApi.listUsers
+ .mockResolvedValueOnce([user])
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveQuery = resolve;
+ }),
+ )
+ .mockResolvedValueOnce([]);
+ clientApi.deleteUser.mockResolvedValue(user);
+ render();
+ await screen.findByText('截图用户');
+ fireEvent.click(screen.getByRole('button', { name: '查询' }));
+ expect(screen.getByRole('button', { name: '查询中...' })).toBeDisabled();
+ resolveQuery([user]);
+ await screen.findByRole('button', { name: '查询' });
+ fireEvent.click(screen.getByRole('button', { name: '删除' }));
+ expect(screen.getByRole('dialog', { name: '删除用户' })).toHaveTextContent('确认删除用户 截图用户');
+ expect(clientApi.deleteUser).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: '确认' }));
+ await waitFor(() => expect(clientApi.deleteUser).toHaveBeenCalledWith('user-1'));
+ });
+});
diff --git a/src/apps/client/ClientUsersPage.tsx b/src/apps/client/ClientUsersPage.tsx
index 765570a..4e391cd 100644
--- a/src/apps/client/ClientUsersPage.tsx
+++ b/src/apps/client/ClientUsersPage.tsx
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
-import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
+import { clientApi, type ManagedUser } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
-import { readSession } from '@/api/session';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import './ClientUsersPage.css';
@@ -42,19 +41,19 @@ const emptyFilters: UserFilters = {
};
function toForm(user?: ManagedUser): UserForm {
- return user ? {
- displayName: user.displayName,
- username: user.username,
- email: user.email ?? '',
- phone: user.phone ?? '',
- status: user.status,
- password: '',
- } : emptyForm;
+ return user
+ ? {
+ displayName: user.displayName,
+ username: user.username,
+ email: user.email ?? '',
+ phone: user.phone ?? '',
+ status: user.status,
+ password: '',
+ }
+ : emptyForm;
}
export function ClientUsersPage() {
- const session = readSession('client');
- const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState([]);
const [filters, setFilters] = useState(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState(emptyFilters);
@@ -72,16 +71,15 @@ export function ClientUsersPage() {
const [querying, setQuerying] = useState(false);
async function loadUsers(query: UserFilters = appliedFilters) {
- if (!tenantId) return;
- setUsers(await clientApi.listUsers(query, tenantId));
+ setUsers(await clientApi.listUsers(query));
}
useEffect(() => {
- if (!tenantId) return;
- void clientApi.listUsers({}, tenantId)
+ void clientApi
+ .listUsers({})
.then(setUsers)
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
- }, [tenantId]);
+ }, []);
function updateFilter(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
@@ -122,26 +120,28 @@ export function ClientUsersPage() {
}
async function saveUser() {
- if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
+ if (
+ !form.displayName.trim() ||
+ (!form.email.trim() && !form.phone.trim()) ||
+ (creating && form.password.length < 6)
+ ) {
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
setFormError('');
- const body: UserPayload = {
+ const body = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
email: form.email,
phone: form.phone,
status: form.status,
- roleCode: 'enterprise_admin',
- operatorId: session?.user.id,
};
try {
if (creating) {
- await clientApi.createUser({ ...body, password: form.password }, tenantId);
+ await clientApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
- await clientApi.updateUser(editingUser.id, body, tenantId);
+ await clientApi.updateUser(editingUser.id, body);
}
setCreating(false);
setEditingUser(null);
@@ -159,9 +159,12 @@ export function ClientUsersPage() {
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
- await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
+ await clientApi.deleteUser(confirmAction.user.id);
} else {
- await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
+ await clientApi.changeUserStatus(
+ confirmAction.user.id,
+ confirmAction.user.status === 'active' ? 'disabled' : 'active',
+ );
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
@@ -181,55 +184,136 @@ export function ClientUsersPage() {
async function savePassword() {
if (!passwordUser) return;
- await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
+ await clientApi.changeUserPassword(passwordUser.id, newPassword);
setPasswordUser(null);
setNewPassword('');
}
- const columns = useMemo>>(() => [
- { key: 'name', title: '用户名', width: '140px', render: (record) => {record.displayName} },
- { key: 'email', title: '邮箱', render: (record) => {record.email ?? '-'} },
- { key: 'phone', title: '手机号', width: '160px', render: (record) => {record.phone ?? '-'} },
- { key: 'role', title: '角色', width: '130px', render: () => 企业管理员 },
- { key: 'status', title: '状态', width: '120px', render: (record) => {record.status === 'active' ? '正常' : '禁用'} },
- { key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => {formatDateTime(record.lastLoginAt)} },
- {
- key: 'actions',
- title: '操作',
- width: '290px',
- render: (record) => (
-
- } onClick={() => openEditor(record)} size="sm" variant="ghost">编辑
- } onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密
-
- } onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger">删除
-
- ),
- },
- ], []);
+ const columns = useMemo>>(
+ () => [
+ {
+ key: 'name',
+ title: '用户名',
+ width: '140px',
+ render: (record) => {record.displayName},
+ },
+ { key: 'email', title: '邮箱', render: (record) => {record.email ?? '-'} },
+ {
+ key: 'phone',
+ title: '手机号',
+ width: '160px',
+ render: (record) => {record.phone ?? '-'},
+ },
+ { key: 'role', title: '角色', width: '130px', render: () => 企业管理员 },
+ {
+ key: 'status',
+ title: '状态',
+ width: '120px',
+ render: (record) => (
+
+ {record.status === 'active' ? '正常' : '禁用'}
+
+ ),
+ },
+ {
+ key: 'lastLoginAt',
+ title: '最后登录时间',
+ width: '190px',
+ render: (record) => {formatDateTime(record.lastLoginAt)},
+ },
+ {
+ key: 'actions',
+ title: '操作',
+ width: '290px',
+ render: (record) => (
+
+ } onClick={() => openEditor(record)} size="sm" variant="ghost">
+ 编辑
+
+ }
+ onClick={() => {
+ setPasswordUser(record);
+ setNewPassword('');
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ 改密
+
+
+ }
+ onClick={() => openConfirm({ type: 'delete', user: record })}
+ size="sm"
+ variant="danger"
+ >
+ 删除
+
+
+ ),
+ },
+ ],
+ [],
+ );
return (
-
+
+
+
用户管理
-
} onClick={() => openEditor()} size="sm">添加用户
+
} onClick={() => openEditor()} size="sm">
+ 添加用户
+
-
updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
-
updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
+
updateFilter('displayName', event.target.value)}
+ placeholder="请输入用户姓名"
+ value={filters.displayName}
+ />
+
updateFilter('login', event.target.value)}
+ placeholder="用户名、邮箱或手机号"
+ value={filters.login}
+ />
{error ? {error}
: null}
@@ -237,38 +321,141 @@ export function ClientUsersPage() {
- {(creating || editingUser) ? (
+ {creating || editingUser ? (
>}
- onClose={() => { setCreating(false); setEditingUser(null); }}
+ footer={
+ <>
+
+
+ >
+ }
+ onClose={() => {
+ setCreating(false);
+ setEditingUser(null);
+ }}
open
size="xl"
title={creating ? '添加用户' : '编辑用户'}
>
-
updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
-
updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
-
updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
-
updateField('username', event.target.value)} value={form.username} />
- {creating ?
updateField('password', event.target.value)} type="password" value={form.password} /> : null}
-
) : null}
{passwordUser ? (
- >} onClose={() => setPasswordUser(null)} open title="修改密码">
+
+
+
+ >
+ }
+ onClose={() => setPasswordUser(null)}
+ open
+ title="修改密码"
+ >
- setNewPassword(event.target.value)} type="password" value={newPassword} />
+ setNewPassword(event.target.value)}
+ type="password"
+ value={newPassword}
+ />
) : null}
{confirmAction ? (
- >} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
- {confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}
- {confirmError ? {confirmError}
: null}
+
+
+
+ >
+ }
+ onClose={() => {
+ if (!confirming) setConfirmAction(null);
+ }}
+ open
+ title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}
+ >
+
+ {confirmAction.type === 'delete'
+ ? `确认删除用户 ${confirmAction.user.displayName}?`
+ : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}
+
+ {confirmError ? (
+
+ {confirmError}
+
+ ) : null}
) : null}
diff --git a/src/routes/RouteLoadBoundary.test.tsx b/src/routes/RouteLoadBoundary.test.tsx
new file mode 100644
index 0000000..b54e3fd
--- /dev/null
+++ b/src/routes/RouteLoadBoundary.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { RouteLoadBoundary } from './RouteLoadBoundary';
+
+function BrokenRoute(): never {
+ throw new Error('chunk load failed');
+}
+
+describe('RouteLoadBoundary', () => {
+ it('shows a recoverable Chinese error state when a route chunk throws', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载失败');
+ expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
+ });
+
+ it('renders healthy route content unchanged', () => {
+ render(
+
+ 正常页面
+ ,
+ );
+ expect(screen.getByText('正常页面')).toBeVisible();
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..c5873df
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,9 @@
+import '@testing-library/jest-dom/vitest';
+import { afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+});
diff --git a/tools/quality/run-changed-code-quality.mjs b/tools/quality/run-changed-code-quality.mjs
new file mode 100644
index 0000000..d3f2aa3
--- /dev/null
+++ b/tools/quality/run-changed-code-quality.mjs
@@ -0,0 +1,31 @@
+import { execFileSync, spawnSync } from 'node:child_process';
+import { resolve } from 'node:path';
+
+const root = resolve(import.meta.dirname, '../..');
+const mode = process.argv[2];
+if (!['lint', 'format'].includes(mode)) throw new Error('Usage: node run-changed-code-quality.mjs ');
+
+const base = process.env.QUALITY_BASE_REF ?? 'HEAD';
+const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--'], {
+ cwd: root,
+ encoding: 'utf8',
+});
+const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
+const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
+ /^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file),
+);
+
+if (!files.length) {
+ console.log(`No changed code files require ${mode}.`);
+ process.exit(0);
+}
+
+const executable =
+ process.platform === 'win32'
+ ? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd`
+ : mode === 'lint'
+ ? 'eslint'
+ : 'prettier';
+const args = mode === 'lint' ? files : ['--check', ...files];
+const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' });
+process.exit(result.status ?? 1);
diff --git a/tools/quality/verify-code-quality.mjs b/tools/quality/verify-code-quality.mjs
index 1f6764d..9c4508b 100644
--- a/tools/quality/verify-code-quality.mjs
+++ b/tools/quality/verify-code-quality.mjs
@@ -5,15 +5,24 @@ import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const violations = [];
-const productionFiles = execFileSync('git', ['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'], { cwd: root, encoding: 'utf8' })
- .split(/\r?\n/).filter(Boolean);
+const productionFiles = execFileSync(
+ 'git',
+ ['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'],
+ { cwd: root, encoding: 'utf8' },
+)
+ .split(/\r?\n/)
+ .filter(Boolean);
for (const file of productionFiles) {
const content = readFileSync(resolve(root, file), 'utf8');
if (/from\s+['"]@\/mock(?:\/|['"])/.test(content)) violations.push(`${file}: production code imports @/mock`);
}
-const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], { cwd: root, encoding: 'utf8' })
- .split(/\r?\n/).filter(Boolean);
+const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], {
+ cwd: root,
+ encoding: 'utf8',
+})
+ .split(/\r?\n/)
+ .filter(Boolean);
for (const file of clientControllers) {
const content = readFileSync(resolve(root, file), 'utf8');
const clientClassOffset = content.indexOf('export class Client');
@@ -23,8 +32,26 @@ for (const file of clientControllers) {
}
}
-const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], { cwd: root, encoding: 'utf8' }).trim();
-if (trackedBuildCaches) violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
+const clientApi = readFileSync(resolve(root, 'src/api/client/client.api.ts'), 'utf8');
+if (/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId|x-tenant-id/.test(clientApi)) {
+ violations.push(
+ 'src/api/client/client.api.ts: client requests must derive tenant scope from the authenticated server session',
+ );
+}
+const httpClient = readFileSync(resolve(root, 'src/api/core/httpClient.ts'), 'utf8');
+if (
+ /DEFAULT_CLIENT_TENANT_ID|getSessionTenantId/.test(httpClient) ||
+ !httpClient.includes("options.tenantId && !path.startsWith('/client')")
+) {
+ violations.push('src/api/core/httpClient.ts: tenant headers must be reserved for explicit non-client operations');
+}
+
+const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], {
+ cwd: root,
+ encoding: 'utf8',
+}).trim();
+if (trackedBuildCaches)
+ violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
const usersService = readFileSync(resolve(root, 'api/src/users/users.service.ts'), 'utf8');
if (/createHash\(['"]sha256['"]\)/.test(usersService)) {
@@ -35,14 +62,20 @@ if (/passwordHash\s*===|===\s*[^\n;]*passwordHash/.test(authService)) {
violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly');
}
const ensureAdmin = readFileSync(resolve(root, 'tools/deploy/ensure-production-admin.mjs'), 'utf8');
-if (/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin)
- || !ensureAdmin.includes("api/dist/auth/password-hasher.js")) {
- violations.push('tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher');
+if (
+ /createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin) ||
+ !ensureAdmin.includes('api/dist/auth/password-hasher.js')
+) {
+ violations.push(
+ 'tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher',
+ );
}
for (const relativePath of ['tools/deploy/ensure-production-admin.mjs', 'tools/smoke/real-env-smoke.mjs']) {
const content = readFileSync(resolve(root, relativePath), 'utf8');
- if (/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content)
- || !content.includes("api/dist/auth/password-hasher.js")) {
+ if (
+ /function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content) ||
+ !content.includes('api/dist/auth/password-hasher.js')
+ ) {
violations.push(`${relativePath}: user password writes must use the compiled API password hasher`);
}
}
@@ -54,6 +87,18 @@ if (packageJson.dependencies?.['react-router-dom'] !== '7.18.2') {
if (packageJson.overrides?.nanoid !== '3.3.18') {
violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline');
}
+const trackedAlternativeLocks = execFileSync('git', ['ls-files', 'pnpm-lock.yaml', 'yarn.lock'], {
+ cwd: root,
+ encoding: 'utf8',
+}).trim();
+if (trackedAlternativeLocks) {
+ violations.push(
+ `${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`,
+ );
+}
+if (packageJson.packageManager !== 'npm@11.6.2') {
+ violations.push('package.json: packageManager must pin the supported npm baseline');
+}
if (violations.length) {
console.error(violations.map((item) => `ERROR: ${item}`).join('\n'));
diff --git a/tools/security/verify-dependency-mitigations.mjs b/tools/security/verify-dependency-mitigations.mjs
index 478e676..9e85f4a 100644
--- a/tools/security/verify-dependency-mitigations.mjs
+++ b/tools/security/verify-dependency-mitigations.mjs
@@ -9,7 +9,10 @@ const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
assertVersionAtLeast(rootLock.packages['node_modules/react-router']?.version, [7, 18, 2], 'react-router');
assertVersionAtLeast(rootLock.packages['node_modules/nanoid']?.version, [3, 3, 18], 'nanoid');
-assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.8', 'brace-expansion-safe');
+assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.9', 'brace-expansion-safe');
+assertEqual(apiLock.packages['node_modules/fast-uri']?.version, '3.1.5', 'fast-uri');
+assertEqual(apiLock.packages['node_modules/js-yaml']?.version, '4.3.1', 'js-yaml');
+assertEqual(apiLock.packages['node_modules/@nestjs/swagger']?.version, '11.4.7', '@nestjs/swagger');
assertEqual(
apiLock.packages['node_modules/brace-expansion']?.resolved,
'vendor/brace-expansion-compat',
@@ -35,7 +38,7 @@ for (const filePath of sourceFiles(join(workspaceRoot, 'src'))) {
const apiRequire = createRequire(join(workspaceRoot, 'api', 'package.json'));
const expand = apiRequire('brace-expansion');
if (typeof expand !== 'function' || expand.EXPANSION_MAX_LENGTH !== 4_000_000) {
- throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.8 implementation');
+ throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.9 implementation');
}
assertEqual(expand('{a,b}{1,2}').join(','), 'a1,a2,b1,b2', 'brace-expansion legacy API');
@@ -51,7 +54,9 @@ for (const relativePath of [
}
}
-console.log('Dependency mitigations verified: PostCSS, React Router and NanoID patched; RSC unused; brace expansion bounded and compatible.');
+console.log(
+ 'Dependency mitigations verified: frontend baselines, Swagger YAML/URI parsers and bounded brace expansion are patched.',
+);
function sourceFiles(directory) {
return readdirSync(directory).flatMap((name) => {
@@ -64,7 +69,11 @@ function sourceFiles(directory) {
function assertVersionAtLeast(actual, minimum, label) {
if (!actual) throw new Error(`${label} is missing from package-lock.json`);
const parts = actual.split('.').map((part) => Number(part.replace(/\D.*$/u, '')));
- if (minimum.some((value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item))) {
+ if (
+ minimum.some(
+ (value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item),
+ )
+ ) {
throw new Error(`${label} ${actual} is older than ${minimum.join('.')}`);
}
}
diff --git a/vite.config.ts b/vite.config.ts
index 6cb3cc8..19c5611 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vite';
+import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
@@ -34,4 +34,16 @@ export default defineConfig({
},
},
},
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.test.{ts,tsx}'],
+ exclude: ['api/**', 'outputs/**', 'dist/**', 'node_modules/**'],
+ setupFiles: ['./src/test/setup.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json-summary'],
+ include: ['src/api/core/**/*.ts', 'src/apps/LoginPage.tsx', 'src/routes/RouteLoadBoundary.tsx'],
+ thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
+ },
+ },
});