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) => ( -
- - - - -
- ), - }, - ], []); + 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) => ( +
+ + + + +
+ ), + }, + ], + [], + ); return (
- + + +

用户管理

- +
- 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} + /> 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} - 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} + 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 }, + }, + }, });