diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e91f21c2e..eda75da41 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,7 @@ - [ ] I read `CONTRIBUTING.md`. - [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`. +- [ ] Schema changes, if any, bumped `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`. - [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed. - [ ] User-facing text changes, if any, updated all locale files. - [ ] Bitwarden client compatibility was considered for sync/API shape changes. @@ -21,10 +22,8 @@ ## Checks -- [ ] `npx tsc -p tsconfig.json --noEmit` -- [ ] `npx tsc -p webapp/tsconfig.json --noEmit` -- [ ] `npm run i18n:validate` -- [ ] `npm run build` +- [ ] `npm run verify` — type checks for all four tsconfigs, `npm test`, `npm run i18n:validate`, `npm run build` +- [ ] New or changed tests: `npm test` was run at least twice (some cleanup paths are gated on `Math.random()`, so one green run can be luck) ## Notes diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3f3db6f87..3457c4ac0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,10 @@ updates: day: "monday" time: "05:00" timezone: "Asia/Shanghai" + # 新版本发布后先等 7 天再提 PR(cooldown):避免上游一天连发几个补丁就刷出一串 PR, + # 也避免刚发布、尚未被广泛验证的版本立刻进仓。 + cooldown: + default-days: 7 open-pull-requests-limit: 5 groups: npm-minor-and-patch: @@ -26,6 +30,10 @@ updates: day: "monday" time: "05:10" timezone: "Asia/Shanghai" + # 同上。本生态的 open-pull-requests-limit 是 0,即不会开出版本更新 PR, + # 所以这条 cooldown 目前不影响实际行为,加它只为两个生态配置保持一致。 + cooldown: + default-days: 7 open-pull-requests-limit: 0 groups: github-actions: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 88cf64326..30fbcbb10 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,16 +5,19 @@ on: branches: - "**" -permissions: - contents: read - actions: read - security-events: write - packages: read +# 权限一律在 job 级声明:工作流级声明会让所有 job(含未来的新 job)共享同一套权限, +# 而 security-events: write 只有上传分析结果的那一步需要。 +permissions: {} jobs: analyze: name: CodeQL Analyze (${{ matrix.language }}) runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write strategy: fail-fast: false diff --git a/.github/workflows/security-extra.yml b/.github/workflows/security-extra.yml index c56525710..96658666d 100644 --- a/.github/workflows/security-extra.yml +++ b/.github/workflows/security-extra.yml @@ -48,35 +48,11 @@ jobs: upload-sarif: true fail-on-vuln: true - pnpm-audit: - name: pnpm audit - runs-on: ubuntu-latest - - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: 22 - - - name: Run pnpm audit - shell: bash - run: | - if [ ! -f pnpm-lock.yaml ]; then - echo "pnpm-lock.yaml not found, skip pnpm audit." - exit 0 - fi - - corepack enable - corepack prepare pnpm@10 --activate - pnpm audit --audit-level=high + # 说明:这里原本还有一个 `pnpm audit` job,但本仓库使用 npm(仅有 + # package-lock.json,无 pnpm-lock.yaml),该 job 的守卫 `if [ ! -f pnpm-lock.yaml ]` + # 必然直接 exit 0 —— 永远不执行任何审计。实测上方 osv job 的 + # `scan source --recursive` 会扫到 package-lock.json(321 个包), + # 覆盖范围等价,故移除该冗余 job。 semgrep: name: Semgrep CE Scan diff --git a/.github/workflows/sync-global-domains.yml b/.github/workflows/sync-global-domains.yml index 12cbb409c..e0366afa3 100644 --- a/.github/workflows/sync-global-domains.yml +++ b/.github/workflows/sync-global-domains.yml @@ -11,19 +11,24 @@ on: default: "main" type: string -permissions: - contents: write - pull-requests: write +# 权限一律在 job 级声明:工作流级声明会让所有 job 共享同一套过宽权限。 +permissions: {} jobs: sync-global-domains: runs-on: ubuntu-latest + # 需要建分支/提交(contents)并创建 PR(pull-requests)—— + # 这也是本文件的 checkout **不能**加 persist-credentials: false 的原因(与 verify.yml 相反)。 + permissions: + contents: write + pull-requests: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: - node-version: 22 + # 与 verify.yml、Cloudflare Workers Builds 共用同一版本来源(.nvmrc)。 + node-version-file: '.nvmrc' - name: Sync generated Bitwarden domains env: diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 000000000..78881a92a --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,45 @@ +name: Verify + +# 说明:本仓库已有一套测试与校验脚本(类型检查 / i18n 对齐 / 通知与 WebAuthn +# 安全测试 / 构建),但在本次补充前,CI 中没有任何一个 workflow 执行它们 —— +# 测试写了却无人自动运行,回归不会被拦截。此外 `pnpm audit` 步骤因项目使用 npm +# (仅有 package-lock.json)而被守卫跳过。此 workflow 补上“验证”这一环。 +# +# 另:scripts/security-audit-*.mjs 三个安全回归脚本此前在 CI、npm scripts 与 +# 文档中均无引用(死资产),现已通过 `test:security-audit` 纳入 `npm test`。 +# +# 本地复现:npm run verify + +on: + push: + branches: [main] + pull_request: + +# 仅需读取仓库内容 +permissions: + contents: read + +jobs: + verify: + name: Type check, i18n, tests, build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + # 本 job 不需要 git push,因此不把 GITHUB_TOKEN 写进 .git/config。 + # 否则 npm ci 期间任何依赖包的 postinstall 脚本都能读到该令牌。 + # 与 codeql.yml / security-extra.yml 保持一致。 + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + # 版本来源单一化:.nvmrc 同时被本 workflow 与 Cloudflare Workers Builds + # 的构建镜像读取(官方文档:NODE_VERSION / .nvmrc / .node-version)。 + # 24.18.0 是 Cloudflare 构建镜像预装的版本,因此三方零分叉、零下载。 + node-version-file: '.nvmrc' + cache: npm + - name: Install dependencies + run: npm ci + - name: Verify (typecheck + i18n + tests + build) + run: npm run verify diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..77f89092b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,32 @@ +# gitleaks 配置:只用于排除「刻意写死的假凭据」误报 —— 测试夹具里的自述式 JWT 密钥, +# 以及 Web 端 UI 演示数据里的假 SSH 私钥。 +# +# 为什么需要配置级白名单,而不是改代码或加行内注释: +# - `scripts/lib/test-harness.ts` 定义的测试用 JWT 密钥,取值是自述式字符串 +# `test-jwt-secret-at-least-32-characters-long`:变量名含 "secret"、熵值 3.602 +# 高于阈值,于是熵值类规则 `generic-api-key` 必然命中;另有 3 个测试文件把同一 +# 字面量写死在本地,合计 4 处(均出自提交 8efe2a6)。 +# - `webapp/src/lib/demo.ts` 的演示密码库里有一条名为 "Production SSH key" 的假条目: +# `notes` 字段自己就写着 `Fake SSH key material for UI preview.`,所谓私钥的主体 +# 是字面量 `DEMO-PRIVATE-KEY`。`private-key` 规则只认 `-----BEGIN/END ... PRIVATE +# KEY-----` 装甲头、不校验中间是否为合法 base64,因此必然命中(2026-06-23 首次报出)。 +# - gitleaks 的扫描单位是「**本次 push 范围内新增的行**」(日志可见它执行的是 +# `git log -p -U0 --no-merges --first-parent `)。因此即使后续提交删掉 +# 或改写这些行,只要 8efe2a6 仍在该范围内(例如合并进 main 的那次 push), +# 行内 `gitleaks:allow` 注释也无能为力 —— 只有配置级白名单能稳定生效。 +# +# 白名单按**那一个字面值**匹配,不是按目录、也不是按文件: +# 测试目录或演示数据里若真的混进其它凭据(哪怕同一个文件),其它取值与其它规则仍会照常报出。 +# +# 注意:本文件是在 gitleaks 8.24.3(CI 中 gitleaks-action 自动安装的版本)下验证的。 +# 升级 gitleaks 大版本时需复核 `[allowlist]` 的写法(新版本已改用 `[[allowlists]]`)。 + +[extend] +useDefault = true + +[allowlist] +description = "刻意写死的假凭据:测试用自述式 JWT 密钥 + UI 演示数据里的假 SSH 私钥" +regexes = [ + '''test-jwt-secret-at-least-32-characters-long''', + '''DEMO-PRIVATE-KEY''', +] diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..df6ae3370 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.21.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a85c5a1f3..1bcba8c45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,8 +109,63 @@ For new locales, update: - `webapp/src/lib/i18n/locales/*` - `scripts/i18n-utils.cjs` +### Tests + +`npm test` runs the whole suite. It needs no external services and no network: the +D1 and R2 bindings are backed by in-process implementations, so the tests execute +**real SQL** against the real schema (including the real shadow tables and the +final swap used by restore). + +```sh +npm test +``` + +Files worth knowing about when adding a test: + +- `scripts/lib/test-harness.ts` — shared fixture. Start from + `createSchemaDatabase()` / `insertUser()` instead of building a database by hand; + it also resets the process-scoped statics that would otherwise stop a second + database from getting a schema. +- `scripts/lib/d1-sqlite.ts` — a `D1Database` on Node's built-in `node:sqlite`. +- `scripts/lib/r2-memory.ts` — in-memory attachment bucket. +- `scripts/lib/sql-recorder.ts` — records the statements that were run. Two + counters, do not mix them up: `queries` counts prepared statements (use it for + query-plan assertions) while `roundTrips` counts database round trips (use it + for N+1 assertions). `batch([...])` is N prepares but **one** round trip. +- `scripts/lib/register-cloudflare-stub.mjs` — handler tests must be started as + `tsx --import ./scripts/lib/register-cloudflare-stub.mjs`, because + `cloudflare:workers` cannot be resolved under Node. This is already wired into + every `test:*-handler` script; copy one of them when adding another. + +Two rules that exist because each has already produced a false result: + +- **Run the suite at least twice** before calling a change green. Some cleanup + paths are gated on `Math.random()` and only run on a fraction of requests, so a + single passing run can be luck. When testing such a path, pin `Math.random` for + the duration of the test rather than hoping the path fires. +- **Never assert that two timestamps differ.** Calling `new Date().toISOString()` + twice within the same millisecond returns the same value. Pin a baseline + timestamp and assert that the new value is greater. + ## Recommended Checks +Before opening a pull request, run the same command CI runs: + +```sh +npm run verify +``` + +which is: + +```sh +npm run typecheck # tsconfig.json, webapp/, tsconfig.scripts.json, tsconfig.webapp-tests.json +npm run i18n:validate +npm test +npm run build +``` + +Narrower runs while iterating. + For most backend or shared changes: ```sh @@ -126,6 +181,13 @@ npx tsc -p webapp/tsconfig.json --noEmit npm run build ``` +For changes under `scripts/` (tests and tooling): + +```sh +npx tsc -p tsconfig.scripts.json --noEmit +npm test +``` + For documentation-only changes: ```sh diff --git a/README.md b/README.md index c02558656..c80af95d6 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ npm run deploy # Optional: KV mode npm run deploy:kv +# The first deploy pins the KV namespace id into wrangler.kv.toml (commit that change). +# If the account already has a namespace with a *similar* title, the script stops and asks +# you to choose explicitly instead of guessing (a wrong guess sends attachments elsewhere): +# node scripts/ensure-kv.cjs --id <32-hex> # reuse one of the candidates +# node scripts/ensure-kv.cjs --force-new # create a new namespace anyway # Local development npm run dev diff --git a/README_ZH.md b/README_ZH.md index 56fb13099..bf66e2d47 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -111,6 +111,11 @@ npm run deploy # 可选:KV 模式 npm run deploy:kv +# 首次部署会把账号里的 KV 命名空间 id 写回 wrangler.kv.toml(记得把这次改动一并提交)。 +# 若账号里已有「标题相近但名字不完全一致」的命名空间,脚本会停下来让你显式选择, +# 而不是替你猜(猜错会把附件写进另一个库): +# node scripts/ensure-kv.cjs --id <32 位 hex> # 复用其中一个 +# node scripts/ensure-kv.cjs --force-new # 确实要新建 # 本地开发 npm run dev diff --git a/SECURITY.md b/SECURITY.md index 11b9f7d10..b0508d7f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,9 @@ Use GitHub Private Vulnerability Reporting instead: 3. Click **Report a vulnerability**. 4. Submit the report privately. +Direct link (replace the owner if this file is reused by another fork): + + NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team. ## What to Include @@ -60,6 +63,42 @@ We aim to acknowledge valid private reports within 72 hours, investigate the iss Please do not publicly disclose vulnerability details before a fix or mitigation is available. +## Administrator Bootstrap and Role Assignment + +NodeWarden has no separate setup step or setup token. Administrator privilege is +assigned as follows. + +**First account.** The first account that registers on an instance is granted the +`admin` role, and the instance is then marked as registered. This is recorded in +the security audit log as `user.register.first_admin`. + +**Later accounts.** After the first account exists, registration requires an +invite code (`Invite code is required`, HTTP 403) and the new account gets the +default `user` role. These are recorded as `user.register.invite`. Registration +can therefore not be used to obtain administrator privilege on an existing +instance. + +**Recovery when no administrator exists.** If an instance ends up with no account +holding the `admin` role — for example the last administrator account was deleted +— the database bootstrap promotes the **earliest-created** account back to `admin` +on its next schema initialization. This is a system action with no actor, and is +recorded in the security audit log as `user.bootstrap.admin_promoted`. + +Two properties of that recovery path are worth knowing: + +* **Administrator accounts are not protected against deletion.** NodeWarden does + not block deleting the last administrator. The bootstrap exists so an instance + cannot become permanently unmanageable, but it is not a substitute for + administrative care on a multi-user instance. +* **The account that regains the role is selected by account creation time, not + by trust.** On a multi-user instance, make sure you intend to delete an + administrator account before doing so. + +The bootstrap only runs when the runtime schema is initialized or re-initialized +(for example after a schema version change), not on every request. Operators who +need tighter control over administrator assignment should edit the `users.role` +column directly and treat the bootstrap as a recovery mechanism only. + ## Supported Versions Security fixes are generally provided for the latest release and the latest code on the default branch. diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index d30dfa6f7..c88ef9e64 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -34,6 +34,14 @@ CREATE TABLE IF NOT EXISTS users ( verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, + -- YubiKey OTP:最多 5 个密钥槽 + NFC 开关。 + -- 与 storage-schema.ts 的运行时定义保持一致(原先仅运行时建表包含这 6 列)。 + yubikey_key1 TEXT, + yubikey_key2 TEXT, + yubikey_key3 TEXT, + yubikey_key4 TEXT, + yubikey_key5 TEXT, + yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -140,6 +148,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); +-- 清理用:DELETE FROM refresh_tokens WHERE expires_at < ? +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at); CREATE TABLE IF NOT EXISTS invites ( code TEXT PRIMARY KEY, @@ -165,6 +175,12 @@ CREATE TABLE IF NOT EXISTS audit_logs ( target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, + -- 操作者邮箱的**行内快照**(写入时抄一份)。 + -- actor_user_id 上的外键是 ON DELETE SET NULL,而 DELETE FROM users 在 + -- 「恢复备份」与「管理端删除用户」两条路径上都会跑 —— 那一刻该用户所有历史 + -- 日志的 actor_user_id 被置空且不会自愈。有了这份快照,被抹掉的只剩编号, + -- 邮箱仍留在同一行里。详见 storage-schema.ts 中同一列的说明。 + actor_email TEXT, FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); @@ -222,6 +238,9 @@ CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date); CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date); +-- 清理用:DELETE FROM auth_requests WHERE creation_date < ?(不带 user_id) +CREATE INDEX IF NOT EXISTS idx_auth_requests_creation_date + ON auth_requests(creation_date); CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( token TEXT PRIMARY KEY, @@ -232,6 +251,9 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( ); CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier); +-- 清理用:DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ? +CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_expires + ON trusted_two_factor_device_tokens(expires_at); CREATE TABLE IF NOT EXISTS totp_login_replays ( user_id TEXT NOT NULL, @@ -281,6 +303,10 @@ CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope); +-- 清理用的语句已拆成两条(见 storage-account-passkey-repo.ts): +-- `WHERE expires_at < ? OR used_at IS NOT NULL` 里的 OR 会让索引全部失效。 +CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_used_at + ON webauthn_challenges(used_at); -- Rate limiting CREATE TABLE IF NOT EXISTS login_attempts_ip ( @@ -289,8 +315,26 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip ( locked_until INTEGER, updated_at INTEGER NOT NULL ); +-- 清理用(storage-schema.ts 的 maybeCleanupLoginAttempts): +-- DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?) +CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_updated_at + ON login_attempts_ip(updated_at); CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); +-- 清理用:DELETE FROM used_attachment_download_tokens WHERE expires_at < ? +CREATE INDEX IF NOT EXISTS idx_used_attachment_download_tokens_expires + ON used_attachment_download_tokens(expires_at); + +-- 严格限流预算(storage-schema.ts 的 RateLimitService 使用)。 +-- 与 storage-schema.ts 的运行时定义保持一致(原先仅运行时建表包含此表)。 +CREATE TABLE IF NOT EXISTS rate_limit_buckets ( + bucket_key TEXT PRIMARY KEY, + count INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires + ON rate_limit_buckets(expires_at); diff --git a/package-lock.json b/package-lock.json index d0fdc3332..496cda2ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "LGPL-3.0", "dependencies": { "@noble/hashes": "^2.2.0", - "@simplewebauthn/server": "^13.3.2", + "@simplewebauthn/server": "^14.0.2", "@tanstack/react-query": "^5.101.2", "@zip.js/zip.js": "^2.8.26", "fflate": "^0.8.3", @@ -21,7 +21,7 @@ "wouter": "^3.10.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260630.1", + "@cloudflare/workers-types": "^5.20260911.1", "@preact/preset-vite": "^2.10.5", "@types/node": "^26.0.1", "autoprefixer": "^10.5.2", @@ -31,13 +31,13 @@ "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.1.3", - "wrangler": "^4.105.0" + "wrangler": "^4.131.1" } }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", "dev": true, "license": "MIT", "engines": { @@ -48,24 +48,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/compat-data": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-8.0.0.tgz", - "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", "dev": true, "license": "MIT", "engines": { @@ -73,20 +72,20 @@ } }, "node_modules/@babel/core": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/@babel/core/-/core-8.0.1.tgz", - "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.5.tgz", + "integrity": "sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-compilation-targets": "^8.0.0", - "@babel/helpers": "^8.0.0", - "@babel/parser": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-compilation-targets": "^8.0.5", + "@babel/helpers": "^8.0.5", + "@babel/parser": "^8.0.5", "@babel/template": "^8.0.0", - "@babel/traverse": "^8.0.0", - "@babel/types": "^8.0.0", + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", @@ -94,7 +93,7 @@ "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", - "semver": "^7.7.3" + "verkit": "^0.3.2" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -104,31 +103,17 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "node_modules/@babel/generator": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.5.tgz", + "integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^8.0.5", + "@babel/types": "^8.0.5", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" }, @@ -136,127 +121,118 @@ "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/core/node_modules/@babel/helper-globals": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-8.0.0.tgz", - "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "node_modules/@babel/helper-annotate-as-pure/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": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", + "node_modules/@babel/helper-annotate-as-pure/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": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", + "node_modules/@babel/helper-annotate-as-pure/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/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/template": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-8.0.0.tgz", - "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.5.tgz", + "integrity": "sha512-Qk8ahMGooH5mz6uuhoDvfZGkUf/Mf3RTBucVVl4MKx4LKMTv872TeW8O92h15iVtlN8wAROBIpI1aV6x1z0LCQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0" + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "verkit": "^0.3.2" }, "engines": { "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/core/node_modules/@babel/traverse": { + "node_modules/@babel/helper-globals": { "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-8.0.0.tgz", - "integrity": "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw==", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-globals": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.0", - "obug": "^2.1.1" - }, "engines": { "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/core/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "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/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@babel/helper-module-imports/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": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "node_modules/@babel/helper-module-imports/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.0", - "@babel/types": "^7.29.0", + "@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" @@ -265,142 +241,131 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/generator/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/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "node_modules/@babel/helper-module-imports/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", - "dependencies": { - "@babel/types": "^7.27.3" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", - "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", + "node_modules/@babel/helper-module-imports/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", - "dependencies": { - "@babel/compat-data": "^8.0.0", - "@babel/helper-validator-option": "^8.0.0", - "browserslist": "^4.24.0", - "lru-cache": "^11.0.0", - "semver": "^7.7.3" - }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "node_modules/@babel/helper-module-imports/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-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@babel/helper-module-imports/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/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "node_modules/@babel/helper-module-imports/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/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "node_modules/@babel/helper-module-imports/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/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@babel/helper-module-imports/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/@babel/helper-validator-option": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", - "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "node_modules/@babel/helper-module-imports/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", - "engines": { - "node": "^22.18.0 || >=24.11.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@babel/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", + "node_modules/@babel/helper-module-imports/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", - "dependencies": { - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } + "license": "MIT" }, - "node_modules/@babel/helpers/node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, "engines": { - "node": "^22.18.0 || >=24.11.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { + "node_modules/@babel/helper-string-parser": { "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", "dev": true, "license": "MIT", @@ -408,92 +373,64 @@ "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, "engines": { "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helpers/node_modules/@babel/template": { + "node_modules/@babel/helper-validator-option": { "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-8.0.0.tgz", - "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0" - }, "engines": { "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", + "node_modules/@babel/helpers": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.5.tgz", + "integrity": "sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5" }, "engines": { "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helpers/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz", + "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^8.0.5" }, "bin": { "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.0.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -503,17 +440,17 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -523,13 +460,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -538,52 +475,86 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "node_modules/@babel/plugin-transform-react-jsx/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/plugin-transform-react-jsx/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/plugin-transform-react-jsx/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/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.5.tgz", + "integrity": "sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.5", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.5", + "obug": "^2.1.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -613,9 +584,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz", - "integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260911.1.tgz", + "integrity": "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==", "cpu": [ "x64" ], @@ -630,9 +601,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz", - "integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260911.1.tgz", + "integrity": "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==", "cpu": [ "arm64" ], @@ -647,9 +618,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz", - "integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260911.1.tgz", + "integrity": "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==", "cpu": [ "x64" ], @@ -664,9 +635,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz", - "integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260911.1.tgz", + "integrity": "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==", "cpu": [ "arm64" ], @@ -681,9 +652,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz", - "integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260911.1.tgz", + "integrity": "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==", "cpu": [ "x64" ], @@ -698,9 +669,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260630.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260630.1.tgz", - "integrity": "sha512-yl+c9vwvko9UZ0frmtsHuwOh3BRHvNjLrfelAp5Akpqe1+Ho1UWekr3nmjJ1D64CH0Yb0K0oRMV4i7npOFzsog==", + "version": "5.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260911.1.tgz", + "integrity": "sha512-yiAvknjulcU85B3yB4aKOn9+l+garWP+AbHgsdCFckeRYDdhZ1rPULi64BDf52R3VTaKqxi47kF+o9ZbjsCt5g==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -717,33 +688,21 @@ "node": ">=12" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -752,9 +711,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -769,9 +728,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -786,9 +745,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -803,9 +762,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -820,9 +779,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -837,9 +796,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -854,9 +813,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -871,9 +830,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -888,9 +847,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -905,9 +864,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -922,9 +881,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -939,9 +898,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -956,9 +915,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -973,9 +932,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -990,9 +949,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1007,9 +966,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1024,9 +983,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1041,9 +1000,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1058,9 +1017,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1075,9 +1034,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1092,9 +1051,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1109,9 +1068,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1126,9 +1085,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1143,9 +1102,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1160,9 +1119,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1177,9 +1136,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1210,9 +1169,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz", - "integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1229,13 +1188,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.0" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz", - "integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1252,13 +1211,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.0" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz", - "integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -1266,7 +1225,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1276,9 +1235,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1293,9 +1252,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", - "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1310,9 +1269,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", - "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -1330,9 +1289,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", - "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -1350,9 +1309,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", - "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], @@ -1370,9 +1329,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", - "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], @@ -1390,9 +1349,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", - "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -1410,9 +1369,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", - "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -1430,9 +1389,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", - "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -1450,9 +1409,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", - "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -1470,9 +1429,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz", - "integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -1492,13 +1451,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.0" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz", - "integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -1518,13 +1477,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.0" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz", - "integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], @@ -1544,13 +1503,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.0" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz", - "integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], @@ -1570,13 +1529,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.0" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz", - "integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -1596,13 +1555,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.0" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz", - "integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -1622,13 +1581,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.0" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz", - "integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -1648,13 +1607,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz", - "integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -1674,18 +1633,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz", - "integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -1695,9 +1654,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz", - "integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], @@ -1705,7 +1664,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1715,9 +1674,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz", - "integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -1735,9 +1694,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz", - "integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -1755,9 +1714,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz", - "integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1775,27 +1734,16 @@ } }, "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==", + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/gen-mapping/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/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1807,21 +1755,21 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "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.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@levischuck/tiny-cbor": { @@ -1830,29 +1778,10 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -1900,150 +1829,215 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@peculiar/asn1-android": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", - "integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.4.tgz", + "integrity": "sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", - "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "@peculiar/asn1-x509-attr": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", - "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", - "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", - "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.7.0", - "@peculiar/asn1-pkcs8": "^2.7.0", - "@peculiar/asn1-rsa": "^2.7.0", - "@peculiar/asn1-schema": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", - "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", - "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.7.0", - "@peculiar/asn1-pfx": "^2.7.0", - "@peculiar/asn1-pkcs8": "^2.7.0", - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "@peculiar/asn1-x509-attr": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", - "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", - "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.6", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", - "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-schema": "^2.9.4", "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.6", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", - "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.4.tgz", + "integrity": "sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/asn1-x509": "^2.7.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-asym-key": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/utils": { @@ -2056,20 +2050,20 @@ } }, "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" }, @@ -2107,9 +2101,9 @@ "license": "MIT" }, "node_modules/@preact/preset-vite": { - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", - "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.6.tgz", + "integrity": "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw==", "dev": true, "license": "MIT", "dependencies": { @@ -2137,9 +2131,9 @@ "license": "MIT" }, "node_modules/@prefresh/core": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", - "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.11.tgz", + "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2198,10 +2192,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -2216,9 +2227,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -2233,9 +2244,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -2250,9 +2261,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -2267,9 +2278,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -2284,9 +2295,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -2304,9 +2315,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -2324,9 +2335,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -2344,9 +2355,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -2364,9 +2375,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -2384,9 +2395,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -2404,9 +2415,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -2420,29 +2431,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -2457,9 +2449,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2481,9 +2473,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2504,9 +2496,9 @@ } }, "node_modules/@simplewebauthn/server": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", - "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz", + "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==", "license": "MIT", "dependencies": { "@hexagon/base64": "^1.1.27", @@ -2516,7 +2508,9 @@ "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/x509": "^1.14.3" + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" }, "engines": { "node": ">=20.0.0" @@ -2536,16 +2530,16 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, "license": "CC0-1.0" }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", "license": "MIT", "funding": { "type": "github", @@ -2553,12 +2547,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.102.8" }, "funding": { "type": "github", @@ -2568,52 +2562,41 @@ "react": "^18 || ^19" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/gensync": { "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/@types/gensync/-/gensync-1.0.5.tgz", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", "dev": true, "license": "MIT" }, "node_modules/@types/jsesc": { "version": "2.5.1", - "resolved": "https://registry.npmmirror.com/@types/jsesc/-/jsesc-2.5.1.tgz", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@zip.js/zip.js": { - "version": "2.8.26", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", - "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.14.1.tgz", + "integrity": "sha512-ChDAOmIvj061bWlm6Zqy5llk3V2SSES893DT9CSeiLd+YGgm5l950F/HvUpKj5ED0nqfE+cgDgFuxqVQvTMatg==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -2677,9 +2660,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", - "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.0.tgz", + "integrity": "sha512-A26d6qs9kqGgkmImIXMYvXTzqb4Qv7AVgpY1NXzr9Y659J8qHHnLOO/zE8ewIGFMprOolAoRAQYDgNryXIJKBw==", "dev": true, "funding": [ { @@ -2697,8 +2680,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.4", - "caniuse-lite": "^1.0.30001799", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2713,40 +2696,6 @@ "postcss": "^8.1.0" } }, - "node_modules/autoprefixer/node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "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": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", @@ -2758,9 +2707,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2811,9 +2760,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2831,11 +2780,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2855,9 +2804,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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": [ { @@ -3089,15 +3038,15 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.381", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", - "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "dev": true, "license": "ISC" }, "node_modules/empathic": { "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/empathic/-/empathic-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", "dev": true, "license": "MIT", @@ -3139,9 +3088,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3152,32 +3101,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -3228,9 +3177,9 @@ } }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -3337,9 +3286,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -3361,7 +3310,7 @@ }, "node_modules/import-meta-resolve": { "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", @@ -3384,13 +3333,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -3443,9 +3392,9 @@ } }, "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==", + "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" }, @@ -3499,9 +3448,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3515,23 +3464,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3550,9 +3499,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3571,9 +3520,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3592,9 +3541,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3613,9 +3562,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3634,9 +3583,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -3658,9 +3607,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -3682,9 +3631,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -3706,9 +3655,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -3730,9 +3679,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3751,9 +3700,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -3792,9 +3741,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "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": { @@ -3802,9 +3751,9 @@ } }, "node_modules/lucide-preact": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.22.0.tgz", - "integrity": "sha512-zFaBtoaWQgvapVEI96M3b5iUOlAEvpUfDuW3Gs8K1RHDyoOTrnXm+Cz5tupm8StKbRKn3W/YKIPolllf5voVDw==", + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.45.0.tgz", + "integrity": "sha512-/6/A5P8Wx3NY+Y2Mqg5Cx8Ak50eZyoXiG0ksqGZL7Oo4EXjGX406tWoOH5j+ivktmerzS8ZoFW5EtK7pPuPQ/Q==", "license": "ISC", "peerDependencies": { "preact": "^10.27.2" @@ -3858,32 +3807,23 @@ } }, "node_modules/miniflare": { - "version": "4.20260625.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz", - "integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==", + "version": "5.20260911.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260911.0-alpha.tgz", + "integrity": "sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.28.0", - "workerd": "1.20260625.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260911.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, - "bin": { - "miniflare": "bootstrap.js" - }, "engines": { "node": ">=22.0.0" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3904,9 +3844,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -3934,9 +3874,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -3987,9 +3927,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4001,9 +3941,9 @@ } }, "node_modules/opencc-js": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.3.2.tgz", - "integrity": "sha512-lO4Kq8J4TcPTa8qHcx5qazQCn+NM68kNwLJOQ58DG9MV8v25XJxByMB74zDGmr3LjJMGqDdrvQfoCi3FO0SC2A==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz", + "integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==", "dev": true, "license": "MIT AND Apache-2.0" }, @@ -4036,9 +3976,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4048,16 +3988,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -4069,9 +3999,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4089,7 +4019,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4142,9 +4072,9 @@ } }, "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, "funding": [ { @@ -4158,21 +4088,28 @@ ], "license": "MIT", "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" + "lilconfig": "^3.1.1" }, "engines": { - "node": ">= 14" + "node": ">= 18" }, "peerDependencies": { + "jiti": ">=1.21.0", "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { + "jiti": { + "optional": true + }, "postcss": { "optional": true }, - "ts-node": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -4204,9 +4141,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4225,13 +4162,21 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.29.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", - "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/pvtsutils": { @@ -4244,9 +4189,9 @@ } }, "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", "license": "MIT", "engines": { "node": ">=16.0.0" @@ -4280,9 +4225,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "peer": true, "engines": { @@ -4290,14 +4235,11 @@ } }, "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } + "license": "MIT" }, "node_modules/readdirp": { "version": "3.6.0", @@ -4374,13 +4316,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.138.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4390,21 +4332,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/run-parallel": { @@ -4445,15 +4387,15 @@ } }, "node_modules/sharp": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz", - "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -4462,31 +4404,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.0", - "@img/sharp-darwin-x64": "0.35.0", - "@img/sharp-freebsd-wasm32": "0.35.0", - "@img/sharp-libvips-darwin-arm64": "1.3.0", - "@img/sharp-libvips-darwin-x64": "1.3.0", - "@img/sharp-libvips-linux-arm": "1.3.0", - "@img/sharp-libvips-linux-arm64": "1.3.0", - "@img/sharp-libvips-linux-ppc64": "1.3.0", - "@img/sharp-libvips-linux-riscv64": "1.3.0", - "@img/sharp-libvips-linux-s390x": "1.3.0", - "@img/sharp-libvips-linux-x64": "1.3.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", - "@img/sharp-libvips-linuxmusl-x64": "1.3.0", - "@img/sharp-linux-arm": "0.35.0", - "@img/sharp-linux-arm64": "0.35.0", - "@img/sharp-linux-ppc64": "0.35.0", - "@img/sharp-linux-riscv64": "0.35.0", - "@img/sharp-linux-s390x": "0.35.0", - "@img/sharp-linux-x64": "0.35.0", - "@img/sharp-linuxmusl-arm64": "0.35.0", - "@img/sharp-linuxmusl-x64": "0.35.0", - "@img/sharp-webcontainers-wasm32": "0.35.0", - "@img/sharp-win32-arm64": "0.35.0", - "@img/sharp-win32-ia32": "0.35.0", - "@img/sharp-win32-x64": "0.35.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/simple-code-frame": { @@ -4552,6 +4499,17 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/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/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -4683,9 +4641,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "dev": true, "license": "MIT", "dependencies": { @@ -4734,9 +4692,9 @@ } }, "node_modules/undici": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", - "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "dev": true, "license": "MIT", "engines": { @@ -4744,9 +4702,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -4761,9 +4719,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -4792,9 +4750,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4807,17 +4765,30 @@ "dev": true, "license": "MIT" }, + "node_modules/verkit": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -4834,7 +4805,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4904,9 +4875,9 @@ } }, "node_modules/workerd": { - "version": "1.20260625.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz", - "integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==", + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260911.1.tgz", + "integrity": "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -4917,20 +4888,19 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260625.1", - "@cloudflare/workerd-darwin-arm64": "1.20260625.1", - "@cloudflare/workerd-linux-64": "1.20260625.1", - "@cloudflare/workerd-linux-arm64": "1.20260625.1", - "@cloudflare/workerd-windows-64": "1.20260625.1" + "@cloudflare/workerd-darwin-64": "1.20260911.1", + "@cloudflare/workerd-darwin-arm64": "1.20260911.1", + "@cloudflare/workerd-linux-64": "1.20260911.1", + "@cloudflare/workerd-linux-arm64": "1.20260911.1", + "@cloudflare/workerd-windows-64": "1.20260911.1" } }, "node_modules/wouter": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.10.0.tgz", - "integrity": "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", + "integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==", "license": "Unlicense", "dependencies": { - "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, @@ -4939,9 +4909,9 @@ } }, "node_modules/wrangler": { - "version": "4.105.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz", - "integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==", + "version": "4.131.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.131.1.tgz", + "integrity": "sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -4949,10 +4919,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260625.0", + "miniflare": "5.20260911.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260625.1" + "workerd": "1.20260911.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -4966,7 +4936,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260625.1" + "@cloudflare/workers-types": "^5.20260911.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -4975,9 +4945,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { @@ -4996,22 +4966,6 @@ } } }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -5038,9 +4992,9 @@ } }, "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 8a835d2ef..c5fe82282 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,35 @@ "test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs", "test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", "test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", + "test:security-audit-api-key": "tsx scripts/security-audit-api-key-semantics.mjs", + "test:security-audit-backup-state": "tsx scripts/security-audit-backup-auth-state.mjs", + "test:security-audit-backup-endpoint": "tsx --test scripts/security-audit-backup-endpoint.test.ts", + "test:security-audit": "npm run test:security-audit-api-key && npm run test:security-audit-backup-state && npm run test:security-audit-backup-endpoint", + "test:backup-roundtrip": "tsx --test scripts/backup-roundtrip.test.ts", + "test:backup-remote-timeout": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/backup-remote-timeout.test.ts", + "test:backup-visibility": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/backup-error-visibility.test.ts", + "test:yubico-otp-timeout": "tsx --test scripts/yubico-otp-timeout.test.ts", + "test:ensure-kv": "tsx --test scripts/ensure-kv.test.ts", + "test:error-message-guard": "tsx --test scripts/error-message-guard.test.ts", + "test:regex-hardening": "tsx --test scripts/regex-hardening.test.ts", + "test:audit-log-pagination": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/audit-log-pagination.test.ts", + "test:migration-upgrade": "tsx --test scripts/migration-upgrade.test.ts", + "test:ciphers-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/ciphers-handler.test.ts", + "test:sync-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/sync-handler.test.ts", + "test:identity-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/identity-handler.test.ts", + "test:admin-last-admin": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/admin-last-admin-guard.test.ts", + "test:sends-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/sends-handler.test.ts", + "test:folders-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/folders-handler.test.ts", + "test:import-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/import-handler.test.ts", + "test:attachments-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/attachments-handler.test.ts", + "test:backup-handler": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/backup-handler.test.ts", + "test:webapp-lib": "tsx --tsconfig tsconfig.webapp-tests.json --test scripts/webapp/*.test.ts", + "test:query-plan": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/query-plan.test.ts", + "test:query-count": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/query-count.test.ts", + "test:app-version-log": "tsx --import ./scripts/lib/register-cloudflare-stub.mjs --test scripts/app-version-log.test.ts", + "test": "npm run test:config-compatibility && npm run test:web-crypto && npm run test:notifications-security && npm run test:webauthn-connectors && npm run test:security-audit && npm run test:backup-roundtrip && npm run test:backup-remote-timeout && npm run test:backup-visibility && npm run test:yubico-otp-timeout && npm run test:ensure-kv && npm run test:error-message-guard && npm run test:regex-hardening && npm run test:audit-log-pagination && npm run test:migration-upgrade && npm run test:ciphers-handler && npm run test:sync-handler && npm run test:identity-handler && npm run test:admin-last-admin && npm run test:sends-handler && npm run test:folders-handler && npm run test:import-handler && npm run test:attachments-handler && npm run test:backup-handler && npm run test:webapp-lib && npm run test:query-plan && npm run test:query-count && npm run test:app-version-log", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p webapp/tsconfig.json --noEmit && tsc -p tsconfig.scripts.json --noEmit && tsc -p tsconfig.webapp-tests.json --noEmit", + "verify": "npm run typecheck && npm run i18n:validate && npm test && npm run build", "deploy": "wrangler deploy", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml", "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" @@ -49,15 +78,16 @@ } }, "overrides": { - "nanoid": "3.3.18", - "undici": "8.9.0", + "nanoid": "^3.3.18", + "undici": "^8.9.0", "@babel/core": ">=7.29.6", "esbuild": ">=0.28.1", - "ws": "8.21.0", - "sharp": "0.35.0" + "ws": "^8.21.0", + "sharp": "^0.35.4", + "browserslist": "^4.28.9" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260630.1", + "@cloudflare/workers-types": "^5.20260911.1", "@preact/preset-vite": "^2.10.5", "@types/node": "^26.0.1", "autoprefixer": "^10.5.2", @@ -67,11 +97,11 @@ "tsx": "^4.22.4", "typescript": "^6.0.3", "vite": "^8.1.3", - "wrangler": "^4.105.0" + "wrangler": "^4.131.1" }, "dependencies": { "@noble/hashes": "^2.2.0", - "@simplewebauthn/server": "^13.3.2", + "@simplewebauthn/server": "^14.0.2", "@tanstack/react-query": "^5.101.2", "@zip.js/zip.js": "^2.8.26", "fflate": "^0.8.3", @@ -80,5 +110,9 @@ "preact": "^10.29.3", "qrcode-generator": "^2.0.4", "wouter": "^3.10.0" + }, + "allowScripts": { + "esbuild@0.28.2": true, + "workerd@1.20260911.1": true } } diff --git a/scripts/admin-last-admin-guard.test.ts b/scripts/admin-last-admin-guard.test.ts new file mode 100644 index 000000000..07b5bbd90 --- /dev/null +++ b/scripts/admin-last-admin-guard.test.ts @@ -0,0 +1,233 @@ +// 「系统必须始终有一个能用的管理员」这条不变量的行为测试。 +// +// 背景(docs/TODO.md 第 5 条): +// `isAdmin()` 要求 `role === 'admin'` **且** `status === 'active'`,但兜底逻辑 +// `ensureAdminUserExists()` 原先只查 `role = 'admin'` —— 两处口径不一致 ⇒ +// 「唯一的管理员被 ban 掉」会被当成"已经有管理员",从此**再也不兜底**(只能手工改库)。 +// 同一个不一致还让兜底把 **banned 用户**提权成管理员(提权后 `isAdmin()` 仍为 false)。 +// +// 本文件覆盖三块: +// ① 计数口径:countActiveAdmins 只数「role=admin 且 status=active」 +// ② 兜底口径:banned 管理员不再卡住兜底;提权对象必须是可登录用户 +// ③ 最后一个管理员的守卫 + 陈旧操作者快照(15 s 缓存窗口)必须被 403 拦下 +// +// 运行方式:npm run test:admin-last-admin +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { guardLastActiveAdmin, handleAdminDeleteUser, handleAdminSetUserStatus } from '../src/handlers/admin'; +import { AuthService } from '../src/services/auth'; +import { StorageService } from '../src/services/storage'; +import { ensureStorageSchema } from '../src/services/storage-schema'; +import type { Env, User } from '../src/types'; +import { createSchemaDatabase, insertUser, TEST_JWT_SECRET } from './lib/test-harness'; + +/** 客户端哈希(服务端再叠一层);密码校验用真实实现生成,不手写 */ +const CLIENT_HASH = 'client-side-hash-of-master-password'; +const ADMIN_A = 'admin-a'; +const ADMIN_B = 'admin-b'; +const PLAIN_USER = 'plain-user'; + +interface Harness { + handle: Awaited>; + env: Env; + storage: StorageService; + /** 用户 id → 库里真实的 User 对象 */ + user: (id: string) => Promise; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + const env = { DB: handle.db, JWT_SECRET: TEST_JWT_SECRET } as unknown as Env; + const storage = new StorageService(handle.db); + return { + handle, + env, + storage, + user: async (id) => { + const found = await storage.getUserById(id); + assert.ok(found, `夹具应当在库里找到 ${id}`); + return found; + }, + }; +} + +/** 插一个密码就是 CLIENT_HASH 的用户(哈希走真实实现) */ +async function seedUser( + h: Harness, + id: string, + options: { role?: string; status?: string; createdAt?: string } = {} +): Promise { + const email = `${id}@example.test`; + insertUser(h.handle.connection, id, { + email, + role: options.role, + status: options.status, + createdAt: options.createdAt, + masterPasswordHash: await new AuthService(h.env).hashPasswordServer(CLIENT_HASH, email), + }); +} + +function row(h: Harness, id: string): { role: string; status: string } { + return h.handle.connection + .prepare('SELECT role, status FROM users WHERE id = ?') + .get(id) as { role: string; status: string }; +} + +function bootstrapAuditRows(h: Harness): Array<{ target_id: string }> { + return h.handle.connection + .prepare("SELECT target_id FROM audit_logs WHERE action = 'user.bootstrap.admin_promoted'") + .all() as Array<{ target_id: string }>; +} + +function deleteRequest(): Request { + return new Request(`https://example.test/api/admin/users/${PLAIN_USER}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.9' }, + body: JSON.stringify({ masterPasswordHash: CLIENT_HASH }), + }); +} + +function statusRequest(status: 'active' | 'banned'): Request { + return new Request(`https://example.test/api/admin/users/${PLAIN_USER}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': '203.0.113.9' }, + body: JSON.stringify({ status, masterPasswordHash: CLIENT_HASH }), + }); +} + +// ---------------------------------------------------------------- 计数口径 +test('口径:countActiveAdmins 只数「role=admin 且 status=active」,banned 管理员不算', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin' }); + await seedUser(h, ADMIN_B, { role: 'admin', status: 'banned' }); + await seedUser(h, PLAIN_USER); + + assert.equal(await h.storage.countActiveAdmins(), 1, '被 ban 的管理员不能占着“有管理员”的名额'); +}); + +// ---------------------------------------------------------------- 兜底口径 +test('兜底:唯一的管理员被 ban 时,把最早的**可登录**用户提权(并写审计事件)', async () => { + const h = await createHarness(); + // A 最早、但被 ban;C 稍晚、可登录 ⇒ 应该提权 C,而不是"看到 admin 就收工" + await seedUser(h, ADMIN_A, { role: 'admin', status: 'banned', createdAt: '2026-01-01T00:00:00.000Z' }); + await seedUser(h, PLAIN_USER, { createdAt: '2026-01-02T00:00:00.000Z' }); + + await ensureStorageSchema(h.handle.db); + + assert.equal(row(h, ADMIN_A).status, 'banned', '不能因为兜底就悄悄解封一个被 ban 的管理员'); + // 注意:node:sqlite 的 .get() 返回 null-prototype 对象,deepEqual 会比原型, + // 所以逐字段断言(而不是 deepEqual 整个行对象)。 + assert.equal(row(h, PLAIN_USER).role, 'admin'); + assert.equal(row(h, PLAIN_USER).status, 'active'); + assert.deepEqual( + bootstrapAuditRows(h).map((entry) => entry.target_id), + [PLAIN_USER], + '提权必须留痕' + ); +}); + +test('兜底:所有用户都被 ban 时保持原样(提权 banned 用户毫无意义)', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin', status: 'banned', createdAt: '2026-01-01T00:00:00.000Z' }); + await seedUser(h, PLAIN_USER, { status: 'banned', createdAt: '2026-01-02T00:00:00.000Z' }); + + await ensureStorageSchema(h.handle.db); + + assert.equal(row(h, ADMIN_A).role, 'admin'); + assert.equal(row(h, ADMIN_A).status, 'banned'); + assert.equal(row(h, PLAIN_USER).role, 'user', '不能把 banned 用户提权成管理员(isAdmin 仍为 false)'); + assert.deepEqual(bootstrapAuditRows(h), []); +}); + +test('兜底:已有可用管理员时不动任何行、也不写审计事件', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin', createdAt: '2026-01-01T00:00:00.000Z' }); + await seedUser(h, PLAIN_USER, { createdAt: '2026-01-02T00:00:00.000Z' }); + + await ensureStorageSchema(h.handle.db); + + assert.equal(row(h, PLAIN_USER).role, 'user'); + assert.deepEqual(bootstrapAuditRows(h), []); +}); + +// ---------------------------------------------------------------- 守卫本体 +test('守卫:目标是最后一个可用管理员 ⇒ 400;还有第二个 ⇒ 放行;目标不是可用管理员 ⇒ 放行', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin' }); + await seedUser(h, PLAIN_USER); + + const onlyAdmin = await h.user(ADMIN_A); + const blocked = await guardLastActiveAdmin(h.storage, onlyAdmin); + assert.equal(blocked?.status, 400, '仅剩一个可用管理员时必须拒绝'); + assert.match(await blocked!.text(), /last active administrator/i, '文案要能指导用户下一步'); + + // 有第二个可用管理员 ⇒ 放行(正常工作流不能被误伤) + await seedUser(h, ADMIN_B, { role: 'admin' }); + assert.equal(await guardLastActiveAdmin(h.storage, onlyAdmin), null); + + // 目标不是「可用管理员」⇒ 与不变量无关,放行 + const plain = await h.user(PLAIN_USER); + assert.equal(await guardLastActiveAdmin(h.storage, plain), null); + h.handle.connection.prepare("UPDATE users SET status = 'banned' WHERE id = ?").run(ADMIN_A); + const bannedAdmin = await h.user(ADMIN_A); + assert.equal(await guardLastActiveAdmin(h.storage, bannedAdmin), null, '封禁一个已失效的管理员不会让可用管理员归零'); +}); + +// ---------------------------------------------------------------- handler 集成 +test('删用户:普通用户照常删除(守卫不误伤 204 路径)', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin' }); + await seedUser(h, PLAIN_USER); + + const response = await handleAdminDeleteUser(deleteRequest(), h.env, await h.user(ADMIN_A), PLAIN_USER); + + assert.equal(response.status, 204); + assert.equal( + h.handle.connection.prepare('SELECT 1 AS hit FROM users WHERE id = ?').get(PLAIN_USER), + undefined, + '目标用户应当真的被删掉' + ); +}); + +test('删用户:操作者的陈旧快照(库里已不是可用管理员)⇒ 403,不采信缓存', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin' }); + await seedUser(h, ADMIN_B, { role: 'admin' }); + await seedUser(h, PLAIN_USER); + + const staleActor = await h.user(ADMIN_A); + // 模拟"刚被别的管理员 ban 掉、但本 isolate 里还留着 15 s 旧快照":库里改成 banned + h.handle.connection.prepare("UPDATE users SET status = 'banned' WHERE id = ?").run(ADMIN_A); + + const response = await handleAdminDeleteUser(deleteRequest(), h.env, staleActor, ADMIN_B); + + assert.equal(response.status, 403, '被 ban 之后不能再用旧快照继续管理'); + assert.ok( + h.handle.connection.prepare('SELECT 1 AS hit FROM users WHERE id = ?').get(ADMIN_B), + '被拒绝的请求不能产生副作用' + ); +}); + +test('ban:普通用户可封禁;封禁自己被拦;陈旧操作者快照被 403 拦下', async () => { + const h = await createHarness(); + await seedUser(h, ADMIN_A, { role: 'admin' }); + await seedUser(h, ADMIN_B, { role: 'admin' }); + await seedUser(h, PLAIN_USER); + + const adminA = await h.user(ADMIN_A); + assert.equal((await handleAdminSetUserStatus(statusRequest('banned'), h.env, adminA, PLAIN_USER)).status, 200); + assert.equal(row(h, PLAIN_USER).status, 'banned'); + + assert.equal( + (await handleAdminSetUserStatus(statusRequest('banned'), h.env, adminA, ADMIN_A)).status, + 400, + '不能封禁自己' + ); + + // 陈旧快照:B 在库里已被 ban,测试仍传旧的 active 快照 + const staleActor = await h.user(ADMIN_B); + h.handle.connection.prepare("UPDATE users SET status = 'banned' WHERE id = ?").run(ADMIN_B); + const blocked = await handleAdminSetUserStatus(statusRequest('banned'), h.env, staleActor, PLAIN_USER); + assert.equal(blocked.status, 403); +}); diff --git a/scripts/app-version-log.test.ts b/scripts/app-version-log.test.ts new file mode 100644 index 000000000..9758400f0 --- /dev/null +++ b/scripts/app-version-log.test.ts @@ -0,0 +1,189 @@ +// 应用版本启动记录的行为验证(写入 system.app.version.started → 出现在 Web 端「日志中心」) +// +// 为什么必须有这个文件: +// 1. 判定逻辑藏在"读 config → 比较 → 原子认领 → 写审计事件"这条链上, +// 任何一环写错都会静默失效(例如元数据忘了登记白名单 → 字段被丢掉, +// 或者把判定写成"先读再写" → 并发时日志中心出现重复条目)。 +// 2. 部署瞬间会有多个 isolate 同时冷启动,重复条目正是最可能出问题的地方, +// 所以"并发只写一条"是这里最重要的断言。 +// +// 运行方式:npm run test:app-version-log +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { APP_VERSION } from '../shared/app-version'; +import { + APP_VERSION_ACTION, + APP_VERSION_CONFIG_KEY, + resetAppVersionTrackingForTests, + trackAppVersionOnce, +} from '../src/services/app-version-log'; +import type { Env } from '../src/types'; +import { createSchemaDatabase } from './lib/test-harness'; + +interface AuditRow { + action: string; + category: string; + level: string; + actor_user_id: string | null; + target_type: string | null; + metadata: string; +} + +function buildEnv(db: Env['DB'], versionMeta?: Env['CF_VERSION_METADATA']): Env { + return { + DB: db, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + ...(versionMeta ? { CF_VERSION_METADATA: versionMeta } : {}), + } as unknown as Env; +} + +function readVersionRows(handle: { connection: { prepare: (sql: string) => any } }): AuditRow[] { + return handle.connection + .prepare('SELECT action, category, level, actor_user_id, target_type, metadata FROM audit_logs WHERE action = ? ORDER BY created_at') + .all(APP_VERSION_ACTION) as AuditRow[]; +} + +function readStoredRecord(handle: { connection: { prepare: (sql: string) => any } }): string | null { + const row = handle.connection.prepare('SELECT value FROM config WHERE key = ?').get(APP_VERSION_CONFIG_KEY) as + | { value: string } + | undefined; + return row?.value ?? null; +} + +/** 把随机性钉住:让概率门控的低频清理永远不触发,断言不受噪声影响 */ +async function withDeterministicRandom(run: () => Promise): Promise { + const original = Math.random; + Math.random = () => 1; + try { + return await run(); + } finally { + Math.random = original; + } +} + +test('首次启动写入一条系统事件,并把当前版本落到 config', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + resetAppVersionTrackingForTests(); + + await trackAppVersionOnce(buildEnv(handle.db, { id: 'deploy-1', timestamp: '2026-09-13T00:00:00.000Z' })); + + const rows = readVersionRows(handle); + assert.equal(rows.length, 1, '首次启动应当写一条'); + assert.equal(rows[0].action, APP_VERSION_ACTION); + assert.equal(rows[0].category, 'system'); + assert.equal(rows[0].level, 'info'); + assert.equal(rows[0].actor_user_id, null, '系统事件没有操作者'); + + // 元数据必须完整 —— 这条专门防"忘了往 ALLOWED_METADATA_KEYS 登记"的回归: + // 未登记的键会被 sanitizeMetadata 静默丢掉,这里就会缺字段。 + const metadata = JSON.parse(rows[0].metadata) as Record; + assert.equal(metadata.version, APP_VERSION); + assert.equal(metadata.deploymentId, 'deploy-1'); + assert.equal(metadata.deployedAt, '2026-09-13T00:00:00.000Z'); + assert.equal(metadata.previousVersion, undefined, '首次没有上一版本'); + + assert.ok(readStoredRecord(handle)?.includes(APP_VERSION), 'config 里应当记下当前版本'); + }); +}); + +test('版本没变时不再写第二条', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + const env = buildEnv(handle.db, { id: 'deploy-1' }); + + resetAppVersionTrackingForTests(); + await trackAppVersionOnce(env); + // 模拟"同一个版本又来了一个全新 isolate" + resetAppVersionTrackingForTests(); + await trackAppVersionOnce(env); + + assert.equal(readVersionRows(handle).length, 1, '同版本重复启动不应再写'); + }); +}); + +test('版本号变了 → 写出旧版本与上一版本的对照', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + // 预置一个"上一版本"的记录,模拟从旧版本升级上来 + handle.connection + .prepare('INSERT INTO config(key, value) VALUES(?, ?)') + .run(APP_VERSION_CONFIG_KEY, JSON.stringify({ version: '1.7.0', deploymentId: 'deploy-old' })); + + resetAppVersionTrackingForTests(); + await trackAppVersionOnce(buildEnv(handle.db, { id: 'deploy-new' })); + + const rows = readVersionRows(handle); + assert.equal(rows.length, 1); + const metadata = JSON.parse(rows[0].metadata) as Record; + assert.equal(metadata.version, APP_VERSION); + assert.equal(metadata.previousVersion, '1.7.0'); + assert.equal(metadata.deploymentId, 'deploy-new'); + }); +}); + +test('版本号没变、仅重新构建部署(deployment id 变了)也要记录', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + resetAppVersionTrackingForTests(); + await trackAppVersionOnce(buildEnv(handle.db, { id: 'deploy-1' })); + + // 版本号一模一样,只有 Cloudflare 的 deployment id 换了 + resetAppVersionTrackingForTests(); + await trackAppVersionOnce(buildEnv(handle.db, { id: 'deploy-2' })); + + const rows = readVersionRows(handle); + assert.equal(rows.length, 2, '仅重新部署也应当留下记录'); + const metadata = JSON.parse(rows[1].metadata) as Record; + assert.equal(metadata.deploymentId, 'deploy-2'); + assert.equal(metadata.previousVersion, APP_VERSION, '版本号没变,但仍应标出上一版本'); + }); +}); + +test('并发冷启动只写一条(原子认领)', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + const env = buildEnv(handle.db, { id: 'deploy-1' }); + + // 注意这里为什么要两次 reset: + // 模块内的"本 isolate 已检查"标志会合并同一 isolate 的并发调用 —— + // 那是第一道闸;要验证**SQL 层的原子认领**(多 isolate 同时冷启动), + // 必须让两个调用各自独立地跑完"读 → 比较 → 认领"这条链。 + resetAppVersionTrackingForTests(); + const first = trackAppVersionOnce(env); + resetAppVersionTrackingForTests(); + const second = trackAppVersionOnce(env); + await Promise.all([first, second]); + + assert.equal(readVersionRows(handle).length, 1, '并发认领时只能有一条记录'); + }); +}); + +test('同一 isolate 内重复调用被标志位短路(不会反复查库)', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + resetAppVersionTrackingForTests(); + + const env = buildEnv(handle.db, { id: 'deploy-1' }); + await Promise.all([trackAppVersionOnce(env), trackAppVersionOnce(env), trackAppVersionOnce(env)]); + + assert.equal(readVersionRows(handle).length, 1); + // 第二次起应当直接返回,不再产生新的日志 + await trackAppVersionOnce(env); + assert.equal(readVersionRows(handle).length, 1); + }); +}); + +test('数据库不可用时只记录错误、不抛出(绝不能影响请求)', async () => { + await withDeterministicRandom(async () => { + const handle = await createSchemaDatabase(); + handle.connection.exec('DROP TABLE config'); + resetAppVersionTrackingForTests(); + + await assert.doesNotReject(() => trackAppVersionOnce(buildEnv(handle.db, { id: 'deploy-1' }))); + }); +}); diff --git a/scripts/attachments-handler.test.ts b/scripts/attachments-handler.test.ts new file mode 100644 index 000000000..0da711551 --- /dev/null +++ b/scripts/attachments-handler.test.ts @@ -0,0 +1,401 @@ +// `src/handlers/attachments.ts` 的行为测试 +// +// 为什么值得单独测:附件是**唯一把数据写进 R2 对象存储**的功能面,因此风险比纯 D1 的 handler 多一层: +// +// ① **两个存储要一致**:删除必须同时清掉 R2 对象与 D1 元数据。只删一边就会产生 +// "数据库说没有、R2 里还躺着用户文件"(隐私问题)或"数据库说有条目、下载 404"(坏体验)。 +// ② **下载授权走的是签名 URL**:`?token=...` 是唯一的凭证,它必须绑定到具体的 +// cipher + attachment,且**只能用一次**(`consumeAttachmentDownloadToken`)。 +// ③ **上传是一次性的**:同一个附件重复上传应被 409 挡住,避免元数据与实际内容对不上。 +// +// 运行方式:npm run test:attachments-handler +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { + handleCreateAttachment, + handleDeleteAttachment, + handleGetAttachment, + handlePublicDownloadAttachment, + handleUploadAttachment, +} from '../src/handlers/attachments'; +import { handleCreateCipher } from '../src/handlers/ciphers'; +import type { Env } from '../src/types'; +import { createR2MemoryBucket } from './lib/r2-memory'; +import { createSchemaDatabase, enc, insertUser, TEST_JWT_SECRET } from './lib/test-harness'; + +const OWNER = 'owner-1'; +const STRANGER = 'stranger-1'; + +interface Harness { + handle: Awaited>; + connection: DatabaseSync; + env: Env; + bucket: ReturnType; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, OWNER); + insertUser(handle.connection, STRANGER); + const bucket = createR2MemoryBucket(); + const env = { + DB: handle.db, + ATTACHMENTS: bucket.bucket, + JWT_SECRET: TEST_JWT_SECRET, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + return { handle, connection: handle.connection, env, bucket }; +} + +function jsonRequest(url: string, body: unknown, method = 'POST'): Request { + return new Request(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +/** 造一个属于 OWNER 的条目,返回其 id */ +async function seedCipher(h: Harness, owner = OWNER): Promise { + const response = await handleCreateCipher( + jsonRequest('https://vault.example.test/api/ciphers', { + type: 1, + name: enc('cipher'), + login: { username: enc('u') }, + }), + h.env, + owner + ); + assert.equal(response.status, 200, `造条目失败:${response.status}`); + return String(((await response.json()) as { id: string }).id); +} + +/** 创建附件元数据,返回 attachmentId 与"上传用的 URL" */ +async function createAttachment( + h: Harness, + cipherId: string, + options: { owner?: string; fileName?: string; key?: string; fileSize?: number } = {} +): Promise<{ status: number; attachmentId: string; uploadIdSearch: string; body: Record }> { + const response = await handleCreateAttachment( + jsonRequest(`https://vault.example.test/api/ciphers/${cipherId}/attachment`, { + fileName: options.fileName ?? enc('file-name'), + key: options.key ?? enc('file-key'), + fileSize: options.fileSize ?? 5, + }), + h.env, + options.owner ?? OWNER, + cipherId + ); + const body = (await response.json()) as Record; + // 响应里的 url 是绝对地址(带一次性上传 token)。只在成功时解析 —— + // 失败时没有 url,硬解析会抛出与本用例无关的 TypeError。 + const rawUrl = typeof body.url === 'string' ? body.url : ''; + const uploadIdSearch = rawUrl ? `https://vault.example.test${new URL(rawUrl).pathname}${new URL(rawUrl).search}` : ''; + return { + status: response.status, + attachmentId: String(body.attachmentId ?? ''), + uploadIdSearch, + body, + }; +} + +/** 字符串的实际字节数 —— 上传时声明的 fileSize 必须与它一致,否则会被 400 拒 */ +function byteLength(content: string): number { + return new TextEncoder().encode(content).length; +} + +/** 把内容作为 multipart 表单上传(字段名必须是 `data`) */ +function multipartUpload(url: string, content: string, fileName = 'file.bin'): Request { + const form = new FormData(); + form.set('data', new File([content], fileName, { type: 'application/octet-stream' })); + return new Request(url, { method: 'POST', body: form }); +} + +function attachmentRowCount(h: Harness): number { + return (h.connection.prepare('SELECT COUNT(*) AS count FROM attachments').get() as { count: number }).count; +} + +// ---------------------------------------------------------------- 创建元数据 + +test('创建附件:条目不存在或不属于当前用户时 404(而且不能借此探测他人条目)', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + + assert.equal((await createAttachment(h, cipherId, { owner: STRANGER })).status, 404, '跨用户必须 404'); + assert.equal((await createAttachment(h, 'no-such-cipher')).status, 404); + assert.equal(attachmentRowCount(h), 0, '被拒绝的创建不得写入任何元数据'); + + h.handle.close(); +}); + +test('创建附件:缺 fileName 或 key 时 400', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + + const noKey = await handleCreateAttachment( + jsonRequest(`https://x/api/ciphers/${cipherId}/attachment`, { fileName: enc('f') }), + h.env, + OWNER, + cipherId + ); + assert.equal(noKey.status, 400); + + const noName = await handleCreateAttachment( + jsonRequest(`https://x/api/ciphers/${cipherId}/attachment`, { key: enc('k') }), + h.env, + OWNER, + cipherId + ); + assert.equal(noName.status, 400); + assert.equal(attachmentRowCount(h), 0); + + h.handle.close(); +}); + +test('创建附件:返回可直接上传的地址与令牌,并写入元数据', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const created = await createAttachment(h, cipherId, { fileSize: 2048 }); + + assert.equal(created.status, 200); + assert.equal(created.body.object, 'attachment-fileUpload'); + assert.equal(created.body.fileUploadType, 1, '官方客户端据此判断是直传还是走表单'); + assert.ok(created.uploadIdSearch.includes('token='), '上传地址必须自带一次性令牌'); + assert.equal(attachmentRowCount(h), 1); + + const row = h.connection + .prepare('SELECT cipher_id, size, size_name FROM attachments WHERE id = ?') + .get(created.attachmentId) as { cipher_id: string; size: number; size_name: string }; + assert.equal(row.cipher_id, cipherId); + assert.equal(row.size, 2048); + assert.ok(row.size_name, '应同时给出人类可读的大小'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 上传内容 + +test('上传:内容写入 R2,且键为「条目 id / 附件 id」', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const content = 'hello-attachment-bytes'; + const created = await createAttachment(h, cipherId, { fileSize: byteLength(content) }); + + const response = await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, content), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(response.status, 201, `上传应成功(201),实际 ${response.status}`); + + const key = `${cipherId}/${created.attachmentId}`; + assert.ok(h.bucket.keys().includes(key), `对象应写入 ${key},实际键:${JSON.stringify(h.bucket.keys())}`); + assert.equal(new TextDecoder().decode(h.bucket.bytesOf(key)!), content, 'R2 里的内容应与上传一致'); + + h.handle.close(); +}); + +test('上传:同一附件重复上传返回 409(避免元数据与实际内容对不上)', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const created = await createAttachment(h, cipherId, { fileSize: byteLength('first') }); + const key = `${cipherId}/${created.attachmentId}`; + + const first = await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, 'first'), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(first.status, 201, '首次上传应成功(201)'); + + const second = await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, 'first'), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(second.status, 409, '第二次上传应被拒绝'); + assert.equal(new TextDecoder().decode(h.bucket.bytesOf(key)!), 'first', '原有内容不得被覆盖'); + + h.handle.close(); +}); + +test('上传:声明尺寸与实际上传字节数不一致时 400(服务端不信任元数据里的尺寸)', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + // 创建时声明 999 字节,实际上传 4 字节 + const created = await createAttachment(h, cipherId, { fileSize: 999 }); + + const response = await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, 'tiny'), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + + assert.equal(response.status, 400, '尺寸对不上应被拒,而不是照单全收'); + const body = (await response.json()) as { error?: string }; + assert.match(String(body.error ?? ''), /size does not match/i); + assert.equal(h.bucket.size(), 0, '被拒绝的上传不得写入对象'); + + h.handle.close(); +}); + +// 说明:**没有**为"文件超过大小上限"写用例 —— 上限是 `LIMITS.attachment.maxFileSizeBytes`(100MB), +// 在测试里造一个真正超过它的请求体不现实。该分支(413)无法用当前手段可靠覆盖, +// 已在文档里记为已知的覆盖缺口,而不是写一个"看起来测了"的假用例。 + +// ---------------------------------------------------------------- 下载授权 + +test('下载信息:跨用户 404', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const created = await createAttachment(h, cipherId); + + assert.equal( + (await handleGetAttachment(new Request('https://x/'), h.env, OWNER, cipherId, created.attachmentId)).status, + 200 + ); + assert.equal( + (await handleGetAttachment(new Request('https://x/'), h.env, STRANGER, cipherId, created.attachmentId)).status, + 404, + '非所有者不得拿到下载地址' + ); + + h.handle.close(); +}); + +test('公开下载:无令牌、令牌与路径不匹配都要 401', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const created = await createAttachment(h, cipherId); + + const noToken = await handlePublicDownloadAttachment( + new Request('https://x/download'), + h.env, + cipherId, + created.attachmentId + ); + assert.equal(noToken.status, 401); + + // 用另一个附件的令牌去下载本附件 → 必须被令牌绑定检查挡住 + const other = await createAttachment(h, cipherId); + const infoResponse = await handleGetAttachment(new Request('https://x/'), h.env, OWNER, cipherId, other.attachmentId); + const infoBody = (await infoResponse.json()) as { url?: string }; + const otherToken = new URL(String(infoBody.url)).searchParams.get('token')!; + + const mismatched = await handlePublicDownloadAttachment( + new Request(`https://x/download?token=${encodeURIComponent(otherToken)}`), + h.env, + cipherId, + created.attachmentId + ); + assert.equal(mismatched.status, 401, '令牌必须绑定到具体条目+附件'); + + h.handle.close(); +}); + +test('公开下载:令牌只能用一次,且响应头做了防嗅探处理', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const content = 'download-me'; + const created = await createAttachment(h, cipherId, { fileSize: byteLength(content) }); + await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, content), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(h.bucket.size(), 1, '前置条件:内容已写入 R2'); + + const infoResponse = await handleGetAttachment(new Request('https://x/'), h.env, OWNER, cipherId, created.attachmentId); + const infoBody = (await infoResponse.json()) as { url?: string }; + const token = new URL(String(infoBody.url)).searchParams.get('token')!; + const downloadUrl = `https://x/download?token=${encodeURIComponent(token)}`; + + const first = await handlePublicDownloadAttachment(new Request(downloadUrl), h.env, cipherId, created.attachmentId); + assert.equal(first.status, 200); + assert.equal(await first.text(), content, '下载内容应与上传一致'); + assert.equal(first.headers.get('X-Content-Type-Options'), 'nosniff', '必须阻止浏览器嗅探内容类型'); + assert.match(String(first.headers.get('Content-Disposition')), /attachment/, '应作为下载而非内联展示'); + + const second = await handlePublicDownloadAttachment(new Request(downloadUrl), h.env, cipherId, created.attachmentId); + assert.equal(second.status, 401, '同一下载令牌不得重复使用'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 删除(两个存储要一致) + +test('删除:跨用户 404,且 R2 对象与元数据都不受影响', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const content = 'keep-me'; + const created = await createAttachment(h, cipherId, { fileSize: byteLength(content) }); + await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, content), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(h.bucket.size(), 1, '前置条件:内容已写入 R2'); + + const response = await handleDeleteAttachment( + new Request('https://x/', { method: 'DELETE' }), + h.env, + STRANGER, + cipherId, + created.attachmentId + ); + assert.equal(response.status, 404); + assert.equal(attachmentRowCount(h), 1, '元数据不得被删'); + assert.equal(h.bucket.size(), 1, 'R2 对象不得被删'); + + h.handle.close(); +}); + +test('删除:所有者删除后,R2 对象与元数据都必须消失,并留下审计事件', async () => { + const h = await createHarness(); + const cipherId = await seedCipher(h); + const content = 'delete-me'; + const created = await createAttachment(h, cipherId, { fileSize: byteLength(content) }); + await handleUploadAttachment( + multipartUpload(created.uploadIdSearch, content), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(h.bucket.size(), 1, '前置条件:对象已写入'); + + const response = await handleDeleteAttachment( + new Request('https://x/', { method: 'DELETE' }), + h.env, + OWNER, + cipherId, + created.attachmentId + ); + assert.equal(response.status, 200); + assert.equal(attachmentRowCount(h), 0, '元数据应被删除'); + assert.equal(h.bucket.size(), 0, 'R2 对象也必须被删除 —— 只删一边会留下"数据库说没有、对象存储里还躺着用户文件"'); + + const audit = h.connection + .prepare("SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'attachment.delete'") + .get() as { count: number }; + assert.equal(audit.count, 1, '删除附件应留下审计事件'); + + h.handle.close(); +}); diff --git a/scripts/audit-log-pagination.test.ts b/scripts/audit-log-pagination.test.ts new file mode 100644 index 000000000..353c654a5 --- /dev/null +++ b/scripts/audit-log-pagination.test.ts @@ -0,0 +1,207 @@ +// 日志中心分页的回归测试。 +// +// 为什么需要它:`listAuditLogs` 曾经把 `total` 算成 +// `offset + logs.length + (hasMore ? 1 : 0)` —— 那是"已走过的行数 + 本页行数", +// 不是真实总数。后果是分页分母每翻一页恰好 +limit,`ceil(total/limit)` 恒等于"页码 + 1": +// 用户看到 350/351 → 400/401、7/8 → 8/9,永远看不到真实条数与真实总页数 +// (只有最后一页凑巧是对的)。这类"数字在自己动"的缺陷光看代码很难发现, +// 所以把"翻页时分母必须不变"写成断言。 +// +// 运行方式:npm run test:audit-log-pagination +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { listAuditLogs, createAuditLog } from '../src/services/storage-admin-repo'; +import { createSchemaDatabase, insertUser, resetProcessScopedStatics } from './lib/test-harness'; + +const USER_A = 'user-audit-a'; +const USER_B = 'user-audit-b'; +const PAGE = 50; + +/** 造一条审计日志:i 决定时间(越新 i 越大)与分类/级别,便于验证过滤分支 */ +function seedLogs(handle: Awaited>, count: number): void { + const insert = handle.connection.prepare( + 'INSERT INTO audit_logs (id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES (?,?,?,?,?,?,?,?,?)' + ); + for (let index = 0; index < count; index += 1) { + const category = index % 3 === 0 ? 'auth' : 'data'; + const level = index % 5 === 0 ? 'security' : 'info'; + // created_at 递增:index 越大越新(列表按 DESC 排序) + const created = `2026-01-${String((index % 28) + 1).padStart(2, '0')}T00:00:${String(index % 60).padStart(2, '0')}.${String(index % 1000).padStart(3, '0')}Z`; + const actor = index % 2 === 0 ? USER_A : USER_B; + insert.run( + `audit-log-${String(index).padStart(4, '0')}`, + actor, + index % 4 === 0 ? 'vault.cipher.create' : 'auth.login.success', + category, + level, + index % 7 === 0 ? 'user' : 'system', + index % 7 === 0 ? USER_A : 'instance', + '{}', + created + ); + } +} + +async function freshHandle() { + resetProcessScopedStatics(); + const handle = await createSchemaDatabase(); + insertUser(handle.connection, USER_A, { email: 'audit-a@example.test' }); + insertUser(handle.connection, USER_B, { email: 'audit-b@example.test' }); + return handle; +} + +test('翻页时 total 必须保持不变,且等于真实总条数', async () => { + const ROWS = 120; + const handle = await freshHandle(); + seedLogs(handle, ROWS); + + const first = await listAuditLogs(handle.db, { limit: PAGE, offset: 0 }); + const second = await listAuditLogs(handle.db, { limit: PAGE, offset: PAGE }); + const third = await listAuditLogs(handle.db, { limit: PAGE, offset: PAGE * 2 }); + + // 回归点:旧实现下这三个数会是 51 / 101 / 121 + for (const [label, page] of [['第 1 页', first], ['第 2 页', second], ['第 3 页', third]] as const) { + assert.equal(page.total, ROWS, `${label}的 total 必须等于真实总条数 ${ROWS}`); + } + assert.equal(first.logs.length, PAGE); + assert.equal(second.logs.length, PAGE); + assert.equal(third.logs.length, ROWS - PAGE * 2, '最后一页只剩余数行'); + + assert.equal(first.hasMore, true); + assert.equal(second.hasMore, true); + assert.equal(third.hasMore, false, '取满最后一页时不应再有下一页'); + + // 总页数应当稳定(前端右上角就靠它) + const totalPages = (result: { total: number }): number => Math.max(1, Math.ceil(result.total / PAGE)); + assert.equal(totalPages(first), 3); + assert.equal(totalPages(second), 3); + assert.equal(totalPages(third), 3); + + handle.close(); +}); + +test('过滤条件下的 total 也必须等于该条件下的真实条数', async () => { + const ROWS = 120; + const handle = await freshHandle(); + seedLogs(handle, ROWS); + const expected = (sql: string, ...params: (string | number | null)[]): number => + Number((handle.connection.prepare(sql).get(...params) as { count: number }).count); + + const authTotal = expected("SELECT COUNT(*) AS count FROM audit_logs WHERE category = 'auth'"); + const filtered = await listAuditLogs(handle.db, { limit: PAGE, offset: 0, category: 'auth' }); + assert.equal(filtered.total, authTotal, '按分类过滤时 total 必须是过滤后的条数'); + assert.ok(authTotal > 0 && authTotal < ROWS, '前置条件:过滤确实筛掉了行'); + + const securityTotal = expected("SELECT COUNT(*) AS count FROM audit_logs WHERE level = 'security'"); + const byLevel = await listAuditLogs(handle.db, { limit: PAGE, offset: 0, level: 'security' }); + assert.equal(byLevel.total, securityTotal); + + const comboTtal = expected("SELECT COUNT(*) AS count FROM audit_logs WHERE category = 'data' AND level = 'info'"); + const combined = await listAuditLogs(handle.db, { limit: PAGE, offset: 0, category: 'data', level: 'info' }); + assert.equal(combined.total, comboTtal, '分类+级别组合过滤的 total 也要正确'); + + // 关键词搜索会引用 actor.email / target.email,计数查询必须复用同一段 FROM + const q = 'vault'; + const qTotal = expected( + "SELECT COUNT(*) AS count FROM audit_logs l LEFT JOIN users actor ON actor.id = l.actor_user_id LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id WHERE LOWER(l.action) LIKE ?", + '%vault%' + ); + const searched = await listAuditLogs(handle.db, { limit: PAGE, offset: 0, q }); + assert.equal(searched.total, qTotal, '关键词搜索的 total 必须正确(且不能因 JOIN 缺失而报错)'); + assert.ok(qTotal > 0, '前置条件:搜索词应有命中'); + + handle.close(); +}); + +test('恰好取满一整页时 hasMore 必须为 false(不能凭 total 猜)', async () => { + const ROWS = 100; + const handle = await freshHandle(); + seedLogs(handle, ROWS); + + const first = await listAuditLogs(handle.db, { limit: PAGE, offset: 0 }); + const second = await listAuditLogs(handle.db, { limit: PAGE, offset: PAGE }); + + assert.equal(first.total, ROWS); + assert.equal(first.hasMore, true); + assert.equal(second.total, ROWS); + assert.equal(second.logs.length, PAGE); + assert.equal(second.hasMore, false, '第 2 页正好取满 100 条时后面没有数据了'); + + handle.close(); +}); + +test('空库与越界 offset:total 为 0 / 不报错', async () => { + const handle = await freshHandle(); + + const empty = await listAuditLogs(handle.db, { limit: PAGE, offset: 0 }); + assert.deepEqual(empty.logs, []); + assert.equal(empty.total, 0); + assert.equal(empty.hasMore, false); + + seedLogs(handle, 10); + const beyond = await listAuditLogs(handle.db, { limit: PAGE, offset: 500 }); + assert.deepEqual(beyond.logs, []); + assert.equal(beyond.total, 10, '越界时 total 仍然是真实条数'); + + handle.close(); +}); + +// 「操作者」列的行内快照(audit_logs.actor_email)。 +// +// 背景:actor_user_id 上有 `ON DELETE SET NULL` 外键,而 `DELETE FROM users` 会在 +// 「从备份恢复」和「管理端删除用户」两条路径上跑 —— 那一刻该用户所有历史日志的 +// actor_user_id 都被置成 NULL,且**不会自愈**(恢复虽把用户按同样 id 写回, +// 但没有任何代码把值算回来)。表现就是日志中心「操作者」永久显示 `—`、 +// 按操作者邮箱搜索永久失效。 +// +// 修法是写入时在行内抄一份邮箱。本测试断言的就是这条快照链路: +// ① `createAuditLog` 真的写了快照;② 编号被置空后仍能显示;③ 搜索也还命中。 +test('恢复/删用户把 actor_user_id 置空后,操作者邮箱仍能显示与搜索', async () => { + const handle = await freshHandle(); + const ACTOR_EMAIL = 'audit-a@example.test'; + + // 必须走生产写入路径,快照才会被填上(直接 INSERT 是绕过快照的)。 + await createAuditLog(handle.db, { + id: 'audit-snapshot-1', + actorUserId: USER_A, + action: 'vault.cipher.create', + category: 'data', + level: 'info', + targetType: 'cipher', + targetId: 'cipher-1', + metadata: '{}', + createdAt: '2026-01-01T00:00:00.000Z', + }); + + // 前置条件:正常状态下两者都在 + const before = await listAuditLogs(handle.db, { limit: PAGE, offset: 0 }); + assert.equal(before.logs.length, 1); + assert.equal(before.logs[0].actorUserId, USER_A); + assert.equal(before.logs[0].actorEmail, ACTOR_EMAIL, '写入时应已把邮箱快照进这一行'); + + // 模拟恢复:DELETE FROM users(外键触发 SET NULL)→ 再把用户按同样的 id 写回。 + // 这正是 backup-import.ts 的 swapShadowTablesIntoPlace 做的事。 + handle.connection.exec('PRAGMA foreign_keys = ON'); + handle.connection.prepare('DELETE FROM users WHERE id = ?').run(USER_A); + insertUser(handle.connection, USER_A, { email: ACTOR_EMAIL }); + + const after = await listAuditLogs(handle.db, { limit: PAGE, offset: 0 }); + assert.equal( + after.logs[0].actorUserId, + null, + '前置条件:外键应已把编号置空。若这里不是 null,说明本测试没有覆盖到真实场景(外键未生效)' + ); + assert.equal( + after.logs[0].actorEmail, + ACTOR_EMAIL, + '编号被置空后,行内快照必须仍然保留操作者邮箱 —— 否则日志中心的「操作者」会永久变成 —' + ); + + // 搜索同理:恢复后 actor.email 那个 JOIN 已经查不到东西,只能靠快照命中。 + const searched = await listAuditLogs(handle.db, { limit: PAGE, offset: 0, q: ACTOR_EMAIL }); + assert.equal(searched.total, 1, '恢复后仍应能按操作者邮箱搜到日志'); + assert.equal(searched.logs[0].id, 'audit-snapshot-1'); + + handle.close(); +}); diff --git a/scripts/backup-error-visibility.test.ts b/scripts/backup-error-visibility.test.ts new file mode 100644 index 000000000..f93d31367 --- /dev/null +++ b/scripts/backup-error-visibility.test.ts @@ -0,0 +1,137 @@ +// 「备份失败 / 被跳过」的可观察性护栏(docs/TODO.md 第 18、19 条)。 +// +// 两条都是 2026-09-17 真机验收时发现的**静默**问题 —— 功能没坏,但人看不到发生了什么: +// +// 第 18 条:每次尝试**一开始**就把 `lastErrorMessage` 清空,而失败不更新 `lastSuccessAt` +// ⇒ 计划任务在容差窗口内立刻重试 ⇒ 「清空 → 30 s 后写回 → 立刻又清空」循环, +// 于是那条错误几乎永远看不到(当时只能靠审计日志确认一直在失败)。 +// 修法:清空只发生在**成功**分支。 +// 第 19 条:租约被别人持有时 DO 回 409,而 handler 侧**直接 return** +// ⇒ 「这一轮计划任务被跳过」没有任何留痕(不报错、日志里全 200)。 +// 修法:`console.warn` + 一条 `system` 审计事件。 +// +// 为什么第 18 条用源码断言而不是跑一遍备份:要观测到「尝试开始时是否清空」, +// 必须在两次尝试之间读 `runtime`,而真实落库发生在 restore/backup 的中途, +// 单测里只能靠 stub 掉整条远端链路 —— 收益低于成本。源码断言更精确: +// 它直接锁住「那段 update 里不许出现 lastError*」,改回去立刻红。 +// +// 运行方式:npm run test:backup-visibility +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { runScheduledBackupIfDue } from '../src/handlers/backup'; +import type { Env } from '../src/types'; +import { createSchemaDatabase, TEST_JWT_SECRET } from './lib/test-harness'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const BACKUP_HANDLER_PATH = path.join(REPO_ROOT, 'src/handlers/backup.ts'); + +interface Harness { + handle: Awaited>; + envFor: (fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise) => Env; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + return { + handle, + envFor: (fetchImpl) => ({ + DB: handle.db, + JWT_SECRET: TEST_JWT_SECRET, + BACKUP_TRANSFER_RUNNER: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: fetchImpl }), + }, + } as unknown as Env), + }; +} + +function skippedAuditRows(h: Harness): Array<{ category: string; level: string; actor_user_id: string | null; metadata: string }> { + return h.handle.connection + .prepare( + "SELECT category, level, actor_user_id, metadata FROM audit_logs WHERE action = 'backup.scheduled.skipped'" + ) + .all() as Array<{ category: string; level: string; actor_user_id: string | null; metadata: string }>; +} + +/** 捕获 console.warn,避免污染测试输出 */ +async function captureWarnings(run: () => Promise): Promise { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map((value) => String(value)).join(' ')); + }; + try { + await run(); + } finally { + console.warn = originalWarn; + } + return warnings; +} + +// ---------------------------------------------------------------- 第 19 条 +test('租约被占用(409):写一条 system 审计事件 + 一条 console.warn,而不是静默返回', async () => { + const h = await createHarness(); + const env = h.envFor(async () => + new Response(JSON.stringify({ error: 'Another backup run is already in progress' }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const warnings = await captureWarnings(() => runScheduledBackupIfDue(env)); + + const rows = skippedAuditRows(h); + assert.equal(rows.length, 1, '「被跳过」必须留痕'); + assert.equal(rows[0].category, 'system'); + assert.equal(rows[0].level, 'warn'); + assert.equal(rows[0].actor_user_id, null, '计划任务没有操作者'); + assert.match(rows[0].metadata, /lease_held/); + assert.match(rows[0].metadata, /already in progress/); + assert.equal(warnings.length, 1, '同时要有一条 console.warn(Workers Logs / wrangler tail 能看到)'); + assert.match(warnings[0], /scheduled backup skipped/); +}); + +test('未占用(200):不写审计、不 warn(避免每次 cron 都刷日志)', async () => { + const h = await createHarness(); + const env = h.envFor(async () => new Response('{}', { status: 200 })); + + const warnings = await captureWarnings(() => runScheduledBackupIfDue(env)); + + assert.deepEqual(skippedAuditRows(h), []); + assert.deepEqual(warnings, []); +}); + +test('真正的失败(500)仍然照旧抛错 —— 留痕不改变失败语义', async () => { + const h = await createHarness(); + const env = h.envFor(async () => + new Response(JSON.stringify({ error: 'Scheduled backup failed' }), { status: 500 }) + ); + + await assert.rejects(() => runScheduledBackupIfDue(env), /Scheduled backup failed/); + assert.deepEqual(skippedAuditRows(h), [], '500 不是「被跳过」,不该写 skipped 事件'); +}); + +// ---------------------------------------------------------------- 第 18 条 +test('源码护栏:备份尝试开始时不得清空 lastError*(否则错误会被重试循环吃掉)', () => { + const source = readFileSync(BACKUP_HANDLER_PATH, 'utf8'); + + const anchor = 'lastAttemptAt: now.toISOString()'; + const anchorIndex = source.indexOf(anchor); + assert.notEqual(anchorIndex, -1, '找不到「尝试开始」那段 runtime 更新:形状变了请同步更新本护栏'); + const attemptUpdate = source.slice(anchorIndex, anchorIndex + 400).split('}));')[0]; + assert.ok( + !attemptUpdate.includes('lastErrorMessage'), + '尝试开始时不得清空 lastErrorMessage:失败会让计划任务立刻重试,错误就在「清空 ↔ 写回」循环里消失(第 18 条)' + ); + assert.ok(!attemptUpdate.includes('lastErrorAt'), '同上:lastErrorAt 也要保留,否则无法判断错误是什么时候的'); + + // 反方向:成功分支**必须**清空(否则修成「错误永远不消失」) + const successAnchor = 'lastSuccessAt: new Date().toISOString()'; + const successIndex = source.indexOf(successAnchor); + assert.notEqual(successIndex, -1, '找不到成功分支的 runtime 更新:形状变了请同步更新本护栏'); + const successUpdate = source.slice(successIndex, successIndex + 400).split('}));')[0]; + assert.ok(successUpdate.includes('lastErrorMessage: null'), '成功时必须清空上次的错误,否则界面会一直显示陈旧失败'); +}); diff --git a/scripts/backup-handler.test.ts b/scripts/backup-handler.test.ts new file mode 100644 index 000000000..2da867bcc --- /dev/null +++ b/scripts/backup-handler.test.ts @@ -0,0 +1,313 @@ +// `src/handlers/backup.ts` 的行为测试 +// +// 为什么值得测:备份/恢复是**唯一直接关系数据存亡**的功能面,而且它是**管理员专用**的 —— +// 13 个端点全部吃 `actorUser`,任何一个漏掉权限判断都等于把整库导出能力开放给普通用户。 +// +// 本文件分两部分: +// ① **权限扫描**:把 13 个管理端点逐个用"非管理员"调一遍,全部必须 403。 +// 写成循环而不是抄 13 遍 —— 将来新增端点时只要加进数组,忘了守卫就会红。 +// ② **本地导出**(含附件):这是 §3.1.1 修过的缺陷,这里做端到端守卫 —— +// 勾了"包含附件"导出的 zip 必须**真的带附件字节**,而不是只有一个说"包含附件"的清单。 +// +// 运行方式:npm run test:backup-handler +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { handleCreateCipher } from '../src/handlers/ciphers'; +import { handleCreateAttachment, handleUploadAttachment } from '../src/handlers/attachments'; +import { + handleAdminExportBackup, + handleAdminImportBackup, + handleDeleteAdminRemoteBackup, + handleDownloadAdminBackupAttachment, + handleDownloadAdminRemoteBackup, + handleGetAdminBackupSettings, + handleGetAdminBackupSettingsRepairState, + handleInspectAdminRemoteBackup, + handleListAdminRemoteBackups, + handleRepairAdminBackupSettings, + handleRestoreAdminRemoteBackup, + handleRunAdminConfiguredBackup, + handleUpdateAdminBackupSettings, +} from '../src/handlers/backup'; +import { parseBackupArchive } from '../src/services/backup-archive'; +import { AuthService } from '../src/services/auth'; +import { StorageService } from '../src/services/storage'; +import type { Env, User } from '../src/types'; +import { createR2MemoryBucket } from './lib/r2-memory'; +import { createSchemaDatabase, enc, insertUser } from './lib/test-harness'; + +const ADMIN_ID = 'admin-1'; +const ADMIN_EMAIL = 'admin-1@example.test'; +const MEMBER_ID = 'member-1'; +/** 管理员的主密码(客户端先哈希一次后上传的值) */ +const CLIENT_HASH = 'admin-client-side-hash'; + +interface Harness { + handle: Awaited>; + connection: DatabaseSync; + env: Env; + bucket: ReturnType; + admin: User; + member: User; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + const bucket = createR2MemoryBucket(); + const env = { + DB: handle.db, + ATTACHMENTS: bucket.bucket, + JWT_SECRET: 'test-jwt-secret-at-least-32-characters-long', + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + + const adminHash = await new AuthService(env).hashPasswordServer(CLIENT_HASH, ADMIN_EMAIL); + insertUser(handle.connection, ADMIN_ID, { email: ADMIN_EMAIL, role: 'admin', masterPasswordHash: adminHash }); + insertUser(handle.connection, MEMBER_ID); + + const storage = new StorageService(handle.db); + const admin = (await storage.getUserById(ADMIN_ID))!; + const member = (await storage.getUserById(MEMBER_ID))!; + + return { handle, connection: handle.connection, env, bucket, admin, member }; +} + +function exportRequest(body: unknown, contentType = 'application/json'): Request { + return new Request('https://vault.example.test/api/admin/backup/export', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +// ---------------------------------------------------------------- ① 权限扫描 + +/** + * 全部管理端点。它们签名一致(`request, env, actorUser`),因此可以统一扫描。 + * 新增端点时把它加进来 —— 忘了 `isAdmin` 守卫,这里就会红。 + */ +const ADMIN_ENDPOINTS: Array<[string, (request: Request, env: Env, actorUser: User) => Promise]> = [ + ['handleGetAdminBackupSettings', handleGetAdminBackupSettings], + ['handleUpdateAdminBackupSettings', handleUpdateAdminBackupSettings], + ['handleGetAdminBackupSettingsRepairState', handleGetAdminBackupSettingsRepairState], + ['handleRepairAdminBackupSettings', handleRepairAdminBackupSettings], + ['handleRunAdminConfiguredBackup', handleRunAdminConfiguredBackup], + ['handleListAdminRemoteBackups', handleListAdminRemoteBackups], + ['handleDownloadAdminRemoteBackup', handleDownloadAdminRemoteBackup], + ['handleInspectAdminRemoteBackup', handleInspectAdminRemoteBackup], + ['handleDeleteAdminRemoteBackup', handleDeleteAdminRemoteBackup], + ['handleRestoreAdminRemoteBackup', handleRestoreAdminRemoteBackup], + ['handleAdminExportBackup', handleAdminExportBackup], + ['handleDownloadAdminBackupAttachment', handleDownloadAdminBackupAttachment], + ['handleAdminImportBackup', handleAdminImportBackup], +]; + +test('权限:全部 13 个管理端点对普通用户一律 403(且不泄露任何数据)', async () => { + const h = await createHarness(); + + for (const [name, handler] of ADMIN_ENDPOINTS) { + const request = new Request('https://vault.example.test/api/admin/backup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash: CLIENT_HASH }), + }); + const response = await handler(request, h.env, h.member); + assert.equal(response.status, 403, `${name} 必须拒绝非管理员,实际 ${response.status}`); + } + + h.handle.close(); +}); + +test('权限:未登录(actorUser 为普通用户且不带密码)同样被挡住', async () => { + const h = await createHarness(); + const response = await handleAdminExportBackup(exportRequest({}), h.env, h.member); + assert.equal(response.status, 403, '权限检查必须发生在密码校验之前'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- ② 本地导出 + +test('本地导出:缺 masterPasswordHash 或密码错误都拒绝', async () => { + const h = await createHarness(); + + const missing = await handleAdminExportBackup(exportRequest({}), h.env, h.admin); + assert.equal(missing.status, 400, '缺密码应 400'); + assert.match(String(((await missing.json()) as { error?: string }).error ?? ''), /required/i); + + const wrong = await handleAdminExportBackup(exportRequest({ masterPasswordHash: 'wrong' }), h.env, h.admin); + assert.equal(wrong.status, 400, '密码错误应 400'); + assert.match(String(((await wrong.json()) as { error?: string }).error ?? ''), /invalid password/i); + + h.handle.close(); +}); + +test('本地导出:成功返回 zip,且响应头做了防嗅探与禁缓存处理', async () => { + const h = await createHarness(); + const response = await handleAdminExportBackup(exportRequest({ masterPasswordHash: CLIENT_HASH }), h.env, h.admin); + + assert.equal(response.status, 200, `导出应成功,实际 ${response.status}`); + assert.equal(response.headers.get('Content-Type'), 'application/zip'); + assert.equal(response.headers.get('X-Content-Type-Options'), 'nosniff', '避免浏览器把归档当成可执行内容'); + assert.equal(response.headers.get('Cache-Control'), 'no-store', '整库导出不得被缓存'); + assert.match(String(response.headers.get('Content-Disposition')), /attachment;.*\.zip/); + + // 内容确实是可解析的归档 + const bytes = new Uint8Array(await response.arrayBuffer()); + assert.ok(bytes.byteLength > 0); + const parsed = parseBackupArchive(bytes); + assert.ok(parsed.payload.db, '归档里应含数据库快照'); + + // 导出应留下审计事件(管理员操作必须可追溯) + const audit = h.connection + .prepare("SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'admin.backup.export'") + .get() as { count: number }; + assert.equal(audit.count, 1, '本地导出应留下审计事件'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- ② 附件内联(§3.1.1 的端到端守卫) + +/** 造一个带附件(元数据 + R2 内容)的条目,返回 cipherId / attachmentId 与内容 */ +async function seedCipherWithAttachment( + h: Harness, + content = 'attachment-bytes-in-archive' +): Promise<{ cipherId: string; attachmentId: string; content: string }> { + const cipherResponse = await handleCreateCipher( + new Request('https://vault.example.test/api/ciphers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 1, name: enc('cipher'), login: { username: enc('u') } }), + }), + h.env, + ADMIN_ID + ); + const cipherId = String(((await cipherResponse.json()) as { id: string }).id); + + const size = new TextEncoder().encode(content).length; + const metaResponse = await handleCreateAttachment( + new Request(`https://vault.example.test/api/ciphers/${cipherId}/attachment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: enc('file'), key: enc('file-key'), fileSize: size }), + }), + h.env, + ADMIN_ID, + cipherId + ); + const meta = (await metaResponse.json()) as { attachmentId: string; url: string }; + const uploadPath = new URL(meta.url); + + const form = new FormData(); + form.set('data', new File([content], 'file.bin', { type: 'application/octet-stream' })); + await handleUploadAttachment( + new Request(`https://vault.example.test${uploadPath.pathname}${uploadPath.search}`, { method: 'POST', body: form }), + h.env, + ADMIN_ID, + cipherId, + meta.attachmentId + ); + + return { cipherId, attachmentId: meta.attachmentId, content }; +} + +test('本地导出(含附件):zip 里必须**真的有附件字节**,而不是只有一份声称包含附件的清单', async () => { + const h = await createHarness(); + const seeded = await seedCipherWithAttachment(h); + assert.equal(h.bucket.size(), 1, '前置条件:附件内容已写入 R2'); + + const response = await handleAdminExportBackup( + exportRequest({ masterPasswordHash: CLIENT_HASH, includeAttachments: true }), + h.env, + h.admin + ); + assert.equal(response.status, 200); + + const parsed = parseBackupArchive(new Uint8Array(await response.arrayBuffer())); + assert.equal(parsed.payload.manifest.includes.attachments, true, '清单应声明包含附件'); + + // 关键断言:归档文件里要能按附件 id 找到那个 blob,且内容与原文件一致。 + // §3.1.1 的缺陷正是"清单说有、字节没有",所以只断言清单是不够的。 + const blobEntries = Object.entries(parsed.files).filter(([name]) => name.includes(seeded.attachmentId)); + assert.equal(blobEntries.length, 1, `归档里应恰有一个附件条目,实际:${JSON.stringify(Object.keys(parsed.files))}`); + assert.equal( + new TextDecoder().decode(blobEntries[0][1]), + seeded.content, + '归档里的附件字节应与上传内容一致' + ); + + h.handle.close(); +}); + +test('本地导出(不含附件):默认可正常工作,且不要求 R2 里有 blob', async () => { + const h = await createHarness(); + // 只写元数据、不写 R2 —— 模拟"附件内容已丢失"的历史状态 + const cipherResponse = await handleCreateCipher( + new Request('https://vault.example.test/api/ciphers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 1, name: enc('cipher') }), + }), + h.env, + ADMIN_ID + ); + const cipherId = String(((await cipherResponse.json()) as { id: string }).id); + await handleCreateAttachment( + new Request(`https://vault.example.test/api/ciphers/${cipherId}/attachment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: enc('file'), key: enc('file-key'), fileSize: 10 }), + }), + h.env, + ADMIN_ID, + cipherId + ); + + const response = await handleAdminExportBackup(exportRequest({ masterPasswordHash: CLIENT_HASH }), h.env, h.admin); + assert.equal(response.status, 200, '不勾选附件时不应因为缺 blob 而失败'); + + h.handle.close(); +}); + +test('本地导出(含附件)但 blob 缺失时返回 409,且提示可操作', async () => { + const h = await createHarness(); + const cipherResponse = await handleCreateCipher( + new Request('https://vault.example.test/api/ciphers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 1, name: enc('cipher') }), + }), + h.env, + ADMIN_ID + ); + const cipherId = String(((await cipherResponse.json()) as { id: string }).id); + await handleCreateAttachment( + new Request(`https://vault.example.test/api/ciphers/${cipherId}/attachment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: enc('file'), key: enc('file-key'), fileSize: 10 }), + }), + h.env, + ADMIN_ID, + cipherId + ); + assert.equal(h.bucket.size(), 0, '前置条件:R2 里确实没有对象'); + + const response = await handleAdminExportBackup( + exportRequest({ masterPasswordHash: CLIENT_HASH, includeAttachments: true }), + h.env, + h.admin + ); + + assert.equal(response.status, 409, `附件内容缺失应是 409(可操作)而不是 500(服务端故障),实际 ${response.status}`); + const body = (await response.json()) as { error?: string }; + assert.match(String(body.error ?? ''), /blob missing/i, '错误信息应指出是哪个 blob 缺失'); + + h.handle.close(); +}); diff --git a/scripts/backup-remote-timeout.test.ts b/scripts/backup-remote-timeout.test.ts new file mode 100644 index 000000000..7796f755c --- /dev/null +++ b/scripts/backup-remote-timeout.test.ts @@ -0,0 +1,269 @@ +// 远端备份请求的超时守卫 +// +// 为什么值得测:这组超时把一类「永远不返回」的故障变成「可读的失败」,但它有三个 +// **静默失败点** —— 任何一个写错都不会报错,只会在真实故障时表现为又一条通用 500、 +// 或一段漫长的等待: +// ① 只包 `fetch()` 不包读 body —— `fetch()` 收到响应头就 resolve, +// 对端「发了头就不再发数据」时卡住的是 `await response.arrayBuffer()`; +// ② 超时被映射成 5xx —— 前端 `retryableRequest` 对 429/5xx 自动重试 3 次, +// 一次超时会被放大成约三倍等待; +// ③ 预算过短 —— 把「慢但成功」的大文件上传误杀成失败。 +// +// 因此下面既有行为断言,也有源码护栏(不得再出现裸露的 `await fetch(`)与「慢但成功」的正向用例。 +// +// 运行方式:npm run test:backup-remote-timeout +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import type { BackupDestinationRecord } from '../src/services/backup-config'; +import { REMOTE_REQUEST_ACTIONS, buildRemoteTimeoutMessage } from '../shared/backup-timeout-message'; +import { + DEFAULT_REMOTE_REQUEST_TIMEOUTS, + downloadRemoteBackupFile, + isRemoteRequestTimeoutError, + isRemoteRequestTimeoutMessage, + listRemoteBackupEntries, + remoteRequestFailureStatus, + resolveRemoteRequestTimeouts, + uploadRemoteBackupFile, +} from '../src/services/backup-uploader'; + +// `remotePath` 留空是刻意的:上传时就不会先触发 MKCOL(建目录走的是「控制类」预算), +// 这样用例测到的才是传输类预算本身。 +const WEBDAV_DESTINATION: BackupDestinationRecord = { + id: 'dest-webdav', + name: 'Test WebDAV', + type: 'webdav', + includeAttachments: true, + destination: { + baseUrl: 'https://dav.example.test', + username: 'user', + password: 'secret', + remotePath: '', + }, + schedule: { enabled: false, intervalHours: 24, startTime: '03:00', timezone: 'UTC', retentionCount: null }, + runtime: { + lastAttemptAt: null, + lastAttemptLocalDate: null, + lastSuccessAt: null, + lastErrorAt: null, + lastErrorMessage: null, + lastUploadedFileName: null, + lastUploadedSizeBytes: null, + lastUploadedDestination: null, + }, +}; + +const S3_DESTINATION: BackupDestinationRecord = { + ...WEBDAV_DESTINATION, + id: 'dest-s3', + name: 'Test S3', + type: 's3', + destination: { + endpoint: 'https://s3.example.test', + bucket: 'backups', + addressingStyle: 'path-style', + region: 'auto', + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + rootPath: '', + }, +}; + +type FetchStub = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); + +async function withFetchStub(stub: FetchStub, run: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = stub as typeof fetch; + try { + return await run(); + } finally { + globalThis.fetch = original; + } +} + +/** + * 黑洞目标:连接建立、但对端从不发送任何字节(丢包 / 被暂停的容器 / 挂住的代理)。 + * 必须尊重 abort —— 与真实 `fetch` 被 abort 后的行为一致,否则用例只会一直挂着。 + */ +function neverResponds(): FetchStub { + return (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('The operation was aborted'))); + }); +} + +/** 响应头立刻返回,但 body 永不结束 —— 用来证明超时覆盖到了「读 body」这一步。 */ +function headersThenHang(): FetchStub { + return async (_input, init) => { + const signal = init?.signal; + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => controller.error(new Error('The operation was aborted'))); + }, + }); + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/zip' } }); + }; +} + +// 挂起类用例都带显式 timeout:若超时未生效,期望的是一个**快速失败**(“测试超时”), +// 而不是让整个测试进程永远挂住 —— 这直接决定了这个文件能不能抓回退。 +test('列目录:对端永不响应时按「列目录预算」超时,并映射成不可重试的 400', { timeout: 3_000 }, async () => { + await withFetchStub(neverResponds(), async () => { + await assert.rejects( + () => listRemoteBackupEntries(WEBDAV_DESTINATION, '', { listingMs: 20 }), + (error: unknown) => { + assert.ok(isRemoteRequestTimeoutError(error), '必须是超时错误,而不是一直 pending'); + assert.equal((error as Error).message, 'WebDAV listing timed out after 20 ms'); + assert.equal( + remoteRequestFailureStatus(error), + 400, + '超时必须不可重试 —— 前端对 5xx 会自动重试 3 次,把一次超时放大成三倍等待' + ); + return true; + } + ); + }); +}); + +test('S3 路径同样不会挂死(第 8 处请求也包了超时)', { timeout: 3_000 }, async () => { + await withFetchStub(neverResponds(), async () => { + await assert.rejects( + () => listRemoteBackupEntries(S3_DESTINATION, '', { listingMs: 20 }), + (error: unknown) => + isRemoteRequestTimeoutError(error) + && (error as Error).message === 'S3 listing timed out after 20 ms' + ); + }); +}); + +test('下载:响应头到了但 body 不再发数据时,卡在「读 body」也会被超时打断', { timeout: 3_000 }, async () => { + await withFetchStub(headersThenHang(), async () => { + await assert.rejects( + () => downloadRemoteBackupFile(WEBDAV_DESTINATION, 'backup.zip', { firstByteMs: 20, transferMinMs: 30 }), + (error: unknown) => { + assert.ok(isRemoteRequestTimeoutError(error)); + // 报的是第二段(body)预算 ⇒ 证明确实是「读 body」阶段被抓到,而不是首包超时 + assert.equal((error as Error).message, 'WebDAV download timed out after 30 ms'); + return true; + } + ); + }); +}); + +test('上传:超时预算随体积放大(100 B @ 1000 B/s ⇒ 100 ms),而不是一律用固定值', { timeout: 3_000 }, async () => { + await withFetchStub(neverResponds(), async () => { + await assert.rejects( + () => + uploadRemoteBackupFile(WEBDAV_DESTINATION, 'attachment.bin', new Uint8Array(100), {}, { + transferMinMs: 1, + transferMaxMs: 60_000, + transferBytesPerSecond: 1_000, + }), + (error: unknown) => + isRemoteRequestTimeoutError(error) + && (error as Error).message === 'WebDAV upload timed out after 100 ms' + ); + }); +}); + +test('慢但成功:30 ms 才返回的响应不会因为预算 200 ms 而失败(不得误杀慢速上传)', async () => { + const slowButOk: FetchStub = async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return new Response(null, { status: 201 }); + }; + await withFetchStub(slowButOk, async () => { + await uploadRemoteBackupFile(WEBDAV_DESTINATION, 'attachment.bin', new Uint8Array(8), {}, { + transferMinMs: 200, + }); + }); +}); + +test('对端主动失败(连接被拒)不当成超时:错误原样冒泡,且沿用调用方给的状态码', async () => { + const refused: FetchStub = async () => { + throw new Error('connect ECONNREFUSED'); + }; + await withFetchStub(refused, async () => { + await assert.rejects( + () => listRemoteBackupEntries(WEBDAV_DESTINATION, '', { listingMs: 20 }), + (error: unknown) => { + assert.equal(isRemoteRequestTimeoutError(error), false, '连接被拒不是超时'); + assert.equal((error as Error).message, 'connect ECONNREFUSED'); + assert.equal(remoteRequestFailureStatus(error), 500, '默认回退值不变'); + assert.equal(remoteRequestFailureStatus(error, 409), 409, '调用方给的回退状态码要被保留'); + return true; + } + ); + }); +}); + +test('DO → handler 只传消息:超时按消息形状识别为 400,HTTP 状态类消息不受影响', () => { + assert.equal(isRemoteRequestTimeoutMessage(buildRemoteTimeoutMessage('WebDAV', 'upload', 15000)), true); + assert.equal(remoteRequestFailureStatus(buildRemoteTimeoutMessage('WebDAV', 'upload', 15000)), 400); + assert.equal(remoteRequestFailureStatus(buildRemoteTimeoutMessage('S3', 'download', 30)), 400); + // 与本项目消息形状不同的串(例如秒而不是毫秒)不应被误判 + assert.equal(isRemoteRequestTimeoutMessage('WebDAV upload timed out after 15 seconds'), false); + // 带状态码的失败仍按调用方的回退值处理 + assert.equal(remoteRequestFailureStatus('WebDAV upload failed: 403', 500), 500); + assert.equal(isRemoteRequestTimeoutMessage('Backup run failed'), false); +}); + +test('消息形状来自共享定义:所有 provider × action 组合都能被识别(防止有人绕过 shared 手写消息)', () => { + for (const action of REMOTE_REQUEST_ACTIONS) { + for (const provider of ['WebDAV', 'S3'] as const) { + const message = buildRemoteTimeoutMessage(provider, action, 12345); + assert.equal( + isRemoteRequestTimeoutMessage(message), + true, + `${provider} ${action} 的消息形状未被识别 —— 前端会看到英文原文、且超时会被当成 500 而重试 3 次` + ); + assert.equal(isRemoteRequestTimeoutMessage(`${message} `), false, '尾随空格不应被匹配'); + } + } +}); + +test('注入的非法预算被忽略(0 / 负数 / NaN / Infinity 会让计时器立即触发或永不触发)', () => { + assert.deepEqual(resolveRemoteRequestTimeouts(undefined), DEFAULT_REMOTE_REQUEST_TIMEOUTS); + assert.equal(resolveRemoteRequestTimeouts({ controlMs: 25 }).controlMs, 25); + assert.equal( + resolveRemoteRequestTimeouts({ controlMs: 25 }).listingMs, + DEFAULT_REMOTE_REQUEST_TIMEOUTS.listingMs + ); + const invalid = resolveRemoteRequestTimeouts({ + controlMs: 0, + listingMs: -1, + firstByteMs: Number.NaN, + transferBytesPerSecond: Number.POSITIVE_INFINITY, + }); + assert.equal(invalid.controlMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.controlMs); + assert.equal(invalid.listingMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.listingMs); + assert.equal(invalid.firstByteMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.firstByteMs); + assert.equal(invalid.transferBytesPerSecond, DEFAULT_REMOTE_REQUEST_TIMEOUTS.transferBytesPerSecond); +}); + +// ---------------------------------------------------------------- 源码护栏 +// 断言不了「每个 fetch 都在超时包装里」(文本上做不精确),但可以断言一条更本质的不变量: +// **每处 fetch( 都必须带 signal** —— signal 只能由 withRemoteTimeout 提供, +// 少了它超时就是形同虚设(请求可以永不 settle)。 +test('源码护栏:backup-uploader.ts 里每个 fetch( 都必须带 signal', () => { + const source = readFileSync(path.join(REPO_ROOT, 'src/services/backup-uploader.ts'), 'utf8') + // 先去掉注释:注释里提到 `fetch()` 不该被算成调用点 + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + const callSites = source.split('fetch(').slice(1); + assert.ok(callSites.length >= 8, `远端请求至少 8 处,实际只找到 ${callSites.length} 处(护栏可能已失效)`); + callSites.forEach((tail: string, index: number) => { + // 截到「下一次 fetch(」为止:这样断言的范围就是这一处调用自身, + // 不会靠后面的代码把 signal 蹭进来(固定字符窗口要么截断真调用、要么跨到下一处)。 + const nextCall = tail.indexOf('fetch('); + const scope = (nextCall === -1 ? tail : tail.slice(0, nextCall)).slice(0, 1_000); + assert.ok( + scope.includes('signal'), + `第 ${index + 1} 处 fetch( 没带 signal ⇒ 该请求可能永不 settle,超时形同虚设` + ); + }); +}); diff --git a/scripts/backup-roundtrip.test.ts b/scripts/backup-roundtrip.test.ts new file mode 100644 index 000000000..0d06db33c --- /dev/null +++ b/scripts/backup-roundtrip.test.ts @@ -0,0 +1,722 @@ +// 备份 / 恢复端到端 round-trip 测试 +// +// 为什么需要它:既有的 `security-audit-backup-auth-state.mjs` 只证明了**反向性质** —— +// 不该进备份的 6 张运行时认证表确实没进去。但**正向性质 —— 该进去的数据完整、且能原样 +// 还原 —— 从未被证明**。对密码管理器而言,这是最贴近用户利益的缺口。 +// +// 做法:用 `node:sqlite` 实现 D1Database 的最小接口(见 ./lib/d1-sqlite.ts),跑**真实 SQL**: +// 真实 schema(直接执行 migrations/0001_init.sql)、真实影子表、真实 swap,然后**逐行比对** +// 两个数据库。纯 mock 做不到 —— 它不存数据,只能断言"发了哪些 SQL",验证不了字段保真。 +// +// 本测试同时把「四处**有意不对称**」钉住。它们不是 bug 而是设计,但必须显式记录, +// 否则将来会被误报;反过来,若有人无意改动这些行为,测试会立刻失败。 +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { + assertBackupDbPayloadRestorable, + buildBackupArchive, + parseBackupArchive, + resolveInlineAttachmentBudgetBytes, +} from '../src/services/backup-archive'; +import { BACKUP_SETTINGS_CONFIG_KEY } from '../src/services/backup-config'; +import { importBackupArchiveBytes } from '../src/services/backup-import'; +import { getAttachmentObjectKey } from '../src/services/blob-store'; +import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from '../src/services/yubico-config'; +import type { Env } from '../src/types'; +import { createD1SqliteDatabase } from './lib/d1-sqlite'; +import { createR2MemoryBucket } from './lib/r2-memory'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const SCHEMA_SQL = readFileSync(path.join(REPO_ROOT, 'migrations', '0001_init.sql'), 'utf8'); +const NOW = '2026-01-01T00:00:00.000Z'; + +// backup-archive.ts 内部常量,未导出(`const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1'`) +const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1'; + +type Handle = ReturnType; +type Row = Record; + +function freshDatabase(): Handle { + const handle = createD1SqliteDatabase(); + handle.connection.exec(SCHEMA_SQL); + return handle; +} + +function envFor(handle: Handle): Env { + return { DB: handle.db } as unknown as Env; +} + +function selectAll(handle: Handle, table: string, orderBy: string): Row[] { + return handle.connection.prepare(`SELECT * FROM ${table} ORDER BY ${orderBy}`).all() as Row[]; +} + +/** 除 api_key 外逐行一致 —— api_key 属"有意不导出"(见下方对应测试) */ +function usersWithoutApiKey(handle: Handle): Row[] { + return selectAll(handle, 'users', 'id').map(({ api_key: _apiKey, ...rest }) => rest); +} + +function configMap(handle: Handle): Record { + const rows = selectAll(handle, 'config', 'key'); + return Object.fromEntries(rows.map((row) => [String(row.key), String(row.value)])); +} + +// 8 种 CipherType(src/types/index.ts:120)。每个都带一个"客户端未知字段", +// 用来验证 CONTRIBUTING 要求的「保留未知/未来字段」。 +const CIPHER_TYPES: ReadonlyArray = [ + [1, 'Login'], + [2, 'SecureNote'], + [3, 'Card'], + [4, 'Identity'], + [5, 'SSHKey'], + [6, 'BankAccount'], + [7, 'DriversLicense'], + [8, 'Passport'], +]; + +function seedSource(handle: Handle): void { + const db = handle.connection; + db.prepare( + 'INSERT INTO users (id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_nfc, api_key, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ).run( + 'user-1', 'alice@example.test', 'Alice', 'hint', 'master-hash', 'wrapped-key', + 'private-key', 'public-key', 0, 600000, 64, 4, 'stamp-1', 'admin', 'active', 1, + 'totp-secret', 'recovery-code', 'yubi-1', 1, 'API-KEY-MUST-NOT-BE-BACKED-UP', NOW, NOW + ); + db.prepare('INSERT INTO domain_settings (user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) VALUES (?,?,?,?,?)') + .run('user-1', '[[1,[2,3]]]', '["a.test"]', '["b.test"]', NOW); + db.prepare('INSERT INTO user_revisions (user_id, revision_date) VALUES (?,?)').run('user-1', NOW); + db.prepare('INSERT INTO folders (id, user_id, name, created_at, updated_at) VALUES (?,?,?,?,?)') + .run('folder-1', 'user-1', 'encrypted-folder-name', NOW, NOW); + + for (const [type, label] of CIPHER_TYPES) { + db.prepare( + 'INSERT INTO ciphers (id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ).run( + `cipher-${type}`, + 'user-1', + type, + 'folder-1', + `enc-name-${label}`, + `enc-notes-${label}`, + type % 2, + JSON.stringify({ name: `enc-name-${label}`, type, unknownFutureField: `keep-me-${type}`, nested: { deep: [1, 2, 3] } }), + type === 1 ? 1 : null, + `enc-key-${label}`, + NOW, + NOW, + type === 5 ? NOW : null, + type === 6 ? NOW : null + ); + } + + db.prepare( + 'INSERT INTO webauthn_credentials (id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ).run( + 'cred-1', 'user-1', 'login', 'YubiKey 5', 'pub-key', 'cred-id', 7, 'public-key', + 'aa-guid', '["usb","nfc"]', 'enc-usk', 'enc-pub', 'enc-priv', 1, NOW, NOW + ); + + // config:含两个"必须被脱敏丢弃"的内部 key + db.prepare('INSERT INTO config (key, value) VALUES (?,?)').run('ui.language', 'zh-CN'); + db.prepare('INSERT INTO config (key, value) VALUES (?,?)').run(BACKUP_RUNNER_LOCK_KEY, '{"token":"secret"}'); + db.prepare('INSERT INTO config (key, value) VALUES (?,?)').run(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, 'claim-secret'); + db.prepare('INSERT INTO config (key, value) VALUES (?,?)').run(BACKUP_SETTINGS_CONFIG_KEY, '{"destinations":[]}'); + + // sends:用于断言"Send 不参与备份" + db.prepare( + 'INSERT INTO sends (id, user_id, type, name, data, key, deletion_date, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)' + ).run('send-1', 'user-1', 0, 'enc-send-name', 'enc-send-data', 'send-key', NOW, NOW, NOW); +} + +async function exportBytes(handle: Handle, includeAttachments = false): Promise { + const bundle = await buildBackupArchive(envFor(handle), new Date(NOW), { includeAttachments }); + return bundle.bytes; +} + +async function roundTrip(): Promise<{ source: Handle; target: Handle; sourceBytes: Uint8Array; targetBytes: Uint8Array }> { + const source = freshDatabase(); + seedSource(source); + const sourceBytes = await exportBytes(source); + + const target = freshDatabase(); + await importBackupArchiveBytes(sourceBytes, envFor(target), 'actor-1', true); + const targetBytes = await exportBytes(target); + + return { source, target, sourceBytes, targetBytes }; +} + +// ------------------------------------------------------------------ 导出侧 + +test('导出覆盖文档化的 8 张表,且不包含运行时认证状态', async () => { + const handle = freshDatabase(); + seedSource(handle); + const parsed = parseBackupArchive(await exportBytes(handle)).payload.db as unknown as Record; + const tableNames = Object.keys(parsed).sort(); + assert.deepStrictEqual(tableNames, [ + 'attachments', + 'ciphers', + 'config', + 'domain_settings', + 'folders', + 'user_revisions', + 'users', + 'webauthn_credentials', + ]); + + for (const forbidden of [ + 'devices', + 'refresh_tokens', + 'auth_requests', + 'trusted_two_factor_device_tokens', + 'account_passkey_challenges', + 'used_attachment_download_tokens', + ]) { + assert.ok(!tableNames.includes(forbidden), `导出不应包含运行时认证表 ${forbidden}`); + } + handle.close(); +}); + +test('导出为全部 8 种 CipherType 保留客户端未知字段', async () => { + const handle = freshDatabase(); + seedSource(handle); + const ciphers = parseBackupArchive(await exportBytes(handle)).payload.db.ciphers as Row[]; + assert.equal(ciphers.length, CIPHER_TYPES.length); + + for (const [type, label] of CIPHER_TYPES) { + const row = ciphers.find((item) => item.id === `cipher-${type}`); + assert.ok(row, `缺少 CipherType=${type} (${label}) 的条目`); + const data = JSON.parse(String(row.data)) as Record; + assert.equal(data.unknownFutureField, `keep-me-${type}`, `CipherType=${type} 的未知字段丢失`); + assert.deepStrictEqual(data.nested, { deep: [1, 2, 3] }); + } + handle.close(); +}); + +// ------------------------------------------------------------------ 往返一致性 + +test('导出 → 导入后,数据逐行一致(除四处已记录的有意不对称)', async () => { + const { source, target } = await roundTrip(); + + for (const [table, orderBy] of [ + ['folders', 'id'], + ['ciphers', 'id'], + ['domain_settings', 'user_id'], + ['user_revisions', 'user_id'], + ['webauthn_credentials', 'id'], + ] as const) { + assert.deepStrictEqual(selectAll(target, table, orderBy), selectAll(source, table, orderBy), `${table} 往返不一致`); + } + + // users:除 api_key 外完全一致 + assert.deepStrictEqual(usersWithoutApiKey(target), usersWithoutApiKey(source), 'users 往返不一致'); + + source.close(); + target.close(); +}); + +test('二次导出与首次导出等价,未知字段仍然保留', async () => { + const { source, target, sourceBytes, targetBytes } = await roundTrip(); + + const first = parseBackupArchive(sourceBytes).payload.db; + const second = parseBackupArchive(targetBytes).payload.db; + + for (const table of ['folders', 'ciphers', 'domain_settings', 'user_revisions', 'webauthn_credentials'] as const) { + assert.deepStrictEqual(second[table], first[table], `${table} 二次导出不一致`); + } + + const cipher = (second.ciphers as Row[]).find((row) => row.id === 'cipher-1'); + assert.ok(cipher); + assert.equal((JSON.parse(String(cipher.data)) as Row).unknownFutureField, 'keep-me-1'); + + source.close(); + target.close(); +}); + +// -------------------------------------------------- 四处「有意不对称」(预期行为) + +test('不对称 1:Send 记录不参与备份,恢复后为空', async () => { + const { source, target } = await roundTrip(); + + assert.equal(selectAll(source, 'sends', 'id').length, 1, '源库应有一条 Send'); + assert.equal(selectAll(target, 'sends', 'id').length, 0, '恢复后 Send 表应为空(有意为之)'); + + const parsed = parseBackupArchive(await exportBytes(source)).payload.db as unknown as Record; + assert.ok(!('sends' in parsed), '导出的 db.json 不应含 sends 表'); + + source.close(); + target.close(); +}); + +test('不对称 2:users.api_key 不导出,恢复后为 NULL', async () => { + const { source, target } = await roundTrip(); + + const sourceRow = selectAll(source, 'users', 'id')[0]; + const targetRow = selectAll(target, 'users', 'id')[0]; + assert.equal(sourceRow.api_key, 'API-KEY-MUST-NOT-BE-BACKED-UP'); + assert.equal(targetRow.api_key, null, 'api_key 不应进入备份'); + + const exported = parseBackupArchive(await exportBytes(source)).payload.db.users as Row[]; + assert.ok(!Object.prototype.hasOwnProperty.call(exported[0], 'api_key'), '导出不应含 api_key 列'); + + source.close(); + target.close(); +}); + +test('不对称 3:内部 config key 被脱敏,不进入备份', async () => { + const source = freshDatabase(); + seedSource(source); + + const exported = configMapFromPayload(await exportBytes(source)); + assert.ok(!(BACKUP_RUNNER_LOCK_KEY in exported), '运行锁 key 不应进入备份'); + assert.ok(!(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY in exported), 'Yubico 引导声明 key 不应进入备份'); + assert.equal(exported['ui.language'], 'zh-CN', '普通 config 应完整保留'); + + source.close(); +}); + +test('不对称 4:恢复会强制把实例标记为 registered', async () => { + const { source, target } = await roundTrip(); + + assert.ok(!('registered' in configMap(source)), '源库无需 registered 标记'); + assert.equal(configMap(target).registered, 'true', '恢复后必须标记为已注册,避免首用户提权逻辑误触发'); + + source.close(); + target.close(); +}); + +/** 直接从归档里取 config 行(不经过导入) */ +function configMapFromPayload(bytes: Uint8Array): Record { + const rows = parseBackupArchive(bytes).payload.db.config as Row[]; + return Object.fromEntries(rows.map((row) => [String(row.key), String(row.value)])); +} + +// ------------------------------------------------------------------ 附件(blob) + +const ATTACHMENT_ID = 'attachment-1'; +const ATTACHMENT_CIPHER_ID = 'cipher-1'; +const ATTACHMENT_BYTES = new TextEncoder().encode('CONFIDENTIAL-ATTACHMENT-BYTES'); +const ATTACHMENT_ZIP_PATH = `attachments/${ATTACHMENT_CIPHER_ID}/${ATTACHMENT_ID}.bin`; + +/** 给源库加一条附件元数据行(blob 由调用方放进 R2) */ +function seedAttachmentRow(handle: Handle): void { + handle.connection + .prepare('INSERT INTO attachments (id, cipher_id, file_name, size, size_name, key) VALUES (?,?,?,?,?,?)') + .run( + ATTACHMENT_ID, + ATTACHMENT_CIPHER_ID, + 'encrypted-file-name', + ATTACHMENT_BYTES.byteLength, + `${ATTACHMENT_BYTES.byteLength} B`, + 'enc-file-key' + ); +} + +async function exportWithAttachments(options: { inlineAttachmentBlobs?: boolean } = {}) { + const source = freshDatabase(); + seedSource(source); + seedAttachmentRow(source); + + const r2 = createR2MemoryBucket(); + await r2.bucket.put(getAttachmentObjectKey(ATTACHMENT_CIPHER_ID, ATTACHMENT_ID), ATTACHMENT_BYTES, { + httpMetadata: { contentType: 'application/pdf' }, + }); + + const bundle = await buildBackupArchive( + { DB: source.db, ATTACHMENTS: r2.bucket } as unknown as Env, + new Date(NOW), + { includeAttachments: true, ...options } + ); + return { source, r2, bytes: bundle.bytes, manifest: bundle.manifest }; +} + +test('附件 1:本地导出勾选「包含附件」时,zip 必须自包含附件文件', async () => { + const { source, bytes } = await exportWithAttachments({ inlineAttachmentBlobs: true }); + + const names = Object.keys(parseBackupArchive(bytes, { allowExternalAttachmentBlobs: true }).files); + assert.ok( + names.includes(ATTACHMENT_ZIP_PATH), + `zip 应内联附件文件,实际条目:${JSON.stringify(names)}` + ); + + source.close(); +}); + +test('附件 2:该 zip 必须能被本地导入接受,且 blob 内容与元数据一致', async () => { + const { source, bytes } = await exportWithAttachments({ inlineAttachmentBlobs: true }); + + const target = freshDatabase(); + const targetR2 = createR2MemoryBucket(); + const result = await importBackupArchiveBytes( + bytes, + { DB: target.db, ATTACHMENTS: targetR2.bucket } as unknown as Env, + 'actor-1', + true + ); + + assert.equal(result.result.skipped.attachments, 0, '不应有被跳过的附件'); + assert.deepStrictEqual( + targetR2.bytesOf(getAttachmentObjectKey(ATTACHMENT_CIPHER_ID, ATTACHMENT_ID)), + ATTACHMENT_BYTES, + '恢复后的 blob 内容应与源一致' + ); + assert.deepStrictEqual(selectAll(target, 'attachments', 'id'), selectAll(source, 'attachments', 'id')); + + source.close(); + target.close(); +}); + +test('附件 3:blob 缺失时应明确报错,而不是产出静默不完整的 zip', async () => { + const source = freshDatabase(); + seedSource(source); + seedAttachmentRow(source); // 有元数据行,但 R2 里没有对应 blob + + const emptyR2 = createR2MemoryBucket(); + await assert.rejects( + () => + buildBackupArchive( + { DB: source.db, ATTACHMENTS: emptyR2.bucket } as unknown as Env, + new Date(NOW), + { includeAttachments: true, inlineAttachmentBlobs: true } + ), + /blob missing/i + ); + + source.close(); +}); + +test('附件 4:不内联时归档仍只含元数据 —— 保护远端"单独增量上传"的既有设计', async () => { + const { source, bytes, manifest } = await exportWithAttachments(); + + const names = Object.keys(parseBackupArchive(bytes, { allowExternalAttachmentBlobs: true }).files); + assert.deepStrictEqual(names.sort(), ['db.json', 'manifest.json'], '未内联时不应含附件文件'); + assert.equal(manifest.includes.attachments, true); + assert.equal(manifest.attachmentBlobs?.length, 1, '元数据仍须列出待上传的 blob'); + + source.close(); +}); + +test('附件 5:仅元数据的远端归档走本地导入时,应给出可操作的提示', async () => { + // 远端备份的归档不含附件字节(附件由 uploadRemoteAttachmentChunk 单独增量上传), + // 而本地导入要求内联 → 必然失败。**失败是正确的**,但必须告诉用户"该怎么办", + // 而不是一句通用的 `missing required file: attachments/...`(且该文案此前还未接入 i18n, + // 所有语言看到都是英文)。 + const { source, bytes } = await exportWithAttachments(); // 不内联 = 远端形态 + + const target = freshDatabase(); + await assert.rejects( + () => importBackupArchiveBytes(bytes, { DB: target.db } as unknown as Env, 'actor-1', true), + (error: Error) => { + assert.match( + error.message, + /restore it from the remote destination instead/i, + `应提示改用"从远端恢复",实际消息:${error.message}` + ); + assert.doesNotMatch(error.message, /missing required file/i, '不应再是通用文案'); + return true; + } + ); + + source.close(); + target.close(); +}); + +test('附件 6:附件相关的后端文案必须已登记到 i18n 映射表(否则用户会看到英文原文)', () => { + // 前端靠「英文字符串 → i18n 键」查表来本地化后端错误(webapp/src/lib/i18n.ts 的 + // translateServerError),**未命中就原样显示英文**。因此后端新增文案时必须同步登记 —— + // 这条关联极易被后来者遗漏(改后端字符串不会有任何编译期报错),故用测试锁住。 + const archiveSource = readFileSync(path.join(REPO_ROOT, 'src/services/backup-archive.ts'), 'utf8'); + const message = archiveSource.match(/const MISSING_ATTACHMENT_FILES_MESSAGE\s*=\s*'([^']+)'/)?.[1]; + assert.ok(message, '应能从 backup-archive.ts 提取 MISSING_ATTACHMENT_FILES_MESSAGE'); + + const escaped = message.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const i18nSource = readFileSync(path.join(REPO_ROOT, 'webapp/src/lib/i18n.ts'), 'utf8'); + const mapped = i18nSource.match(new RegExp(`'${escaped}':\\s*'([^']+)'`))?.[1]; + assert.ok(mapped, `i18n 映射表缺少该后端文案的条目:${message}`); + + for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru', 'es', 'fi', 'de', 'fr', 'it', 'sv']) { + const localeSource = readFileSync( + path.join(REPO_ROOT, `webapp/src/lib/i18n/locales/${locale}.ts`), + 'utf8' + ); + assert.ok(localeSource.includes(`"${mapped}"`), `${locale} 缺少键 ${mapped}`); + } +}); + +test('附件 7:内联导出超出体积预算时必须拒绝,而不是产出无法本地恢复的归档', async () => { + // 回归点:此前的预检只把「附件字节数」与 64 MiB 比较 —— 既没算进 db.json(恢复侧按 + // 解压后**总**字节判定上限,见 createBackupUnzipFilter),也没考虑 zipSync 会把内存 + // 占用翻倍。结果是导出成功、但本地导入必然失败的归档。 + const source = freshDatabase(); + seedSource(source); + const oversizedBytes = 33 * 1024 * 1024; // 超过 32 MiB 的内联总量上限 + source.connection + .prepare('INSERT INTO attachments (id, cipher_id, file_name, size, size_name, key) VALUES (?,?,?,?,?,?)') + .run(ATTACHMENT_ID, ATTACHMENT_CIPHER_ID, 'encrypted-file-name', oversizedBytes, `${oversizedBytes} B`, 'enc-file-key'); + + // 故意不放 blob:预检必须**早于**读 blob 触发,否则本用例会以 /blob missing/ 通过 + const emptyR2 = createR2MemoryBucket(); + await assert.rejects( + () => + buildBackupArchive( + { DB: source.db, ATTACHMENTS: emptyR2.bucket } as unknown as Env, + new Date(NOW), + { includeAttachments: true, inlineAttachmentBlobs: true } + ), + (error: Error) => { + assert.match(error.message, /is too large to export/i, `应报导出体积超限,实际:${error.message}`); + assert.doesNotMatch(error.message, /blob missing/i, '应在读取 blob 之前就拒绝'); + return true; + } + ); + + source.close(); +}); + +test('附件 8:内联预算必须同时扣除 db.json 并满足内存上限', () => { + const MIB = 1024 * 1024; + // 2 ×(db.json + 附件)是内联导出的峰值内存,必须显著低于 Worker 的 128 MB 上限 + for (const dbPayloadBytes of [0, MIB, 16 * MIB, 31 * MIB]) { + const budget = resolveInlineAttachmentBudgetBytes(dbPayloadBytes); + const total = dbPayloadBytes + budget; + assert.equal(total, 32 * MIB, `db.json = ${dbPayloadBytes} 时解压后总量上限应恒为 32 MiB`); + assert.ok(2 * total <= 128 * MIB, `峰值内存 ${2 * total} 必须低于 Worker 上限`); + } + // db.json 自身超出恢复侧单条目上限(32 MiB)时,预算必须为负 —— 任何附件都不许内联 + assert.ok(resolveInlineAttachmentBudgetBytes(40 * MIB) < 0, 'db.json 过大时必须拒绝内联'); +}); + +test('附件 9:导出体积超限文案必须已登记到 i18n 映射表(正则形式)', () => { + // 与「附件 6」同一类问题:后端文案未被前端识别时,用户看到的是英文原文。 + // 该文案带会变化的字节数,无法精确查表,只能用「前缀 + 数字」正则,故断言正则本身存在。 + const archiveSource = readFileSync(path.join(REPO_ROOT, 'src/services/backup-archive.ts'), 'utf8'); + const prefix = archiveSource.match(/TOO_LARGE_TO_EXPORT_MESSAGE_PREFIX\s*=\s*'([^']+)'/)?.[1]; + assert.ok(prefix, '应能从 backup-archive.ts 提取 TOO_LARGE_TO_EXPORT_MESSAGE_PREFIX'); + + const i18nSource = readFileSync(path.join(REPO_ROOT, 'webapp/src/lib/i18n.ts'), 'utf8'); + assert.ok(i18nSource.includes(prefix), `i18n 正则表缺少该前缀:${prefix}`); + assert.ok(i18nSource.includes('txt_backup_error_archive_export_too_large'), 'i18n 映射表应引用 txt_backup_error_archive_export_too_large'); + + for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru', 'es', 'fi', 'de', 'fr', 'it', 'sv']) { + const localeSource = readFileSync( + path.join(REPO_ROOT, `webapp/src/lib/i18n/locales/${locale}.ts`), + 'utf8' + ); + assert.ok( + localeSource.includes('"txt_backup_error_archive_export_too_large"'), + `${locale} 缺少键 txt_backup_error_archive_export_too_large` + ); + } +}); + +test('附件 10:导出侧 db.json 上限必须与恢复侧的单条目上限一致(32 MiB)', () => { + const MIB = 1024 * 1024; + // 这两个数字必须锁在一起:导出放行的体积一旦超过恢复侧的 MAX_BACKUP_DB_JSON_BYTES, + // 就会重新出现"导出成功、但谁都无法恢复"的归档。此处把 33554432 写死, + // 目的是让任何单方面调整上限的改动都会立刻让本用例失败。 + assertBackupDbPayloadRestorable(32 * MIB, 32 * MIB); + assert.throws( + () => assertBackupDbPayloadRestorable(32 * MIB + 1), + /^Error: Backup database payload is too large to restore: 33554433 database bytes exceed the 33554432 byte limit$/ + ); +}); + +test('附件 11:不内联附件的导出路径也必须做 db.json 体积预检(回归)', async () => { + // 回归点:预检过去只存在于 `includeAttachments && inlineAttachmentBlobs` 分支里, + // 于是**远端 / 定时备份**与**本地导出但不勾选附件**都会产出恢复侧必然拒收的归档。 + const source = freshDatabase(); + seedSource(source); + + // 真实上限是 32 MiB,为覆盖拒绝分支注入一个极小的上限(生产调用方不传这个参数) + await assert.rejects( + () => + buildBackupArchive(envFor(source), new Date(NOW), { + includeAttachments: false, + restorableDbPayloadLimitBytes: 512, + }), + (error: Error) => { + assert.match(error.message, /database payload is too large to restore/i, `应报 db.json 超限,实际:${error.message}`); + assert.match(error.message, /exceed the 512 byte limit/, '报错应带上具体字节数与上限'); + return true; + } + ); + + // 对照:同一份数据在默认上限下必须能正常导出 —— 证明上面的失败源于体积,而非其他错误 + const bundle = await buildBackupArchive(envFor(source), new Date(NOW), { includeAttachments: false }); + assert.ok(bundle.bytes.byteLength > 0); + assert.ok(bundle.manifest.tableCounts.ciphers > 0); + + source.close(); +}); + +test('附件 12:db.json 超限文案必须已登记到 i18n 映射表(正则形式)', () => { + const archiveSource = readFileSync(path.join(REPO_ROOT, 'src/services/backup-archive.ts'), 'utf8'); + const prefix = archiveSource.match(/TOO_LARGE_DB_PAYLOAD_MESSAGE_PREFIX\s*=\s*'([^']+)'/)?.[1]; + assert.ok(prefix, '应能从 backup-archive.ts 提取 TOO_LARGE_DB_PAYLOAD_MESSAGE_PREFIX'); + + const i18nSource = readFileSync(path.join(REPO_ROOT, 'webapp/src/lib/i18n.ts'), 'utf8'); + assert.ok(i18nSource.includes(prefix), `i18n 正则表缺少该前缀:${prefix}`); + assert.ok( + i18nSource.includes('txt_backup_error_archive_db_payload_too_large'), + 'i18n 映射表应引用 txt_backup_error_archive_db_payload_too_large' + ); + + for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru', 'es', 'fi', 'de', 'fr', 'it', 'sv']) { + const localeSource = readFileSync( + path.join(REPO_ROOT, `webapp/src/lib/i18n/locales/${locale}.ts`), + 'utf8' + ); + assert.ok( + localeSource.includes('"txt_backup_error_archive_db_payload_too_large"'), + `${locale} 缺少键 txt_backup_error_archive_db_payload_too_large` + ); + } +}); + +test('附件 13:解析后必须释放 db.json 的解压字节(否则与解析出的对象树同时占内存)', async () => { + const source = freshDatabase(); + seedSource(source); + const parsed = parseBackupArchive(await exportBytes(source)); + + // 条目名保留(调用方仍能列举归档内容),但字节已被释放 + assert.deepStrictEqual(Object.keys(parsed.files).sort(), ['db.json', 'manifest.json']); + assert.equal(parsed.files['db.json'].byteLength, 0, 'db.json 的解压字节应在解析后立即释放'); + assert.ok(parsed.payload.db.ciphers.length > 0, '数据本身必须完整解析出来'); + assert.ok(parsed.payload.db.users.length > 0); + + source.close(); +}); + +test('附件 14:恢复写入必须分批提交,而不是整表一个 db.batch', async () => { + const ROW_TOTAL = 500; + const source = freshDatabase(); + seedSource(source); + // 加行到远超一批:整表一次提交的实现会在这里出现一个 500+ 条的 batch + const insert = source.connection.prepare( + 'INSERT INTO ciphers (id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ); + for (let i = 0; i < ROW_TOTAL; i++) { + const id = `bulk-${i}`; + insert.run(id, 'user-1', 1, 'folder-1', `enc-name-${i}`, `enc-notes-${i}`, 0, JSON.stringify({ i }), null, null, NOW, NOW, null, null); + } + const bytes = await exportBytes(source); + + const target = freshDatabase(); + const batches: number[] = []; + const countingDb = new Proxy(target.db, { + get(targetDb, property, receiver) { + if (property === 'batch') { + return async (statements: unknown[]) => { + batches.push(statements.length); + return (targetDb.batch as (items: unknown[]) => Promise)(statements); + }; + } + const value = Reflect.get(targetDb, property, receiver); + return typeof value === 'function' ? value.bind(targetDb) : value; + }, + }); + + await importBackupArchiveBytes(bytes, { DB: countingDb } as unknown as Env, 'user-1', false); + + const cipherRows = selectAll(target, 'ciphers', 'id'); + assert.equal(cipherRows.length, ROW_TOTAL + CIPHER_TYPES.length, '全部行都必须写进去'); + assert.ok(batches.length > 1, `应分多次 batch 提交,实际只调用 ${batches.length} 次`); + const largest = Math.max(...batches); + assert.ok(largest <= 200, `单批不应超过 200 条语句,实际最大 ${largest}`); + + source.close(); + target.close(); +}); + +// ────────────────────────────────────────── 「进度上报不得决定业务成败」的守卫 +// +// 不变式的定义与背景见 `src/services/backup-progress.ts` 的 CONTRACT(即 H3 事故)。 +// 简言之:`handlers/backup.ts` 传给恢复流程的进度回调会先 `touchLease()` 去续 Durable Object +// 的作业租约,而那一步**会抛**;当时的调用点写的是裸 `await progress?.(...)`,于是「上报失败」 +// 被当成了「恢复失败」。下面两条分别钉住它的两个后果。 + +test('附件 15:进度上报每次抛错,也必须导入成功且数据真的落库', async () => { + const source = freshDatabase(); + seedSource(source); + const sourceBytes = await exportBytes(source); + + const target = freshDatabase(); + const reported: string[] = []; + const imported = await importBackupArchiveBytes(sourceBytes, envFor(target), 'actor-1', true, async (event) => { + reported.push(event.step); + // 用 async + throw 贴近真实:`touchLease()` 是在 await 之后失败的 + throw new Error('progress callback exploded'); + }); + + assert.ok(reported.length > 0, '前置条件:回调应确实被调用过,否则本测试没覆盖到真实场景'); + assert.ok( + reported.includes('local_complete'), + `「完成」阶段也必须尝试上报过 —— 它正是过去会对外报 500 的那一处;实际:${JSON.stringify(reported)}` + ); + + // 导入必须报成功,且数据真的落库 —— 否则「成功」只是假象 + assert.equal(imported.result.imported.users, 1); + assert.equal( + (target.connection.prepare('SELECT COUNT(*) AS count FROM ciphers').get() as { count: number }).count, + CIPHER_TYPES.length + ); + + source.close(); + target.close(); +}); + +test('附件 16:导入失败时,进度上报抛错不得覆盖原始的失败原因', async () => { + const source = freshDatabase(); + seedSource(source); + const sourceBytes = await exportBytes(source); + const target = freshDatabase(); + const reported: string[] = []; + + // 让写库这一步在 try 内部失败(真失败),从而走到 catch 里的「失败」上报那一步。 + // 注:缺 ATTACHMENTS 绑定不是真失败 —— 附件会被记入 `skipped` 并继续,那是设计。 + // + // 触发时机刻意用「首次进度上报之后再失败」而不是数第几次 batch:清理残留的 + // resetRestoreArtifacts() 跑在 try **之前**,若在那里就失败,永远不会走到 catch 里的 + // 失败上报,这条测试就变成空断言了(下面那条前置断言就是为此设的)。 + const failingDb = new Proxy(target.db, { + get(targetDb, property, receiver) { + if (property === 'batch') { + return async (statements: unknown[]) => { + if (reported.length === 0) { + return (targetDb.batch as (items: unknown[]) => Promise)(statements); + } + throw new Error('database exploded'); + }; + } + const value = Reflect.get(targetDb, property, receiver); + return typeof value === 'function' ? value.bind(targetDb) : value; + }, + }); + + await assert.rejects( + () => + importBackupArchiveBytes(sourceBytes, { DB: failingDb } as unknown as Env, 'actor-1', true, async (event) => { + reported.push(event.step); + throw new Error('progress callback exploded'); + }), + (error: Error) => { + assert.match(error.message, /database exploded/i, `抛出的应是原始的失败原因,实际:${error.message}`); + assert.doesNotMatch( + error.message, + /progress callback exploded/i, + '进度回调的错误绝不能覆盖原始失败原因(否则排障时看不到真因)' + ); + return true; + } + ); + + assert.ok( + reported.includes('local_failed'), + `前置条件:失败阶段应尝试上报过,否则本测试没覆盖到「覆盖原始原因」这条分支;实际:${JSON.stringify(reported)}` + ); + + source.close(); + target.close(); +}); diff --git a/scripts/ciphers-handler.test.ts b/scripts/ciphers-handler.test.ts new file mode 100644 index 000000000..6aefd0cf6 --- /dev/null +++ b/scripts/ciphers-handler.test.ts @@ -0,0 +1,363 @@ +// ciphers handler 的行为测试 +// +// 为什么需要它:`ciphers.ts` 是核心数据面(约 1500 行),却**没有任何测试** —— +// 第 1-10 轮的后端审计只覆盖了安全面(注入 / 越权 / 并发 / 限流),§3.4 的统计显示 +// 20 个 handler 中只有 2 个被测试引用。 +// +// 本文件用**真实 SQL**(node:sqlite 适配器 + migrations/0001_init.sql)驱动真实 handler, +// 重点是三件最要紧的事: +// 1. **跨用户隔离** —— 越权读写必须 404,且数据分毫不变 +// 2. **陈旧写入** —— 过期的 lastKnownRevisionDate 必须被拒绝 +// 3. **未知字段保留** —— CONTRIBUTING 的硬性要求(Bitwarden 兼容面) +// +// 运行方式(必须带模块解析钩子): +// npm run test:ciphers-handler +// +// 原因:`ciphers.ts` 间接 import `src/durable/notifications-hub.ts`,后者从 Workers 虚拟 +// 模块 `cloudflare:workers` 导入,而 Node 无法解析该协议 +// (ERR_UNSUPPORTED_ESM_URL_SCHEME)。钩子把该模块重定向到本地桩。 +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import type { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +import test from 'node:test'; + +import { + handleCreateCipher, + handleDeleteCipherCompat, + handleGetCipher, + handleRestoreCipher, + handleUpdateCipher, +} from '../src/handlers/ciphers'; +import type { Env } from '../src/types'; +import { createD1SqliteDatabase } from './lib/d1-sqlite'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const SCHEMA_SQL = readFileSync(path.join(REPO_ROOT, 'migrations', '0001_init.sql'), 'utf8'); +const NOW = '2026-01-01T00:00:00.000Z'; + +const USER_A = 'user-a'; +const USER_B = 'user-b'; + +/** 远早于任何 updated_at,用于触发 stale 检查(判定阈值是差值 >1000ms) */ +const STALE_REVISION = '2020-01-01T00:00:00.000Z'; + +/** + * 生成合法的 Bitwarden EncString。 + * 服务端会校验"加密串"格式(`validateCipherEncryptedFieldsForCompatibility`): + * type 2 = AES-CBC-HMAC,需 `2.||` 三段。传明文会被 400 拒绝。 + */ +function enc(label: string): string { + return `2.${label}-iv|${label}-data|${label}-mac`; +} + +interface Harness { + handle: ReturnType; + env: Env; + connection: DatabaseSync; +} + +function createHarness(): Harness { + const handle = createD1SqliteDatabase(); + handle.connection.exec(SCHEMA_SQL); + for (const [id, email] of [ + [USER_A, 'a@example.test'], + [USER_B, 'b@example.test'], + ] as const) { + handle.connection + .prepare( + 'INSERT INTO users (id, email, name, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, status, verify_devices, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run(id, email, id, 'master-hash', 'wrapped-key', 0, 600000, `stamp-${id}`, 'user', 'active', 0, NOW, NOW); + } + + // 桩掉 NOTIFICATIONS_HUB:handler 在写操作后会发通知,缺绑定虽被 try/catch 吞掉, + // 但会往 stderr 打一堆堆栈、淹没有用信息。给它一个空实现更接近生产形态。 + const notificationsHub = { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }; + + return { + handle, + env: { DB: handle.db, NOTIFICATIONS_HUB: notificationsHub } as unknown as Env, + connection: handle.connection, + }; +} + +function jsonRequest(body: unknown, method = 'POST'): Request { + return new Request('https://vault.example.test/api/ciphers', { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === null ? undefined : JSON.stringify(body), + }); +} + +async function callCreate(env: Env, userId: string, body: Record) { + const response = await handleCreateCipher(jsonRequest(body), env, userId); + return { status: response.status, body: (await response.json()) as Record }; +} + +async function callUpdate(env: Env, userId: string, id: string, body: Record) { + const response = await handleUpdateCipher(jsonRequest(body, 'PUT'), env, userId, id); + return { status: response.status, body: (await response.json()) as Record }; +} + +function cipherRow(connection: DatabaseSync, id: string): Record | undefined { + return connection.prepare('SELECT * FROM ciphers WHERE id = ?').get(id) as Record | undefined; +} + +function cipherData(connection: DatabaseSync, id: string): Record { + const row = cipherRow(connection, id); + assert.ok(row, `ciphers 表中缺少 ${id}`); + return JSON.parse(String(row.data)) as Record; +} + +/** 建一个属于 USER_A 的 login 类型条目,返回其 id */ +async function seedCipher(h: Harness, extra: Record = {}): Promise { + const created = await callCreate(h.env, USER_A, { + type: 1, + name: enc('name'), + notes: enc('notes'), + login: { username: enc('username'), password: enc('password') }, + ...extra, + }); + assert.equal(created.status, 200, `创建应成功,实际 ${created.status}:${JSON.stringify(created.body)}`); + return String(created.body.id); +} + +// ---------------------------------------------------------------- 创建与读取 + +test('创建:cipher.key 非法时的文案必须归因正确,且已登记 i18n(服务器其实**支持**逐项密钥)', async () => { + const h = createHarness(); + + // ① 合法 EncString ⇒ 接受并**原样入库**。这就是"服务器支持逐项密钥"的直接证据 + // (config-response.ts 的 'cipher-key-encryption': true 与之相符)。 + const itemKey = enc('item-key'); + const accepted = await callCreate(h.env, USER_A, { type: 1, name: enc('name'), key: itemKey }); + assert.equal(accepted.status, 200, `合法 cipher.key 应被接受:${JSON.stringify(accepted.body)}`); + assert.equal(cipherRow(h.connection, String(accepted.body.id))?.key, itemKey, '合法 key 必须原样入库'); + + // ② 畸形值 ⇒ 400,且**不得**再说"服务器不支持逐项密钥"(旧文案既归错因、又给了无效建议) + const rejected = await callCreate(h.env, USER_A, { type: 1, name: enc('name'), key: 'not-an-encstring' }); + assert.equal(rejected.status, 400); + const message = String(rejected.body.error || ''); + assert.match(message, /not a valid encrypted string/i, `文案应指出"值不是合法加密串",实际:${message}`); + assert.doesNotMatch(message, /not supported/i, '不得再声称"不支持逐项密钥"(错误归因)'); + + // ③ 文案必须登记到前端 i18n 映射表,且 10 个语言包都有该键 + // (前端靠「英文字符串 → i18n 键」查表本地化后端错误,未命中就原样显示英文) + const source = readFileSync(path.join(REPO_ROOT, 'src/handlers/ciphers.ts'), 'utf8'); + const declared = source.match(/const INVALID_CIPHER_KEY_MESSAGE\s*=\s*\n?\s*'([^']+)'/)?.[1]; + assert.ok(declared, '应能从 ciphers.ts 提取 INVALID_CIPHER_KEY_MESSAGE'); + assert.equal(declared, message, '响应文案应与常量一致(避免两处副本漂移)'); + + const escaped = declared.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const i18nSource = readFileSync(path.join(REPO_ROOT, 'webapp/src/lib/i18n.ts'), 'utf8'); + const mapped = i18nSource.match(new RegExp(`'${escaped}':\\s*'([^']+)'`))?.[1]; + assert.ok(mapped, `i18n 映射表缺少该后端文案的条目:${declared}`); + + for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru', 'es', 'fi', 'de', 'fr', 'it', 'sv']) { + const localeSource = readFileSync(path.join(REPO_ROOT, `webapp/src/lib/i18n/locales/${locale}.ts`), 'utf8'); + assert.ok(localeSource.includes(`"${mapped}"`), `${locale} 缺少键 ${mapped}`); + } + + h.handle.close(); +}); + +test('创建:服务端接管 id/userId/时间戳,且保留客户端未知字段', async () => { + const h = createHarness(); + const created = await callCreate(h.env, USER_A, { + type: 1, + name: enc('name'), + login: { username: enc('u') }, + futureClientField: 'keep-me', + nested: { deep: [1, 2] }, + }); + + assert.equal(created.status, 200); + const id = String(created.body.id); + assert.ok(id, '响应应含服务端生成的 id'); + + const row = cipherRow(h.connection, id); + assert.ok(row, '应写入 ciphers 表'); + assert.equal(row.user_id, USER_A, 'userId 必须取自会话参数,而不是客户端'); + assert.equal(row.type, 1); + assert.equal(row.deleted_at, null, '新建条目不应是已删除状态'); + + const data = cipherData(h.connection, id); + assert.equal(data.futureClientField, 'keep-me', '客户端未知字段必须保留(CONTRIBUTING 硬性要求)'); + assert.deepStrictEqual(data.nested, { deep: [1, 2] }); + + h.handle.close(); +}); + +test('创建:客户端伪造的 userId 必须被服务端覆盖', async () => { + const h = createHarness(); + const forged = await callCreate(h.env, USER_A, { + type: 1, + name: enc('forged'), + userId: USER_B, + }); + + assert.equal(forged.status, 200); + assert.equal( + cipherRow(h.connection, String(forged.body.id))?.user_id, + USER_A, + '客户端传入的 userId 必须被覆盖,否则可写入他人数据' + ); + + h.handle.close(); +}); + +test('读取:所有者能读到,非所有者 404', async () => { + const h = createHarness(); + const id = await seedCipher(h); + + const owner = await handleGetCipher(jsonRequest(null, 'GET'), h.env, USER_A, id); + assert.equal(owner.status, 200); + + const stranger = await handleGetCipher(jsonRequest(null, 'GET'), h.env, USER_B, id); + assert.equal(stranger.status, 404, '非所有者读取必须 404(而不是 200 或 500)'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 跨用户隔离 + +test('跨用户写入必须 404,且目标数据分毫不变', async () => { + const h = createHarness(); + const id = await seedCipher(h); + const before = { ...cipherRow(h.connection, id)! }; + + const attempt = await callUpdate(h.env, USER_B, id, { type: 1, name: enc('hijacked') }); + assert.equal(attempt.status, 404, '非所有者更新必须 404'); + + assert.deepStrictEqual({ ...cipherRow(h.connection, id)! }, before, '被拒绝的写入不得改动任何数据'); + h.handle.close(); +}); + +test('跨用户删除必须 404,且数据仍在', async () => { + const h = createHarness(); + const id = await seedCipher(h); + + const attempt = await handleDeleteCipherCompat(jsonRequest(null, 'DELETE'), h.env, USER_B, id); + assert.equal(attempt.status, 404, '非所有者删除必须 404'); + + const row = cipherRow(h.connection, id); + assert.ok(row, '数据不应被删除'); + assert.equal(row.deleted_at, null, '也不应被软删除'); + + h.handle.close(); +}); + +test('跨用户恢复必须 404,且数据仍是已删除状态', async () => { + const h = createHarness(); + const id = await seedCipher(h); + await handleDeleteCipherCompat(jsonRequest(null, 'DELETE'), h.env, USER_A, id); + + const attempt = await handleRestoreCipher(jsonRequest(null, 'PUT'), h.env, USER_B, id); + assert.equal(attempt.status, 404, '非所有者恢复必须 404'); + assert.ok(cipherRow(h.connection, id)?.deleted_at, '数据应仍是已删除状态'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 陈旧写入 + +test('陈旧的 lastKnownRevisionDate 必须被拒绝,且数据不变', async () => { + const h = createHarness(); + const id = await seedCipher(h); + const before = { ...cipherRow(h.connection, id)! }; + + const stale = await callUpdate(h.env, USER_A, id, { + type: 1, + name: enc('overwritten-by-stale-client'), + lastKnownRevisionDate: STALE_REVISION, + }); + assert.equal(stale.status, 400, `陈旧写入应被拒绝,实际 ${stale.status}`); + assert.match(String(stale.body.error), /out of date/i, '应提示客户端重新同步'); + + assert.deepStrictEqual({ ...cipherRow(h.connection, id)! }, before, '被拒绝的陈旧写入不得改动数据'); + h.handle.close(); +}); + +test('revisionDate 足够新时应被接受', async () => { + const h = createHarness(); + const id = await seedCipher(h); + const current = String(cipherRow(h.connection, id)?.updated_at); + + const ok = await callUpdate(h.env, USER_A, id, { + type: 1, + name: enc('updated-name'), + lastKnownRevisionDate: current, + }); + assert.equal(ok.status, 200, `revisionDate 足够新时应接受,实际 ${ok.status}:${JSON.stringify(ok.body)}`); + + h.handle.close(); +}); + +test('不传 revisionDate 时不做陈旧判定(客户端可省略该字段)', async () => { + const h = createHarness(); + const id = await seedCipher(h); + + const ok = await callUpdate(h.env, USER_A, id, { type: 1, name: enc('without-revision') }); + assert.equal(ok.status, 200, '省略 lastKnownRevisionDate 不应被当作陈旧'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 未知字段与字段语义 + +test('更新时同时保留「既有未知字段」与「本次新增未知字段」', async () => { + const h = createHarness(); + const id = await seedCipher(h, { originalUnknown: 'from-create' }); + + const updated = await callUpdate(h.env, USER_A, id, { + type: 1, + name: enc('updated-name'), + newlyAddedUnknown: 'from-update', + }); + assert.equal(updated.status, 200); + + const data = cipherData(h.connection, id); + assert.equal(data.originalUnknown, 'from-create', '既有未知字段必须保留'); + assert.equal(data.newlyAddedUnknown, 'from-update', '本次新增的未知字段也必须保留'); + + h.handle.close(); +}); + +test('全量更新中省略 notes 表示清空(replacement 语义,而非"保持原值")', async () => { + const h = createHarness(); + const id = await seedCipher(h); // seedCipher 带了 notes + assert.ok(cipherRow(h.connection, id)?.notes, '前置条件:创建时应写入 notes'); + + const updated = await callUpdate(h.env, USER_A, id, { type: 1, name: enc('no-notes') }); + assert.equal(updated.status, 200); + + assert.equal( + cipherRow(h.connection, id)?.notes, + null, + '该端点对可空字段使用替换语义:客户端省略即视为清空(否则"清空备注"永远无法生效)' + ); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 软删除与恢复 + +test('软删除与恢复:只改 deleted_at,不物理删除', async () => { + const h = createHarness(); + const id = await seedCipher(h); + + const deleted = await handleDeleteCipherCompat(jsonRequest(null, 'DELETE'), h.env, USER_A, id); + assert.equal(deleted.status, 200); + assert.ok(cipherRow(h.connection, id)?.deleted_at, '软删除应写入 deleted_at'); + assert.ok(cipherRow(h.connection, id), '不应物理删除行'); + + const restored = await handleRestoreCipher(jsonRequest(null, 'PUT'), h.env, USER_A, id); + assert.equal(restored.status, 200); + assert.equal(cipherRow(h.connection, id)?.deleted_at, null, '恢复应清空 deleted_at'); + + h.handle.close(); +}); diff --git a/scripts/config-compatibility.test.ts b/scripts/config-compatibility.test.ts index 571192dfa..e5ef1b22d 100644 --- a/scripts/config-compatibility.test.ts +++ b/scripts/config-compatibility.test.ts @@ -10,3 +10,13 @@ test('config enables the official Bitwarden desktop settings dialog', () => { assert.equal(body.environment.vault, 'https://vault.example.test'); assert.equal(body.object, 'config'); }); + +test('config 不得宣称支持邮箱验证(本服务器没有邮件发送通道)', () => { + const body = buildConfigResponse('https://vault.example.test'); + + // 邮件相关端点(/accounts/register/send-verification-email、/accounts/verify-email、 + // /api/two-factor/send-email-login)一律返回 501。若这里报 true, + // 客户端会展示相应的设置项并调用注定失败的接口。 + assert.equal(body.featureStates['email-verification'], false); + assert.equal(body.featureStates['pm-19051-send-email-verification'], false); +}); diff --git a/scripts/ensure-kv.cjs b/scripts/ensure-kv.cjs index af0bdb936..0a95acc52 100644 --- a/scripts/ensure-kv.cjs +++ b/scripts/ensure-kv.cjs @@ -6,20 +6,41 @@ * by name. The template ships without an id so fresh accounts can provision one * on first deploy. In non-interactive builds, wrangler may try to create the * same namespace again on later builds and fail with code 10014. + * + * 加固(2026-09-18 代码审计):本脚本会**改写受版本控制的 `wrangler.kv.toml`**,而写进去的 + * id 决定「附件写进哪个 KV 库」。因此: + * 1. 只复用**标题完全一致**的命名空间;发现"标题相近"的会**停下来报错并列出候选**, + * 而不是猜一个 —— 猜错会让附件静默写进别的库。 + * 2. 显式指定:`--id <32 位 hex>` 复用指定命名空间,`--force-new` 确认要新建。 + * 3. 回写后**校验** id 确实进了目标段,否则报错退出 —— 原来的实现遇到格式不匹配会 + * "打印成功但其实没写",下一次构建又会去新建(正是本脚本要防的 10014)。 + * 4. 纯函数在文件末尾导出,`main()` 只在作为主模块运行时执行 + * (便于单测,见 `scripts/ensure-kv.test.ts`)。 */ -const { execSync } = require('node:child_process'); +const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml'); const BINDING = 'ATTACHMENTS_KV'; +const NAMESPACE_ID_RE = /^[0-9a-fA-F]{32}$/; + +// Windows 下 npm 的可执行文件带 .cmd 后缀,execFileSync 不经 shell 解析。 +const NPX = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +// 用 execFileSync + 参数数组,不再把参数拼进 shell 字符串 —— title 源自 +// wrangler.kv.toml 的 name,虽属本地配置,但没有理由让它经过 shell 解释。 const wrangler = (args) => - execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); + execFileSync(NPX, ['wrangler', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); -function bindingBlockHasId(toml) { +/** 取出 `[[kv_namespaces]]` 里属于该 binding 的那一段(找不到返回 null)。 */ +function bindingBlock(toml, binding = BINDING) { const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || []; - const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry)); + return blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${binding}"`).test(entry)) || null; +} + +function bindingBlockHasId(toml, binding = BINDING) { + const block = bindingBlock(toml, binding); return block ? /^\s*id\s*=/m.test(block) : false; } @@ -28,37 +49,138 @@ function expectedTitle(toml) { return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`; } -function resolveId(title) { - const list = JSON.parse(wrangler('kv namespace list')); - const hit = - list.find((namespace) => namespace.title === title) || - list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv')); - if (hit) { - console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`); - return hit.id; - } +/** + * 「标题相近」只用于**报警**,不再用于自动复用。 + * 之所以还要它:若将来把 Worker 改了名,推导出的标题就不再等于旧命名空间的标题 —— + * 这时静默新建会让已有附件“凭空消失”,所以要先停下来让人确认。 + */ +function findSimilarNamespaces(namespaces, title) { + const suffix = `-${BINDING.toLowerCase().replace(/_/g, '-')}`; + return (Array.isArray(namespaces) ? namespaces : []).filter( + (namespace) => + typeof namespace?.title === 'string' + && namespace.title !== title + && namespace.title.endsWith(suffix) + ); +} - const out = wrangler(`kv namespace create "${title}"`); - const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1]; - if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`); - console.log(`[ensure-kv] created namespace "${title}" (${id})`); +function parseCreatedNamespaceId(output) { + const id = (String(output || '').match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1]; + if (!id) { + throw new Error(`[ensure-kv] could not parse new namespace id from:\n${output}`); + } return id; } -function main() { - let toml = fs.readFileSync(CONFIG, 'utf8'); +/** + * 把 id 插进属于本 binding 的那一段。 + * 段缺失 / 格式不匹配 / 已有 id 都**抛错** —— 绝不“静默成功”(否则下次构建又会新建)。 + */ +function insertIdIntoBindingBlock(toml, id, binding = BINDING) { + if (!NAMESPACE_ID_RE.test(String(id || ''))) { + throw new Error(`[ensure-kv] invalid namespace id: ${id}`); + } + if (bindingBlockHasId(toml, binding)) { + throw new Error(`[ensure-kv] binding = "${binding}" already has an id`); + } + const next = toml.replace( + new RegExp(`(\\[\\[kv_namespaces\\]\\][^[]*?binding\\s*=\\s*"${binding}")`), + `$1\nid = "${id}"` + ); + if (next === toml || !bindingBlockHasId(next, binding)) { + throw new Error( + `[ensure-kv] 未能在 wrangler.kv.toml 里为 binding = "${binding}" 写入 id(段缺失或格式不匹配)` + ); + } + return next; +} + +function parseArgs(argv) { + const args = { id: null, forceNew: false, help: false }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--help' || token === '-h') { + args.help = true; + } else if (token === '--force-new') { + args.forceNew = true; + } else if (token === '--id') { + args.id = String(argv[index + 1] || '').trim(); + index += 1; + } else if (token.startsWith('--id=')) { + args.id = token.slice('--id='.length).trim(); + } else { + throw new Error(`[ensure-kv] unknown argument: ${token}`); + } + } + if (args.id && !NAMESPACE_ID_RE.test(args.id)) { + throw new Error(`[ensure-kv] --id must be a 32-character hex KV namespace id, got: ${args.id}`); + } + return args; +} + +const USAGE = [ + '用法:node scripts/ensure-kv.cjs [--id <32 位 hex>] [--force-new]', + ' (无参数) 标题完全匹配则复用,否则新建', + ' --id 显式复用指定命名空间(标题不一致、或账号里存在相近标题时使用)', + ' --force-new 确认要新建(存在“标题相近”的命名空间时会要求显式选择)', +].join('\n'); + +function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + if (args.help) { + console.log(USAGE); + return; + } + + const toml = fs.readFileSync(CONFIG, 'utf8'); if (bindingBlockHasId(toml)) { console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`); return; } - const id = resolveId(expectedTitle(toml)); - toml = toml.replace( - new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`), - `$1\nid = "${id}"` - ); - fs.writeFileSync(CONFIG, toml); - console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build'); + const title = expectedTitle(toml); + let id = args.id; + + if (id) { + console.log(`[ensure-kv] 使用显式指定的命名空间 id(${id})`); + } else { + const namespaces = JSON.parse(wrangler(['kv', 'namespace', 'list'])); + const exact = (Array.isArray(namespaces) ? namespaces : []).find((ns) => ns?.title === title); + if (exact) { + id = exact.id; + console.log(`[ensure-kv] reusing existing namespace "${exact.title}" (${exact.id})`); + } else { + const similar = findSimilarNamespaces(namespaces, title); + if (similar.length && !args.forceNew) { + throw new Error([ + `[ensure-kv] 账号里没有标题为 "${title}" 的 KV 命名空间,但存在标题相近的:`, + ...similar.map((ns) => ` · ${ns.title} (${ns.id})`), + '请显式二选一后重跑(不替你猜 —— 猜错会把附件写进别的库):', + ' · 复用其中一个: node scripts/ensure-kv.cjs --id <上面的 id>', + ' · 确实要新建: node scripts/ensure-kv.cjs --force-new', + ].join('\n')); + } + id = parseCreatedNamespaceId(wrangler(['kv', 'namespace', 'create', title])); + console.log(`[ensure-kv] created namespace "${title}" (${id})`); + } + } + + fs.writeFileSync(CONFIG, insertIdIntoBindingBlock(toml, id)); + console.log(`[ensure-kv] 已写入 wrangler.kv.toml:binding = "${BINDING}" 段新增 id = "${id}"`); + console.log('[ensure-kv] wrangler.kv.toml 受版本控制,记得把这次改动一并提交'); +} + +if (require.main === module) { + main(); } -main(); +module.exports = { + BINDING, + bindingBlock, + bindingBlockHasId, + expectedTitle, + findSimilarNamespaces, + insertIdIntoBindingBlock, + parseArgs, + parseCreatedNamespaceId, +}; diff --git a/scripts/ensure-kv.test.ts b/scripts/ensure-kv.test.ts new file mode 100644 index 000000000..85743c6e1 --- /dev/null +++ b/scripts/ensure-kv.test.ts @@ -0,0 +1,141 @@ +// `scripts/ensure-kv.cjs` 的加固守卫 +// +// 为什么值得测:这个脚本**会改写受版本控制的 `wrangler.kv.toml`**,而写进去的 id 决定 +// 「附件写进哪个 KV 库」。它有三种**静默失败**: +// ① 猜错命名空间(原来会退化到 `endsWith('attachments-kv')` 模糊匹配)⇒ 附件写进别的库; +// ② 「打印成功但其实没写」(插入正则不匹配时写回原文)⇒ 下次构建又去新建,正是它要防的 10014; +// ③ 把 id 插进**别的段**(原来要求 `binding` 行必须紧跟段头)。 +// 所以下面的断言重点不是"能跑通",而是"猜错/写错/插错时会不会响"。 +// +// 运行方式:npm run test:ensure-kv +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; + +const require_ = createRequire(import.meta.url); +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); + +interface EnsureKvModule { + BINDING: string; + bindingBlock: (toml: string, binding?: string) => string | null; + bindingBlockHasId: (toml: string, binding?: string) => boolean; + expectedTitle: (toml: string) => string; + findSimilarNamespaces: (namespaces: unknown, title: string) => Array<{ title: string; id: string }>; + insertIdIntoBindingBlock: (toml: string, id: string, binding?: string) => string; + parseArgs: (argv: string[]) => { id: string | null; forceNew: boolean; help: boolean }; + parseCreatedNamespaceId: (output: string) => string; +} + +const ensureKv = require_(path.join(REPO_ROOT, 'scripts/ensure-kv.cjs')) as EnsureKvModule; + +const ID = '0123456789abcdef0123456789abcdef'; +const TOML_WITHOUT_ID = [ + 'name = "nodewarden"', + '', + '[[d1_databases]]', + 'binding = "DB"', + 'database_name = "nodewarden-db"', + '', + '[[kv_namespaces]]', + 'binding = "ATTACHMENTS_KV"', + '', + '[[durable_objects.bindings]]', + 'name = "NOTIFICATIONS_HUB"', + '', +].join('\n'); + +// ---------------------------------------------------------------- 段落识别 + +test('bindingBlockHasId:段内没有 id 时返回 false', () => { + assert.equal(ensureKv.bindingBlockHasId(TOML_WITHOUT_ID), false); +}); + +test('bindingBlockHasId:段内有 id 时为 true;同一个 binding 名在别的段不算', () => { + const withId = TOML_WITHOUT_ID.replace('binding = "ATTACHMENTS_KV"', `binding = "ATTACHMENTS_KV"\nid = "${ID}"`); + assert.equal(ensureKv.bindingBlockHasId(withId), true); + // `DB` 这个 binding 只出现在 [[d1_databases]] 里 —— 不该被当成 KV 段 + assert.equal(ensureKv.bindingBlockHasId(TOML_WITHOUT_ID, 'DB'), false); + assert.equal(ensureKv.bindingBlock(TOML_WITHOUT_ID, 'DB'), null); +}); + +test('expectedTitle:从配置的 name 推导(这是"标题完全一致"判定的唯一依据)', () => { + assert.equal(ensureKv.expectedTitle(TOML_WITHOUT_ID), 'nodewarden-attachments-kv'); + assert.equal(ensureKv.expectedTitle('[x]\nfoo = 1\n'), 'worker-attachments-kv'); +}); + +test('真实 wrangler.kv.toml:标题契约不变(改名会让复用判定失效,需显式 --id)', () => { + const toml = readFileSync(path.join(REPO_ROOT, 'wrangler.kv.toml'), 'utf8'); + assert.equal(ensureKv.expectedTitle(toml), 'nodewarden-attachments-kv'); +}); + +// ---------------------------------------------------------------- 不替你猜 + +test('findSimilarNamespaces:只挑"结尾相同且不等于期望标题"的候选,非数组/非字符串都容忍', () => { + const namespaces = [ + { title: 'nodewarden-attachments-kv', id: 'a'.repeat(32) }, // 精确匹配 ⇒ 不算"相近" + { title: 'other-project-attachments-kv', id: 'b'.repeat(32) }, // 相近 ⇒ 要报警 + { title: 'nodewarden-attachments', id: 'c'.repeat(32) }, // 结尾不同 ⇒ 不算 + { title: null, id: 'd'.repeat(32) }, + { id: 'e'.repeat(32) }, + ]; + const similar = ensureKv.findSimilarNamespaces(namespaces, 'nodewarden-attachments-kv'); + assert.deepEqual(similar.map((item) => item.title), ['other-project-attachments-kv']); + assert.deepEqual(ensureKv.findSimilarNamespaces(null, 'nodewarden-attachments-kv'), []); +}); + +test('parseCreatedNamespaceId:解析 wrangler 的输出;解析不到必须抛错(不能静默返回 undefined)', () => { + const output = `🌀 Creating new KV Namespace "nodewarden-attachments-kv"...\n\n[[kv_namespaces]]\nbinding = "ATTACHMENTS_KV"\nid = "${ID}"\n`; + assert.equal(ensureKv.parseCreatedNamespaceId(output), ID); + assert.throws(() => ensureKv.parseCreatedNamespaceId('some unrelated output'), /could not parse/); + assert.throws(() => ensureKv.parseCreatedNamespaceId(''), /could not parse/); +}); + +// ---------------------------------------------------------------- 写入正确性 + +test('insertIdIntoBindingBlock:id 必须落在 KV 段内,不能掉进下一段', () => { + const next = ensureKv.insertIdIntoBindingBlock(TOML_WITHOUT_ID, ID); + const kvIndex = next.indexOf('binding = "ATTACHMENTS_KV"'); + const idIndex = next.indexOf(`id = "${ID}"`); + const nextHeaderIndex = next.indexOf('[[', kvIndex + 1); + assert.ok(kvIndex >= 0 && idIndex > kvIndex, 'id 应写在 KV 段的 binding 行之后'); + assert.ok( + nextHeaderIndex === -1 || idIndex < nextHeaderIndex, + 'id 必须留在 [[kv_namespaces]] 段内 —— 插到别的段会让部署绑定错乱' + ); + assert.equal(ensureKv.bindingBlockHasId(next), true); +}); + +test('insertIdIntoBindingBlock:段缺失 / 已有 id / 非法 id 都要抛错(原来会"打印成功但其实没写")', () => { + const missingBlock = 'name = "nodewarden"\n\n[[d1_databases]]\nbinding = "DB"\n'; + assert.throws(() => ensureKv.insertIdIntoBindingBlock(missingBlock, ID), /未能.*写入 id/); + + const withId = ensureKv.insertIdIntoBindingBlock(TOML_WITHOUT_ID, ID); + assert.throws(() => ensureKv.insertIdIntoBindingBlock(withId, ID), /already has an id/); + + assert.throws(() => ensureKv.insertIdIntoBindingBlock(TOML_WITHOUT_ID, 'not-an-id'), /invalid namespace id/); +}); + +// ---------------------------------------------------------------- 参数 + +test('parseArgs:--id / --id= / --force-new / --help;未知参数与非法 id 都报错', () => { + assert.deepEqual(ensureKv.parseArgs([]), { id: null, forceNew: false, help: false }); + assert.deepEqual(ensureKv.parseArgs(['--id', ID]), { id: ID, forceNew: false, help: false }); + assert.deepEqual(ensureKv.parseArgs([`--id=${ID}`]), { id: ID, forceNew: false, help: false }); + assert.deepEqual(ensureKv.parseArgs(['--force-new']), { id: null, forceNew: true, help: false }); + assert.deepEqual(ensureKv.parseArgs(['--help']), { id: null, forceNew: false, help: true }); + assert.throws(() => ensureKv.parseArgs(['--nope']), /unknown argument/); + assert.throws(() => ensureKv.parseArgs(['--id', 'short']), /32-character hex/); +}); + +// ---------------------------------------------------------------- 源码护栏 + +test('源码护栏:main() 只在作为主模块运行时执行(否则单测一 import 就会去读配置/调 wrangler)', () => { + const source = readFileSync(path.join(REPO_ROOT, 'scripts/ensure-kv.cjs'), 'utf8'); + assert.ok( + source.includes('if (require.main === module)'), + '必须有入口守卫:本文件的用例能 import 而不产生任何副作用,靠的就是它' + ); + assert.ok(!/^main\(\);\s*$/m.test(source), '不得在顶层直接调用 main()'); +}); diff --git a/scripts/error-message-guard.test.ts b/scripts/error-message-guard.test.ts new file mode 100644 index 000000000..34af9fd05 --- /dev/null +++ b/scripts/error-message-guard.test.ts @@ -0,0 +1,687 @@ +// 「把异常原文回给客户端」的源码护栏 +// +// 背景(docs/TODO.md 第 4 条):后端大量使用 +// catch (error) { return errorResponse(error.message, 500); } +// 这个模式。**今天**是安全的 —— 逐条审计下来,这些消息要么是刻意给用户看的业务文案 +// (正是前端 i18n 映射表覆盖的那些),要么只插值 HTTP 状态码 / 存档内的业务标识, +// 没有主机名、路径或堆栈。但它**默认不安全**:将来只要有人在 catch 里包一层低层调用 +// (D1 / WebCrypto / fetch),error.message 就可能变成 +// `D1_ERROR: no such table: users`、`TypeError: fetch failed`(含 host:port) +// 然后直接回给客户端,而**没有任何测试会红**。 +// +// 本文件把这条不变量钉住:**消息实参要么是可静态证明"固定文本"的形状,要么必须显式登记**。 +// +// 算安全的形状(都是静态可证,不是命名约定): +// ① 字符串字面量; +// ② 模板里只有数学运算 —— `${Math.floor(x / 1024)}` 这类,Math.* 的返回值一定是 number; +// ③ 三元的两个分支都是字面量 —— 条件只是判断,不会回给客户端; +// ④ 同一个文件里被声明为 `const NAME = '<字面量>'` 的常量(判定的是**声明**)。 +// 其余一切(error.message、变量、函数调用、拼接、未核实常量)⇒ 必须登记,并写明理由。 +// 新增一处动态来源 ⇒ 强制一次人工判断;登记项失效或数量对不上 ⇒ 测试红, +// 避免白名单腐烂成"什么都放行"。 +// 零运行时改动 —— 纯静态扫描。 +// +// 运行方式:npm run test:error-message-guard +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const SRC_ROOT = path.join(REPO_ROOT, 'src'); + +/** + * 会把**第一个实参**原样写进 JSON 响应体(error / error_description / ErrorModel.Message) + * 的助手函数。identityErrorResponse 同样写这三个字段,所以一并纳入。 + */ +const RESPONSE_HELPERS = ['errorResponse', 'unsupportedResponse', 'identityErrorResponse', 'badRequest']; + +/** 只有数学运算能出现在模板插值里 —— `${Math.max(a, b)}` 这类是数字,不是文本 */ +const NUMERIC_BUILTINS = new Set(['Math', 'Number', 'Infinity', 'NaN']); + +/** + * 取出 `callee(第一个实参)` 的原文。 + * + * 为什么不用正则一把梭:实参里可能嵌套括号、逗号、对象字面量与带引号的字符串 + * (例如 `errorResponse(\`Maximum size is ${MB}MB\`, 413)`)。所以这里做一个小扫描器: + * 跟踪括号深度、跳过字符串与转义,在深度 0 处的逗号或配对的 `)` 停下。 + */ +function findMessageArguments(source: string, callees: string[]): Array<{ line: number; callee: string; argument: string }> { + const found: Array<{ line: number; callee: string; argument: string }> = []; + const lineAt = (index: number) => source.slice(0, index).split('\n').length; + + for (const callee of callees) { + const pattern = new RegExp(`(^|[^\\w$.])${callee}\\s*\\(`, 'g'); + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + // 跳过函数**声明**:`function badRequest(message: string, …)` 里的 `(` 不是调用。 + // 不跳的话会多出一条 "message" 假阳性,还会逼着登记表去登记一个并不存在的调用点。 + const nameStart = match.index + match[1].length; + if (/function\s+$/.test(source.slice(Math.max(0, nameStart - 16), nameStart))) continue; + const openIndex = match.index + match[0].length - 1; + let depth = 0; + let quote: string | null = null; + let argument = ''; + for (let i = openIndex; i < source.length; i += 1) { + const char = source[i]; + if (quote) { + argument += char; + if (char === '\\') { + argument += source[i + 1] ?? ''; + i += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + argument += char; + continue; + } + if (char === '(' || char === '[' || char === '{') { + depth += 1; + if (depth > 1) argument += char; + continue; + } + if (char === ')' || char === ']' || char === '}') { + depth -= 1; + if (depth === 0) break; + argument += char; + continue; + } + if (char === ',' && depth === 1) break; + if (depth >= 1) argument += char; + } + found.push({ line: lineAt(openIndex), callee, argument: argument.trim() }); + } + } + + return found.sort((a, b) => a.line - b.line); +} + +/** 字符串字面量(不含插值):这段文本在源码里就是固定的 */ +function isMessageLiteral(argument: string): boolean { + const trimmed = argument.trim(); + if (/^'(?:[^'\\]|\\.)*'$/s.test(trimmed)) return true; + if (/^"(?:[^"\\]|\\.)*"$/s.test(trimmed)) return true; + if (/^`(?:[^`\\$]|\\.)*`$/s.test(trimmed)) return true; + return false; +} + +/** 同一个文件里 `const NAME = '<字面量>'` 形式的模块级常量(值本身就是固定文案) */ +function declaredLiteralConstants(source: string): Set { + const names = new Set(); + const pattern = /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*(?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\s*;/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) names.add(match[1]); + return names; +} + +/** + * 从 import 的目标模块里核实跨文件常量:`import { A } from './x'` ⇒ 去 x.ts 里找 + * `const A = '<字面量>'`。解析不到目标模块 / 目标模块里不是字面量(或同一个名字被 + * 重新赋过值)⇒ 不算安全,宁可让它落到登记表里。 + */ +function importedLiteralConstants(source: string, filePath: string): Set { + const names = new Set(); + if (!filePath) return names; + const pattern = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"](\.[^'"]+)['"]/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + const specifier = match[2].endsWith('.ts') ? match[2] : `${match[2]}.ts`; + let targetSource: string; + try { + targetSource = readFileSync(path.resolve(path.dirname(filePath), specifier), 'utf8'); + } catch { + continue; + } + const targetConstants = declaredLiteralConstants(targetSource); + for (const imported of match[1].split(',')) { + const [exported, local = exported] = imported.trim().split(/\s+as\s+/); + if (exported && targetConstants.has(exported.trim())) names.add(local.trim()); + } + } + return names; +} + +/** + * 把 `Math.floor(…)` / `Number.isInteger(…)` 这类调用整段替换成 `0`。 + * 依据:Math.* 与 Number.* 的返回值一定是 number,拼不出表名或路径。 + */ +function stripNumericCalls(expression: string): string { + let result = expression; + for (;;) { + const match = /\b(?:Math|Number)\.\w+\s*\(/.exec(result); + if (!match) return result; + let depth = 0; + let end = -1; + let quote: string | null = null; + for (let i = match.index + match[0].length - 1; i < result.length; i += 1) { + const char = result[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '(') depth += 1; + else if (char === ')') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) return result; + result = `${result.slice(0, match.index)}0${result.slice(end + 1)}`; + } +} + +/** `${…}` 里只允许数字运算:出现字符串,或出现未经核实的变量,就算可能带出文本 */ +function isNumericInterpolation(expression: string, constants: Set): boolean { + if (/['"`]/.test(expression)) return false; + // 属性名不是变量(`LIMITS.device.max` ⇒ 只留 `LIMITS`),再摘掉数学调用 + const rest = stripNumericCalls(expression).replace(/\.[A-Za-z_$][\w$]*/g, ''); + const identifiers = rest.match(/[A-Za-z_$][\w$]*/g) ?? []; + return identifiers.every((name) => NUMERIC_BUILTINS.has(name) || /^[a-z]$/.test(name) || constants.has(name)); +} + +/** 取出模板字符串里的 `${…}` 表达式;不是完整模板时返回 null */ +function templateInterpolations(argument: string): string[] | null { + if (!argument.startsWith('`') || !argument.endsWith('`')) return null; + const interpolations: string[] = []; + for (let i = 1; i < argument.length - 1; i += 1) { + const char = argument[i]; + if (char === '\\') { + i += 1; + continue; + } + if (char !== '$' || argument[i + 1] !== '{') continue; + let depth = 0; + let quote: string | null = null; + let end = -1; + for (let j = i + 1; j < argument.length; j += 1) { + const inner = argument[j]; + if (quote) { + if (inner === '\\') j += 1; + else if (inner === quote) quote = null; + continue; + } + if (inner === "'" || inner === '"' || inner === '`') { + quote = inner; + continue; + } + if (inner === '{') depth += 1; + else if (inner === '}') { + depth -= 1; + if (depth === 0) { + end = j; + break; + } + } + } + if (end === -1) return null; + interpolations.push(argument.slice(i + 2, end)); + i = end; + } + return interpolations; +} + +/** 找顶层三元表达式的第一个 `?`(跳过 `??` 与 `?.`) */ +function topLevelIndexOfTernary(argument: string): number { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < argument.length; i += 1) { + const char = argument[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '(' || char === '[' || char === '{') depth += 1; + else if (char === ')' || char === ']' || char === '}') depth -= 1; + else if (char === '?' && depth === 0 && argument[i + 1] !== '?' && argument[i + 1] !== '.') return i; + } + return -1; +} + +/** 把 `cond ? a : b` 拆成两个分支(嵌套三元按层级配对方括号外的冒号) */ +function splitTopLevelTernary(argument: string): { whenTrue: string; whenFalse: string } | null { + const ternaryIndex = topLevelIndexOfTernary(argument); + if (ternaryIndex === -1) return null; + let depth = 0; + let nested = 0; + let quote: string | null = null; + for (let i = ternaryIndex + 1; i < argument.length; i += 1) { + const char = argument[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '(' || char === '[' || char === '{') { + depth += 1; + continue; + } + if (char === ')' || char === ']' || char === '}') { + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === '?' && argument[i + 1] !== '?' && argument[i + 1] !== '.') { + nested += 1; + continue; + } + if (char !== ':') continue; + if (nested === 0) { + return { whenTrue: argument.slice(ternaryIndex + 1, i).trim(), whenFalse: argument.slice(i + 1).trim() }; + } + nested -= 1; + } + return null; +} + +/** 三元的两个分支都是字面量(允许继续嵌套三元)—— 条件本身不会回给客户端 */ +function isLiteralTernary(argument: string): boolean { + const split = splitTopLevelTernary(argument.trim()); + if (!split) return false; + const isLiteralBranch = (branch: string) => isMessageLiteral(branch) || isLiteralTernary(branch); + return isLiteralBranch(split.whenTrue) && isLiteralBranch(split.whenFalse); +} + +type MessageVerdict = { safe: true; reason: string } | { safe: false }; + +/** + * 判定某个消息实参是否算"可静态证明的固定文本"。 + * 只有开头的四种形状算安全,其余一律落到登记表里,强制一次人工判断。 + */ +function classifyMessageArgument(argument: string, source: string, filePath = ''): MessageVerdict { + const trimmed = argument.trim(); + if (isMessageLiteral(trimmed)) return { safe: true, reason: '字面量' }; + const constants = new Set([...declaredLiteralConstants(source), ...importedLiteralConstants(source, filePath)]); + if (trimmed.startsWith('`')) { + const interpolations = templateInterpolations(trimmed); + if (interpolations && interpolations.every((expr) => isNumericInterpolation(expr, constants))) { + return { safe: true, reason: '模板插值只含数学运算' }; + } + return { safe: false }; + } + if (isLiteralTernary(trimmed)) return { safe: true, reason: '三元分支均为字面量' }; + if (/^[A-Za-z_$][\w$]*$/.test(trimmed) && constants.has(trimmed)) { + return { safe: true, reason: `常量 ${trimmed} 已核实为字符串字面量(同文件或 import 目标模块里)` }; + } + return { safe: false }; +} + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) + .map((entry) => path.join(entry.parentPath ?? dir, entry.name)) + .filter((file) => !file.endsWith('.test.ts')); +} + +// ---------------------------------------------------------------- 扫描器自检 +// 护栏自身的正确性必须先被钉住:解析器一旦写错(例如把嵌套括号吃掉、把模板字符串 +// 误判成字面量),下面的白名单断言就会**静默放行**,护栏等于不存在。 +test('扫描器自检:字面量 / 模板插值 / 变量 / 嵌套括号与转义', () => { + const fixture = [ + "errorResponse('Simple message', 400);", + 'errorResponse("Double quoted", 400);', + 'errorResponse(`No interpolation here`, 400);', + 'errorResponse(`File too large. Maximum size is ${mb}MB`, 413);', + 'errorResponse(message, 500);', + 'errorResponse(buildMessage(a, b), 500);', + "errorResponse('Has a ) paren and , comma', 400);", + "errorResponse(`With ${nested({ a: 1, b: 'x,y' })} inside`, 400);", + 'badRequest(error instanceof Error ? error.message : "x", 500);', + 'notAHelper(`should be ignored`, 400);', + ].join('\n'); + + const sites = findMessageArguments(fixture, RESPONSE_HELPERS); + assert.deepEqual( + sites.map((site) => site.argument), + [ + "'Simple message'", + '"Double quoted"', + '`No interpolation here`', + '`File too large. Maximum size is ${mb}MB`', + 'message', + 'buildMessage(a, b)', + "'Has a ) paren and , comma'", + '`With ${nested({ a: 1, b: \'x,y\' })} inside`', + 'error instanceof Error ? error.message : "x"', + ], + '扫描器必须逐字取出第一个实参,且不把非同名前缀的调用算进来' + ); + assert.equal(classifyMessageArgument("'Simple message'", '').safe, true); + assert.equal(classifyMessageArgument('`No interpolation here`', '').safe, true); + assert.equal(classifyMessageArgument('message', '').safe, false); + assert.equal(classifyMessageArgument('error instanceof Error ? error.message : "x"', '').safe, false); +}); + +// ---------------------------------------------------------------- 判定器自检 +test('判定器自检:安全形状(数学插值 / 字面量三元 / 已核实常量)与动态来源的分界', () => { + const fixture = [ + 'const LIMITS = { device: { maxBulkIdentifiers: 200 } };', + "const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';", + "const KDF_MESSAGE =\n 'PBKDF2 iterations must be at least 100000';", + 'const NOT_A_LITERAL = `boom ${error.message}`;', + ].join('\n'); + const verdict = (argument: string) => classifyMessageArgument(argument, fixture).safe; + + // 安全:插值只做数学运算或计数 + assert.equal(verdict('`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`'), true); + assert.equal(verdict('`Try again in ${Math.ceil((check.retryAfterSeconds || 60) / 60)} minutes`'), true); + assert.equal(verdict('`Cipher ${i + 1} is invalid`'), true); + assert.equal(verdict('`Maximum is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`'), true); + assert.equal(verdict('`Cipher ${i + 1}: ${compatibilityError}`'), false, '插值里的变量必须登记'); + assert.equal(verdict('`Attachment blob missing for ${blobName}`'), false); + assert.equal(verdict('`boom ${error.message}`'), false, '异常原文绝不允许'); + assert.equal(verdict('`max ${LIMITS.device.maxBulkIdentifiers}`'), false, '属性取值链无法证明是数字'); + assert.equal(verdict('`max ${MAX_BACKUP_ARCHIVE_BYTES}`'), false, '没被数学运算包裹的常量无法证明是数字'); + assert.equal(verdict('`${lines.join(", ")}`'), false, '插值里出现字符串字面量(可能是拼接出来的文本)'); + + // 安全:三元的两个分支都是字面量(条件是判断,不是文本) + assert.equal(verdict("unsafe === 'missing' ? 'JWT_SECRET is not set' : 'JWT_SECRET must be at least 32 characters'"), true); + assert.equal(verdict("initialized?.credentials\n ? 'already configured'\n : 'unable to initialize'"), true); + assert.equal(verdict("ok ? 'fine' : failureReason"), false, '分支里有变量必须登记'); + + // 安全:常量在**同一文件**里被声明为字符串字面量(判定声明,不是命名约定) + assert.equal(verdict('SEND_INACCESSIBLE_MSG'), true); + assert.equal(verdict('KDF_MESSAGE'), true, '跨行声明的字面量常量同样算'); + assert.equal(verdict('NOT_A_LITERAL'), false, '同名常量本身是模板拼出来的 ⇒ 不安全'); + assert.equal(verdict('SOME_OTHER_FILE_CONSTANT'), false, '别的文件里的常量拿不到声明 ⇒ 必须登记'); + + // 跨文件常量:只有去 import 指向的模块里核实到字面量声明才算安全 + const publicPath = path.join(SRC_ROOT, 'handlers/sends-public.ts'); + assert.equal( + classifyMessageArgument('SEND_INACCESSIBLE_MSG', readFileSync(publicPath, 'utf8'), publicPath).safe, + true, + 'sends-public.ts 从 sends-shared.ts import 的常量应当在目标模块里核实' + ); + assert.equal( + classifyMessageArgument( + 'SEND_INACCESSIBLE_MSG', + "import { SEND_INACCESSIBLE_MSG } from './not-a-real-module';", + publicPath + ).safe, + false, + '解析不到目标模块时 fail-closed' + ); + + // 不安全:拼接 / join / 序列化 + assert.equal(verdict("'prefix: ' + value"), false); + assert.equal(verdict('lines.join(", ")'), false); + assert.equal(verdict('JSON.stringify(payload)'), false); +}); + +// ---------------------------------------------------------------- 登记表 +interface RegisteredDynamicMessage { + /** `文件 :: 第一个实参原文`(空白压平成单空格) */ + site: string; + /** 期望出现的次数:少一处 / 多一处都会红,逼着人重看一遍 */ + count: number; + /** 为什么这段文本不可能带出内部信息 */ + reason: string; +} + +/** + * 允许的**动态**消息来源。 + * + * 每条都必须写清理由。新增条目 = 一次人工判断;条目失效或数量对不上 = 测试红, + * 防止登记表腐烂成"什么都放行"。 + */ +const REGISTERED_DYNAMIC_MESSAGES: RegisteredDynamicMessage[] = [ + // ── 管理端备份链路:刻意保留的可诊断性 ────────────────────────────── + // 远端地址是管理员自己填的 WebDAV / S3,失败原文(真机验收见到的 + // `WebDAV upload timed out after 30000 ms`)正是前端 i18n 映射表覆盖的业务文案, + // 也是排障的唯一线索;这些接口仅管理员可达,文案里不含堆栈与凭据。 + { + site: "src/durable/backup-transfer-runner.ts :: error instanceof Error ? error.message : 'Backup run failed'", + count: 1, + reason: 'DO 内备份任务失败原文,经管理端备份中心回显', + }, + { + site: "src/durable/backup-transfer-runner.ts :: error instanceof Error ? error.message : 'Scheduled backup failed'", + count: 1, + reason: '同上:定时备份失败原文(管理端可见)', + }, + { + site: "src/durable/backup-transfer-runner.ts :: error instanceof Error ? error.message : 'Remote backup restore failed'", + count: 1, + reason: '同上:远端恢复失败原文', + }, + { + site: "src/durable/backup-transfer-runner.ts :: error instanceof Error ? error.message : 'Backup transfer request failed'", + count: 1, + reason: '同上:备份传输请求失败原文', + }, + { + site: 'src/durable/backup-transfer-runner.ts :: `Attachment blob missing for ${blobName}`', + count: 1, + reason: 'blobName 是管理端备份流程自己传进来的 R2 对象名,不是异常原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup settings could not be loaded'", + count: 1, + reason: '管理端备份设置读取失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup settings are invalid'", + count: 1, + reason: '管理端备份设置校验失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup settings repair state could not be loaded'", + count: 1, + reason: '管理端备份修复状态读取失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup settings repair payload is invalid'", + count: 1, + reason: '管理端备份修复入参校验失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup run failed'", + count: 1, + reason: '管理端手动备份失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Remote backup listing failed'", + count: 1, + reason: '远端备份列表失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Remote backup download failed'", + count: 1, + reason: '远端备份下载失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Remote backup integrity inspection failed'", + count: 1, + reason: '远端备份完整性检查失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Remote backup delete failed'", + count: 1, + reason: '远端备份删除失败原文', + }, + { + site: "src/handlers/backup.ts :: error instanceof Error ? error.message : 'Backup attachment download failed'", + count: 1, + reason: '备份附件下载失败原文', + }, + { + site: 'src/handlers/backup.ts :: message', + count: 3, + reason: '1185 / 1254 / 1368 三处 recovery/import/export 的失败原文(同上,管理端)', + }, + // ── 传参式文案 / 校验函数返回值:逐条核实过只返回固定文案 ────────────── + { + site: 'src/utils/response.ts :: message', + count: 1, + reason: 'unsupportedResponse 的透传参数;所有调用点都传字面量(调用点同样被本护栏扫描)', + }, + { + site: 'src/handlers/accounts.ts :: kdfErr', + count: 1, + reason: 'validateKdfParams 的返回值,逐条核实只返回固定文案(KDF type must be … 等)', + }, + { + site: 'src/handlers/ciphers.ts :: compatibilityError', + count: 2, + reason: 'validateCipherEncryptedFieldsForCompatibility 的返回值,逐条核实只返回固定文案(字段名取自代码内固定列表)', + }, + { + site: 'src/handlers/import.ts :: `Cipher ${i + 1}: ${compatibilityError}`', + count: 1, + reason: '同上 + 第 i 个条目序号(数字)', + }, + { + site: 'src/handlers/sends-shared.ts :: sendPasswordLockMessage(retryAfterSeconds)', + count: 1, + reason: '文案内只插值 Math.ceil(retryAfterSeconds / 60)', + }, + { + site: 'src/utils/direct-upload.ts :: tooLargeMessage', + count: 3, + reason: '文案由调用方以字面量传入(attachments.ts / sends-private.ts 调用点均是字面量),函数内只透传', + }, + { + site: 'src/utils/direct-upload.ts :: missingBodyMessage', + count: 2, + reason: '同上(默认值本身就是字面量,真实文案来自调用方)', + }, + { + site: 'src/utils/direct-upload.ts :: contentLengthRequiredMessage', + count: 1, + reason: '同上(默认值本身就是字面量,真实文案来自调用方)', + }, + { + site: "src/utils/direct-upload.ts :: fileNameMismatchMessage || 'File name does not match.'", + count: 1, + reason: '同上(默认值本身就是字面量,真实文案来自调用方)', + }, + { + site: "src/utils/direct-upload.ts :: sizeMismatchMessage || 'File size does not match.'", + count: 2, + reason: '同上(默认值本身就是字面量,真实文案来自调用方)', + }, + // ── WebAuthn:异常原文来自本仓库自己的固定文案 ────────────────────── + { + site: "src/handlers/account-passkeys.ts :: error instanceof Error ? error.message : 'Passkey assertion failed'", + count: 1, + reason: '该文件里所有 throw new Error(…) 都是固定文案(Invalid passkey assertion response / Passkey assertion could not be verified 等)', + }, + { + site: 'src/handlers/account-passkeys.ts :: `Passkey setup failed while ${passkeySetupStageMessage(stage)}`', + count: 1, + reason: 'passkeySetupStageMessage 逐条核实只返回固定阶段文案(verifying master password 等)', + }, + // ── 数值上限:静态扫描证明不了"这是数字",所以登记下来 ───────────────── + { + site: 'src/handlers/devices.ts :: `Too many devices in one request (max ${LIMITS.device.maxBulkIdentifiers})`', + count: 2, + reason: 'LIMITS.device.maxBulkIdentifiers 是数值上限(src/config/limits.ts 里的 number)', + }, + { + site: 'src/handlers/import.ts :: `Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`', + count: 1, + reason: 'LIMITS.performance.importItemLimit 是数值上限(number)', + }, + // ── identity 端点:透传参数,唯一调用点传字面量 ─────────────────────── + { + site: 'src/handlers/accounts.ts :: message', + count: 1, + reason: "第 259–262 行 `const message = unsafe === 'missing' ? 'JWT_SECRET is not set' : 'JWT_SECRET must be at least 32 characters'`,两个分支都是字面量(未做局部变量解析,所以在这里登记)", + }, + { + site: 'src/handlers/identity.ts :: message', + count: 1, + reason: 'recordFailedLoginAndBuildResponse 的透传参数;唯一调用点传字面量(Username or password is incorrect. Try again)', + }, + { + site: 'src/handlers/identity.ts :: `Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`', + count: 1, + reason: 'sendAccessLimit.retryAfterSeconds 是限速服务算出来的秒数(number)', + }, + { + site: 'src/handlers/identity.ts :: `Rate limit exceeded. Try again in ${retryAfter} seconds.`', + count: 1, + reason: 'retryAfter 来自 Math.max(1, rejected.retryAfterSeconds || 1),是秒数(number)', + }, +]; + +/** `文件 :: 实参原文` 作为键(空白压平,避免换行影响匹配) */ +function dynamicMessageKey(relativeFile: string, argument: string): string { + return `${relativeFile} :: ${argument.replace(/\s+/g, ' ').trim()}`; +} + +test('护栏:响应助手的消息实参只能是字面量或已登记的动态来源', () => { + const found = new Map(); + + for (const file of collectSourceFiles(SRC_ROOT)) { + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + const source = readFileSync(file, 'utf8'); + for (const site of findMessageArguments(source, RESPONSE_HELPERS)) { + if (classifyMessageArgument(site.argument, source, file).safe) continue; + const key = dynamicMessageKey(relative, site.argument); + const locations = found.get(key) ?? []; + locations.push(`${relative}:${site.line} ${site.callee}(${site.argument}, …)`); + found.set(key, locations); + } + } + + const registered = new Map(); + for (const entry of REGISTERED_DYNAMIC_MESSAGES) { + registered.set(entry.site, (registered.get(entry.site) ?? 0) + entry.count); + } + + const unregistered: string[] = []; + for (const [key, locations] of found) { + const allowed = registered.get(key) ?? 0; + if (locations.length > allowed) unregistered.push(...locations.slice(allowed)); + } + unregistered.sort(); + assert.deepEqual( + unregistered, + [], + `发现未登记的动态错误消息(${unregistered.length} 处)。\n` + + '这些文本会被原样回给客户端(error / error_description / ErrorModel.Message 三个字段)。\n' + + '若它来自 catch 到的异常(error.message / String(error)),就可能泄露表名、路径或内网地址。\n' + + '处理方式:① 改成固定业务文案;② 或在本文件的 REGISTERED_DYNAMIC_MESSAGES 登记并写明理由。\n\n' + + unregistered.join('\n') + ); + + // 反方向:登记项失效、或数量对不上,都必须红 —— 否则它会默默放行未来的同名变量 + const stale: string[] = []; + for (const [key, count] of registered) { + const actual = found.get(key)?.length ?? 0; + if (actual !== count) stale.push(`${key}(登记 ${count} 处,实际 ${actual} 处)`); + } + stale.sort(); + assert.deepEqual(stale, [], `登记表与代码对不上,请更新 REGISTERED_DYNAMIC_MESSAGES:\n${stale.join('\n')}`); +}); + +test('护栏自检:扫描到足够多的调用点(API 改名/重构时这条会先红,而不是静默扫到 0 处)', () => { + let total = 0; + for (const file of collectSourceFiles(SRC_ROOT)) { + total += findMessageArguments(readFileSync(file, 'utf8'), RESPONSE_HELPERS).length; + } + // 实测 547 处(2026-09 全量扫描)。留一点余量,但任何「扫描器失效 ⇒ 扫到 0 处」 + // 的情况都会先撞上这条断言,而不是让下面那条护栏变成空转。 + assert.ok(total >= 500, `期望扫到 500+ 处错误响应调用点,实际只有 ${total} 处 —— 护栏可能已失效`); +}); diff --git a/scripts/folders-handler.test.ts b/scripts/folders-handler.test.ts new file mode 100644 index 000000000..d9f2399b2 --- /dev/null +++ b/scripts/folders-handler.test.ts @@ -0,0 +1,333 @@ +// `src/handlers/folders.ts` 的行为测试 +// +// 选它的理由(NEXT.md §3.4):逻辑简单,适合把"handler 级测试能测到什么"这件事示范清楚。 +// 但简单不等于没坑 —— 这里有三处**容易在改动中悄悄坏掉**的行为: +// +// ① **删除文件夹会把条目的 `folder_id` 清空**(跨表副作用,不测就看不见) +// ② **更新时省略 `name` 表示"不改名"**(与 ciphers 的"省略即清空"**相反** —— 两个端点的 +// 语义确实不同,属于历史约定,用测试固化下来,避免后人"统一"成一种) +// ③ **列表支持真正的分页**(`handleGetFolders` 用 `pageSize` + `continuationToken`, +// 这是正确写法;与 §3.3.5 里管理端那个"形状支持但没实现"的接口形成对比) +// +// 运行方式:npm run test:folders-handler +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { + handleCreateFolder, + handleDeleteFolder, + handleGetFolder, + handleGetFolders, + handleUpdateFolder, +} from '../src/handlers/folders'; +import { LIMITS } from '../src/config/limits'; +import type { Env } from '../src/types'; +import { createSchemaDatabase, enc, insertUser } from './lib/test-harness'; + +const OWNER = 'owner-1'; +const STRANGER = 'stranger-1'; + +interface Harness { + handle: Awaited>; + connection: DatabaseSync; + env: Env; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, OWNER); + insertUser(handle.connection, STRANGER); + const env = { + DB: handle.db, + JWT_SECRET: 'test-jwt-secret-at-least-32-characters-long', + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + return { handle, connection: handle.connection, env }; +} + +function jsonRequest(url: string, body: unknown, method = 'POST'): Request { + return new Request(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function createFolder(h: Harness, user: string, name: string): Promise> { + const response = await handleCreateFolder(jsonRequest('https://x/api/folders', { name }), h.env, user); + assert.equal(response.status, 200, `创建文件夹应成功,实际 ${response.status}`); + return (await response.json()) as Record; +} + +function folderCount(h: Harness): number { + return (h.connection.prepare('SELECT COUNT(*) AS count FROM folders').get() as { count: number }).count; +} + +// ---------------------------------------------------------------- 创建 + +test('创建:缺 name 与非法 JSON 都返回 400', async () => { + const h = await createHarness(); + + assert.equal((await handleCreateFolder(jsonRequest('https://x/api/folders', {}), h.env, OWNER)).status, 400); + assert.equal( + (await handleCreateFolder(jsonRequest('https://x/api/folders', { name: '' }), h.env, OWNER)).status, + 400, + '空字符串 name 视同缺失' + ); + assert.equal( + ( + await handleCreateFolder( + new Request('https://x/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }), + h.env, + OWNER + ) + ).status, + 400 + ); + assert.equal(folderCount(h), 0, '被拒绝的请求不得写入任何数据'); + + h.handle.close(); +}); + +test('创建:客户端伪造的 userId 会被会话身份覆盖', async () => { + const h = await createHarness(); + const response = await handleCreateFolder( + jsonRequest('https://x/api/folders', { name: enc('name'), userId: STRANGER }), + h.env, + OWNER + ); + const body = (await response.json()) as { id: string }; + + const row = h.connection.prepare('SELECT user_id FROM folders WHERE id = ?').get(body.id) as { + user_id: string; + }; + assert.equal(row.user_id, OWNER, '归属必须取自会话,否则可把数据写进他人账户'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 读取与跨用户隔离 + +test('读取:所有者 200,非所有者 404', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('name')); + const id = String(created.id); + + assert.equal((await handleGetFolder(new Request(`https://x/api/folders/${id}`), h.env, OWNER, id)).status, 200); + assert.equal( + (await handleGetFolder(new Request(`https://x/api/folders/${id}`), h.env, STRANGER, id)).status, + 404, + '非所有者必须 404' + ); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 更新 + +test('更新:非所有者 404 且数据不变', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('original')); + const id = String(created.id); + const before = { ...(h.connection.prepare('SELECT * FROM folders WHERE id = ?').get(id) as object) }; + + const response = await handleUpdateFolder( + jsonRequest(`https://x/api/folders/${id}`, { name: enc('hijacked') }, 'PUT'), + h.env, + STRANGER, + id + ); + assert.equal(response.status, 404); + assert.deepStrictEqual( + { ...(h.connection.prepare('SELECT * FROM folders WHERE id = ?').get(id) as object) }, + before, + '被拒绝的更新不得改动数据' + ); + + h.handle.close(); +}); + +test('更新:省略 name 表示「不改名」(与 ciphers 的替换语义相反,此处固化既有约定)', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('keep-me')); + const id = String(created.id); + + // 刻意把 updated_at 拨回一个**已知的过去时刻**再更新。 + // + // 原先这里比较的是「更新前后两次 `new Date().toISOString()` 是否不同」—— 而两次调用 + // 可能落在**同一毫秒**,断言就随机失败(实际上已经偶发红过一次)。 + // 把基线钉死在过去,这里才变成确定性判断。 + const baseline = '2020-01-01T00:00:00.000Z'; + h.connection.prepare('UPDATE folders SET updated_at = ? WHERE id = ?').run(baseline, id); + + const response = await handleUpdateFolder( + jsonRequest(`https://x/api/folders/${id}`, {}, 'PUT'), + h.env, + OWNER, + id + ); + assert.equal(response.status, 200); + + const row = h.connection.prepare('SELECT name, updated_at FROM folders WHERE id = ?').get(id) as { + name: string; + updated_at: string; + }; + assert.equal(row.name, enc('keep-me'), '省略 name 不应把名字清空'); + assert.ok( + row.updated_at > baseline, + `即使没改名也应推进 updated_at(客户端据此同步),实际仍是 ${row.updated_at}` + ); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 删除(含跨表副作用) + +test('删除:非所有者 404 且数据仍在', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('name')); + const id = String(created.id); + + const response = await handleDeleteFolder(new Request(`https://x/api/folders/${id}`, { method: 'DELETE' }), h.env, STRANGER, id); + assert.equal(response.status, 404); + assert.equal(folderCount(h), 1, '被拒绝的删除不得真的删掉'); + + h.handle.close(); +}); + +test('删除:所有者返回 204,行被删除,并留下审计事件', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('name')); + const id = String(created.id); + + const response = await handleDeleteFolder(new Request(`https://x/api/folders/${id}`, { method: 'DELETE' }), h.env, OWNER, id); + assert.equal(response.status, 204); + assert.equal(folderCount(h), 0, '文件夹应被删除'); + + const audit = h.connection + .prepare("SELECT COUNT(*) AS count FROM audit_logs WHERE action = 'folder.delete' AND target_id = ?") + .get(id) as { count: number }; + assert.equal(audit.count, 1, '删除文件夹应留下审计事件'); + + h.handle.close(); +}); + +test('删除:关联条目的 folder_id 必须被清空(跨表副作用,最容易被改坏)', async () => { + const h = await createHarness(); + const created = await createFolder(h, OWNER, enc('name')); + const id = String(created.id); + + // 造两个条目:一个在该文件夹里,一个不在 + for (const [cipherId, folderId] of [ + ['cipher-in-folder', id], + ['cipher-elsewhere', null], + ] as const) { + h.connection + .prepare( + 'INSERT INTO ciphers (id, user_id, type, folder_id, name, data, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)' + ) + .run(cipherId, OWNER, 1, folderId, enc('c'), '{}', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); + } + + await handleDeleteFolder(new Request(`https://x/api/folders/${id}`, { method: 'DELETE' }), h.env, OWNER, id); + + const inFolder = h.connection.prepare('SELECT folder_id FROM ciphers WHERE id = ?').get('cipher-in-folder') as { + folder_id: string | null; + }; + assert.equal( + inFolder.folder_id, + null, + '文件夹被删后,原来属于它的条目必须变成"未分类",否则客户端会指向一个不存在的文件夹' + ); + + // 关键:清空必须只影响该文件夹,不能误伤别的条目 + const elsewhere = h.connection.prepare('SELECT COUNT(*) AS count FROM ciphers WHERE id = ?').get('cipher-elsewhere') as { + count: number; + }; + assert.equal(elsewhere.count, 1, '其他条目的行必须原样保留'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 列表与分页 + +test('列表:不带分页参数时返回全部', async () => { + const h = await createHarness(); + for (let i = 0; i < 3; i += 1) await createFolder(h, OWNER, enc(`f${i}`)); + + const response = await handleGetFolders(new Request('https://x/api/folders'), h.env, OWNER); + const body = (await response.json()) as { data: unknown[]; continuationToken: string | null }; + assert.equal(body.data.length, 3); + assert.equal(body.continuationToken, null, '未分页时不应给出游标'); + + h.handle.close(); +}); + +test('列表:分页游标可翻到底,且最后一页不再返回游标', async () => { + const h = await createHarness(); + const total = 5; + for (let i = 0; i < total; i += 1) await createFolder(h, OWNER, enc(`f${i}`)); + + const pageSize = 2; + const seen: string[] = []; + let token: string | null = null; + let pages = 0; + + do { + const url = new URL('https://x/api/folders'); + url.searchParams.set('pageSize', String(pageSize)); + if (token) url.searchParams.set('continuationToken', token); + + const body = (await (await handleGetFolders(new Request(url.toString()), h.env, OWNER)).json()) as { + data: Array<{ id: string }>; + continuationToken: string | null; + }; + for (const folder of body.data) seen.push(folder.id); + token = body.continuationToken; + pages += 1; + assert.ok(pages <= 5, '翻页不应无限循环(游标没推进就会这样)'); + } while (token); + + assert.equal(seen.length, total, '翻完所有页应恰好拿到全部条目,不重不漏'); + assert.equal(new Set(seen).size, total, '不应出现重复条目'); + assert.equal(pages, Math.ceil(total / pageSize), `5 条按每页 2 条应翻 3 页,实际 ${pages} 页`); + + h.handle.close(); +}); + +test('列表:非法的 pageSize 被忽略并回退到「返回全部」', async () => { + const h = await createHarness(); + for (let i = 0; i < 3; i += 1) await createFolder(h, OWNER, enc(`f${i}`)); + + for (const bad of ['0', '-1', 'abc', '1.5']) { + const url = new URL('https://x/api/folders'); + url.searchParams.set('pageSize', bad); + const body = (await (await handleGetFolders(new Request(url.toString()), h.env, OWNER)).json()) as { + data: unknown[]; + }; + assert.equal(body.data.length, 3, `pageSize=${bad} 应被忽略,回退为返回全部`); + } + + h.handle.close(); +}); + +test('列表:pageSize 超过服务端上限时被夹到上限', async () => { + const h = await createHarness(); + const url = new URL('https://x/api/folders'); + url.searchParams.set('pageSize', String(LIMITS.pagination.maxPageSize * 10)); + + // 只要不抛错、且不是按客户端要的巨量分页就行 —— 这里造的数据量远小于上限, + // 因此断言"能正常返回全部"即可;上限本身由 parsePagination 的 Math.min 保证。 + const response = await handleGetFolders(new Request(url.toString()), h.env, OWNER); + assert.equal(response.status, 200); + + h.handle.close(); +}); diff --git a/scripts/i18n-validate.cjs b/scripts/i18n-validate.cjs index f2e3b199a..e8af704ad 100644 --- a/scripts/i18n-validate.cjs +++ b/scripts/i18n-validate.cjs @@ -1,4 +1,11 @@ -const { localeFiles, readLocale } = require('./i18n-utils.cjs'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { localeFiles, readLocale, localeDir } = require('./i18n-utils.cjs'); + +// i18n.ts 的 localeLoaders 是语言集合的权威来源;i18n-utils.cjs 里手写的列表 +// 只描述「每个语言对应哪个文件」。两者必须一致,见下方的显式比对。 +const i18nEntry = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n.ts'); // CONTRACT: // This is the authoritative locale consistency gate. It checks key parity, @@ -11,6 +18,51 @@ const base = locales.en; const baseKeys = Object.keys(base).sort(); const placeholderRe = /\{\w+\}/g; const errors = []; + +// 读取 i18n.ts 中的 localeLoaders,返回 Map。 +// fileName 为 null 表示该 loader 不是动态 import(例如 en 走静态导入)。 +// 按行解析而非正则跨行匹配:声明行含有 `=>`,正则会被类型里的等号干扰。 +function readI18nLoaderLocales() { + const lines = fs.readFileSync(i18nEntry, 'utf8').split('\n'); + const start = lines.findIndex((line) => line.includes('localeLoaders') && line.includes('= {')); + if (start === -1) return null; + + const entries = new Map(); + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (line.trim() === '};') break; + const match = line.match(/^\s*(?:'([^']+)'|([A-Za-z][\w-]*))\s*:\s*(.+?),?\s*$/); + if (!match) continue; + const locale = match[1] || match[2]; + const importMatch = match[3].match(/import\('\.\/i18n\/locales\/([^']+)'\)/); + entries.set(locale, importMatch ? `${importMatch[1]}.ts` : null); + } + return entries; +} + +// 语种集合必须与 i18n.ts 一致。若只改了 i18n.ts 而漏改 i18n-utils.cjs,新语言会被 +// 本脚本静默跳过(不报错、假绿),因此这里显式报错。 +const loaderLocales = readI18nLoaderLocales(); +if (!loaderLocales || loaderLocales.size === 0) { + errors.push({ locale: 'i18n.ts', problem: 'could not read localeLoaders', file: i18nEntry }); +} else { + const declared = localeFiles.map(([locale]) => locale); + const missingInUtils = [...loaderLocales.keys()].filter((locale) => !declared.includes(locale)); + const extraInUtils = declared.filter((locale) => !loaderLocales.has(locale)); + if (missingInUtils.length || extraInUtils.length) { + errors.push({ + locale: 'i18n.ts', + problem: 'locale list out of sync with scripts/i18n-utils.cjs', + missingInUtils, + extraInUtils, + }); + } + for (const [locale, fileName] of loaderLocales) { + if (fileName && !fs.existsSync(path.join(localeDir, fileName))) { + errors.push({ locale, problem: `locale file not found: ${fileName}` }); + } + } +} const intentionallyEnglishKeys = new Set([ 'txt_backup_destination_detail_note', 'txt_backup_protocol_webdav', diff --git a/scripts/identity-handler.test.ts b/scripts/identity-handler.test.ts new file mode 100644 index 000000000..d48781775 --- /dev/null +++ b/scripts/identity-handler.test.ts @@ -0,0 +1,526 @@ +// `src/handlers/identity.ts` 的行为测试 —— 认证入口 +// +// 为什么这个 handler 最该测:它是**唯一签发凭据的地方**。这里出错的后果与其他 handler +// 不同量级 —— 不是"某个列表少一条",而是"凭据发给了不该发的人"或"合法用户进不来"。 +// +// 覆盖重点(按 NEXT.md §3.4 的规划): +// ① 密码校验的**失败路径**:每种失败都必须**不签发任何凭据** +// ② 防用户枚举:prelogin 对不存在的用户必须返回**与真实用户相同的响应形状** +// ③ **凭据用途隔离**:access token 不能被当成 refresh token 用 +// ④ `checkClientCredentialsParam` 这个安全门控的边界 +// +// 运行方式:npm run test:identity-handler +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + checkClientCredentialsParam, + handlePrelogin, + handleRevocation, + handleToken, +} from '../src/handlers/identity'; +import { AuthService } from '../src/services/auth'; +import type { Env } from '../src/types'; +import { createSchemaDatabase, insertUser, TEST_JWT_SECRET } from './lib/test-harness'; + +const USER_ID = 'user-1'; +const USER_EMAIL = 'user-1@example.test'; +/** 客户端先对主密码做一次哈希后发上来的值(服务端再叠一层,见 AuthService) */ +const CLIENT_HASH = 'client-side-hash-of-master-password'; +/** 客户端 IP 缺失时 handleToken 会直接 503,所以每个请求都要带上 */ +const CLIENT_IP = '203.0.113.7'; + +interface Harness { + handle: Awaited>; + env: Env; + auth: AuthService; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + const env = buildEnv(handle.db); + return { handle, env, auth: new AuthService(env) }; +} + +function buildEnv(db: Env['DB']): Env { + return { + DB: db, + // 签发 JWT 需要密钥;不在测试里给就会以零长度密钥 importKey, + // 报 `DataError: Zero-length key is not supported` + JWT_SECRET: TEST_JWT_SECRET, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; +} + +/** 存一个"密码就是 CLIENT_HASH"的用户。哈希用真实实现生成,不手写。 */ +async function seedUserWithPassword( + h: Harness, + options: { id?: string; email?: string; status?: string; yubikeyKey1?: string } = {} +): Promise { + const id = options.id ?? USER_ID; + const email = options.email ?? USER_EMAIL; + const masterPasswordHash = await h.auth.hashPasswordServer(CLIENT_HASH, email); + insertUser(h.handle.connection, id, { + email, + status: options.status, + masterPasswordHash, + yubikeyKey1: options.yubikeyKey1, + }); + return email; +} + +function tokenRequest(payload: Record, options: { form?: boolean; ip?: string | null } = {}): Request { + const headers: Record = {}; + if (options.ip !== null) headers['CF-Connecting-IP'] = options.ip ?? CLIENT_IP; + + if (options.form) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + return new Request('https://vault.example.test/identity/connect/token', { + method: 'POST', + headers, + body: new URLSearchParams(payload).toString(), + }); + } + + headers['Content-Type'] = 'application/json'; + return new Request('https://vault.example.test/identity/connect/token', { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); +} + +async function callToken( + h: Harness, + payload: Record, + options: { form?: boolean; ip?: string | null } = {} +): Promise<{ status: number; body: Record }> { + const response = await handleToken(tokenRequest(payload, options), h.env); + return { status: response.status, body: (await response.json()) as Record }; +} + +// ---------------------------------------------------------------- 纯函数:client_credentials 门控 + +test('checkClientCredentialsParam:只放行「user. 前缀的 clientId + 非空 secret + scope=api」', () => { + // 这是 client_credentials 授权的唯一门控,四条规则都必须成立 + const cases: Array<[string, string, string, boolean, string]> = [ + ['user.abc', 'secret', 'api', true, '三个条件齐备才放行'], + ['user.abc', 'secret', 'offline_access', false, 'scope 必须是 api'], + ['user.abc', 'secret', '', false, 'scope 不能为空'], + ['abc', 'secret', 'api', false, 'clientId 必须带 user. 前缀'], + ['User.abc', 'secret', 'api', false, '前缀区分大小写'], + ['organization.abc', 'secret', 'api', false, 'organization. 前缀不放行'], + ['user.', '', 'api', false, 'secret 不能为空'], + ['user.', ' ', 'api', true, '空白 secret 当前视为非空(记录既有行为,非缺陷判定)'], + ['', 'secret', 'api', false, '空 clientId 不放行'], + ]; + + for (const [clientId, clientSecret, scope, expected, why] of cases) { + assert.equal( + checkClientCredentialsParam(clientId, clientSecret, scope), + expected, + `clientId="${clientId}" secret="${clientSecret}" scope="${scope}" —— ${why}` + ); + } +}); + +// ---------------------------------------------------------------- 防用户枚举 + +test('prelogin:不存在的用户返回与真实用户相同的响应形状(防用户枚举)', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const prelogin = (email: string) => + handlePrelogin( + new Request('https://vault.example.test/identity/accounts/prelogin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }), + h.env + ); + + const existing = (await (await prelogin(USER_EMAIL)).json()) as Record; + const missing = (await (await prelogin('nobody@example.test')).json()) as Record; + + assert.deepStrictEqual( + Object.keys(missing).sort(), + Object.keys(existing).sort(), + '键集合必须一致 —— 少一个字段就是一个用户枚举信号' + ); + // 这些字段对不存在的用户应为 null,而不是 undefined 或被省略 + for (const key of ['kdfMemory', 'kdfParallelism']) { + assert.ok(key in missing, `${key} 必须显式存在`); + assert.equal(missing[key], null, `${key} 对不存在的用户应为 null`); + assert.equal(missing[key], existing[key], `${key} 与真实用户应取同一个值(此处都为 null)`); + } + + h.handle.close(); +}); + +test('prelogin:缺 email 返回 400,非法 JSON 也返回 400', async () => { + const h = await createHarness(); + const build = (body: string) => + new Request('https://vault.example.test/identity/accounts/prelogin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + assert.equal((await handlePrelogin(build(JSON.stringify({})), h.env)).status, 400, '缺 email 应 400'); + assert.equal((await handlePrelogin(build('{not json'), h.env)).status, 400, '非法 JSON 应 400'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 签发路径的失败分支 + +test('token:缺少客户端 IP 时返回 503,而不是继续往下走', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken(h, { grant_type: 'password', username: USER_EMAIL, password: CLIENT_HASH }, { ip: null }); + assert.equal(result.status, 503, '拿不到客户端 IP 时无法做限流,应拒服务'); + assert.equal(result.body.error, 'temporarily_unavailable'); + assert.ok(result.body.access_token === undefined, '绝不能在缺少限流依据时签发凭据'); + + h.handle.close(); +}); + +test('token:password 授权缺 username 或 password 时 400 invalid_request', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const noPassword = await callToken(h, { grant_type: 'password', username: USER_EMAIL }); + assert.equal(noPassword.status, 400); + assert.equal(noPassword.body.error, 'invalid_request'); + + const noUsername = await callToken(h, { grant_type: 'password', password: CLIENT_HASH }); + assert.equal(noUsername.status, 400); + assert.equal(noUsername.body.error, 'invalid_request'); + + h.handle.close(); +}); + +test('token:用户不存在时 400 invalid_grant,且不签发凭据', async () => { + const h = await createHarness(); + + const result = await callToken(h, { + grant_type: 'password', + username: 'nobody@example.test', + password: CLIENT_HASH, + }); + + assert.equal(result.status, 400); + assert.equal(result.body.error, 'invalid_grant'); + assert.ok(result.body.access_token === undefined); + assert.ok(result.body.refresh_token === undefined); + assert.equal( + h.handle.connection.prepare('SELECT COUNT(*) AS count FROM refresh_tokens').get()?.count, + 0, + '失败的登录不得写入任何 refresh token' + ); + + h.handle.close(); +}); + +test('token:密码错误时 400 invalid_grant,且不签发凭据、不落库', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: 'wrong-password', + }); + + assert.equal(result.status, 400); + assert.equal(result.body.error, 'invalid_grant'); + assert.ok(result.body.access_token === undefined, '密码错误绝不能签发 access token'); + assert.ok(result.body.refresh_token === undefined, '密码错误绝不能签发 refresh token'); + assert.equal( + h.handle.connection.prepare('SELECT COUNT(*) AS count FROM refresh_tokens').get()?.count, + 0, + '密码错误的登录不得写入 refresh token' + ); + + h.handle.close(); +}); + +test('token:被封禁(banned)的账号无法登录,且不签发凭据', async () => { + const h = await createHarness(); + await seedUserWithPassword(h, { status: 'banned' }); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + + assert.equal(result.status, 400, `被封禁的账号必须登不进来,实际 ${result.status}`); + assert.match(String(result.body.error_description ?? ''), /disabled/i); + assert.ok(result.body.access_token === undefined, '封禁账号绝不能签发 access token'); + assert.ok(result.body.refresh_token === undefined); + + h.handle.close(); +}); + +// 下面这条**记录既有行为,不是缺陷判定**,请勿当成"期望行为"照抄。 +// +// 背景:`mapUserRow`(storage-user-repo.ts)只有一行 +// status: row.status === 'banned' ? 'banned' : 'active' +// 即**只认 'banned',其余一切值都归为 'active'** —— 而 `handleToken` 的门是 +// `user.status !== 'active'`,所以只有 'banned' 能拦住登录。 +// +// 为什么现在不算缺陷:`users.status` 的**唯一**非 active 写入方是管理端的 +// `handleAdminUpdateUserStatus`,它做了白名单(`status must be active or banned`,其余 400)。 +// 也就是说系统自己永远不会产生第三个值。 +// +// 但这是个"宽容映射"(fail-open):手工执行 +// `UPDATE users SET status = 'suspended'` 之类会被**静默当作正常账号**。 +// 本用例把这一点固定下来,将来若有人把映射改成 fail-closed,这里会红, +// 从而强制做一次有意识的决策(而不是无声漂移)。 +test('记录既有行为:状态映射只认 banned,其余值一律当作 active(fail-open)', async () => { + const h = await createHarness(); + await seedUserWithPassword(h, { status: 'suspended' }); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + + assert.equal( + result.status, + 200, + '当前实现下未被识别为 banned 的值不会拦截登录 —— 这是既有行为,不是期望行为' + ); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 签发路径的成功分支 + +test('token:密码正确时签发 access_token 与 refresh_token,并把 refresh token 落库', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + + assert.equal(result.status, 200, `正确密码应登录成功,实际 ${result.status}:${JSON.stringify(result.body)}`); + assert.ok(typeof result.body.access_token === 'string' && result.body.access_token.length > 0); + assert.ok(typeof result.body.refresh_token === 'string' && result.body.refresh_token.length > 0); + assert.equal(result.body.token_type, 'Bearer'); + assert.equal(result.body.scope, 'api offline_access'); + + const stored = h.handle.connection + .prepare('SELECT COUNT(*) AS count FROM refresh_tokens WHERE user_id = ?') + .get(USER_ID) as { count: number }; + assert.equal(stored.count, 1, 'refresh token 应落库一条'); + + h.handle.close(); +}); + +test('token:form-urlencoded 请求体也能登录(官方客户端就是这么发的)', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken( + h, + { grant_type: 'password', username: USER_EMAIL, password: CLIENT_HASH }, + { form: true } + ); + + assert.equal(result.status, 200, `form 形式应同样可用,实际 ${result.status}`); + assert.ok(result.body.access_token); + + h.handle.close(); +}); + +test('token:邮箱大小写不敏感(客户端可能发大写)', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL.toUpperCase(), + password: CLIENT_HASH, + }); + + assert.equal(result.status, 200, '大写邮箱应能登录'); + h.handle.close(); +}); + +// ---------------------------------------------------------------- 2FA 分支 + +// 这两条守的是一个**关键性质**:光有正确密码,在开启 2FA 时**不能**换到凭据。 +// 这是整个认证链上最容易被改坏、也最贵的一处。 +const TWO_FACTOR_PROVIDER_YUBIKEY = '3'; +const TWO_FACTOR_PROVIDER_REMEMBER = '5'; +const TWO_FACTOR_PROVIDER_AUTHENTICATOR = '0'; + +test('token:TOTP 密钥字母表非法时仍必须走挑战流程(fail-closed),不得静默降级', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + // `0` / `1` 不在 base32 字母表(A–Z2–7)内 ⇒ 这样的密钥算不出任何验证码。 + // 此时**不能**把两步验证当成“未启用”(那就是静默降级成仅凭密码登录); + // 只能继续 fail-closed,把用户引向恢复码或到设置页重新启用 TOTP。 + h.handle.connection.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run('INVALID0', USER_ID); + + const invalid = await callToken(h, { grant_type: 'password', username: USER_EMAIL, password: CLIENT_HASH }); + assert.equal(invalid.status, 400, '存在(哪怕不可用的)TOTP 密钥时不得直接签发凭据'); + assert.ok(invalid.body.access_token === undefined, '两步验证未完成前绝不能签发 access token'); + assert.ok( + Array.isArray(invalid.body.TwoFactorProviders) && + (invalid.body.TwoFactorProviders as string[]).includes(TWO_FACTOR_PROVIDER_AUTHENTICATOR), + `非法密钥也必须列出验证器提供方,实际:${JSON.stringify(invalid.body.TwoFactorProviders)}` + ); + + // 正向对照:合法密钥同样(且必须)进入挑战流程 + h.handle.connection.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run('JBSWY3DPEHPK3PXP', USER_ID); + const valid = await callToken(h, { grant_type: 'password', username: USER_EMAIL, password: CLIENT_HASH }); + assert.equal(valid.status, 400); + assert.ok((valid.body.TwoFactorProviders as string[]).includes(TWO_FACTOR_PROVIDER_AUTHENTICATOR)); + + h.handle.close(); +}); + +test('token:开启 2FA 后,只有密码正确也必须走挑战流程,不得签发凭据', async () => { + const h = await createHarness(); + // YubiKey 的"公钥 ID"当前只做非空判断,任意非空值即可让用户进入 2FA 分支 + await seedUserWithPassword(h, { yubikeyKey1: 'ccccccbcgujh' }); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + + assert.equal(result.status, 400, '开了 2FA 就不该直接返回 200'); + assert.equal(result.body.error, 'invalid_grant'); + assert.ok(result.body.access_token === undefined, '2FA 未完成前绝不能签发 access token'); + assert.ok(result.body.refresh_token === undefined, '2FA 未完成前绝不能签发 refresh token'); + assert.ok( + Array.isArray(result.body.TwoFactorProviders) && + (result.body.TwoFactorProviders as string[]).includes(TWO_FACTOR_PROVIDER_YUBIKEY), + `挑战响应应列出已启用的 2FA 提供方,实际:${JSON.stringify(result.body.TwoFactorProviders)}` + ); + assert.equal( + h.handle.connection.prepare('SELECT COUNT(*) AS count FROM refresh_tokens').get()?.count, + 0, + '2FA 挑战阶段不得写入 refresh token' + ); + + h.handle.close(); +}); + +test('token:提供无效的「记住此设备」令牌时回到挑战流程,而不是放行', async () => { + const h = await createHarness(); + await seedUserWithPassword(h, { yubikeyKey1: 'ccccccbcgujh' }); + + const result = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + twoFactorProvider: TWO_FACTOR_PROVIDER_REMEMBER, + twoFactorToken: 'not-a-real-remember-token', + }); + + assert.equal(result.status, 400, '无效的记住令牌必须回到挑战流程'); + assert.ok(result.body.access_token === undefined); + assert.ok(Array.isArray(result.body.TwoFactorProviders), '应重新给出 2FA 挑战载荷'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 凭据用途隔离 + +test('凭据用途隔离:access token 不能当 refresh token 用', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const login = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + assert.equal(login.status, 200); + const accessToken = String(login.body.access_token); + + const abuse = await callToken(h, { grant_type: 'refresh_token', refresh_token: accessToken }); + + assert.notEqual(abuse.status, 200, 'access token 不该能换出新凭据'); + assert.ok(abuse.body.access_token === undefined, '被拒绝的刷新绝不能签发新 access token'); + + h.handle.close(); +}); + +test('凭据用途隔离:不存在的 refresh token 换不出凭据', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const result = await callToken(h, { grant_type: 'refresh_token', refresh_token: 'not-a-real-token' }); + + assert.notEqual(result.status, 200); + assert.ok(result.body.access_token === undefined); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 撤销 + +test('撤销:未知 token 也返回 200(RFC 7009),已知 token 会被删除', async () => { + const h = await createHarness(); + await seedUserWithPassword(h); + + const login = await callToken(h, { + grant_type: 'password', + username: USER_EMAIL, + password: CLIENT_HASH, + }); + assert.equal(login.status, 200); + const refreshToken = String(login.body.refresh_token); + + const revoke = (token: string) => + handleRevocation( + new Request('https://vault.example.test/identity/connect/revocation', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': CLIENT_IP }, + body: JSON.stringify({ token }), + }), + h.env + ); + + assert.equal((await revoke('unknown-token')).status, 200, '未知 token 也应是 200,避免泄露存在性'); + assert.equal((await revoke(refreshToken)).status, 200); + + const remaining = h.handle.connection + .prepare('SELECT COUNT(*) AS count FROM refresh_tokens WHERE user_id = ?') + .get(USER_ID) as { count: number }; + assert.equal(remaining.count, 0, '撤销后 refresh token 应已删除'); + + h.handle.close(); +}); + +test('撤销:请求体无法解析时仍返回 200(best-effort,不报错)', async () => { + const h = await createHarness(); + + const response = await handleRevocation( + new Request('https://vault.example.test/identity/connect/revocation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }), + h.env + ); + + assert.equal(response.status, 200); + h.handle.close(); +}); diff --git a/scripts/import-handler.test.ts b/scripts/import-handler.test.ts new file mode 100644 index 000000000..1f6bced87 --- /dev/null +++ b/scripts/import-handler.test.ts @@ -0,0 +1,336 @@ +// `src/handlers/import.ts` 的行为测试(Bitwarden 客户端的导入端点) +// +// 为什么值得测:导入是**批量写入**路径 —— 客户端会把整个 vault(最多 5000 条)一次性推上来。 +// 这里出错的形态与单条 CRUD 不同: +// · 写错了 → 一次污染几千条,且用户很难分辨哪些是导入的 +// · 失败时留下了半截数据 → 用户看到"导入失败",库里却多了东西,重试后越积越多 +// +// 本文件把下面这条当作**期望行为**来断言:**导入要么整体成功,要么什么都不留下**。 +// (首次运行时它应当失败 —— 那正是要修的东西。) +// +// 运行方式:npm run test:import-handler +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { handleCiphersImport } from '../src/handlers/import'; +import { LIMITS } from '../src/config/limits'; +import type { Env } from '../src/types'; +import { createSchemaDatabase, enc, insertUser } from './lib/test-harness'; + +const OWNER = 'owner-1'; +const STRANGER = 'stranger-1'; + +interface Harness { + handle: Awaited>; + connection: DatabaseSync; + env: Env; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, OWNER); + insertUser(handle.connection, STRANGER); + const env = { + DB: handle.db, + JWT_SECRET: 'test-jwt-secret-at-least-32-characters-long', + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + return { handle, connection: handle.connection, env }; +} + +function importRequest(payload: unknown, query = ''): Request { + return new Request(`https://vault.example.test/api/ciphers/import${query}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +function counts(h: Harness, user = OWNER): { folders: number; ciphers: number } { + const folders = h.connection.prepare('SELECT COUNT(*) AS count FROM folders WHERE user_id = ?').get(user) as { + count: number; + }; + const ciphers = h.connection.prepare('SELECT COUNT(*) AS count FROM ciphers WHERE user_id = ?').get(user) as { + count: number; + }; + return { folders: folders.count, ciphers: ciphers.count }; +} + +/** 一条合法的导入条目(name 必须是合法 EncString,否则校验会拒) */ +function validCipher(overrides: Record = {}): Record { + return { + type: 1, + name: enc('name'), + notes: null, + login: { username: enc('u'), password: enc('p') }, + ...overrides, + }; +} + +// ---------------------------------------------------------------- 入参校验 + +test('导入:非法 JSON 返回 400', async () => { + const h = await createHarness(); + + const response = await handleCiphersImport( + new Request('https://vault.example.test/api/ciphers/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{not json', + }), + h.env, + OWNER + ); + assert.equal(response.status, 400); + assert.deepStrictEqual(counts(h), { folders: 0, ciphers: 0 }); + + h.handle.close(); +}); + +test('导入:文件夹与条目合计超过上限时 400,且不写入任何数据', async () => { + const h = await createHarness(); + const payload = { + folders: Array.from({ length: 10 }, (_, i) => ({ name: enc(`f${i}`) })), + ciphers: Array.from({ length: LIMITS.performance.importItemLimit }, () => validCipher()), + }; + + const response = await handleCiphersImport(importRequest(payload), h.env, OWNER); + assert.equal(response.status, 400); + assert.match(String(((await response.json()) as { error?: string }).error ?? ''), /maximum/i); + assert.deepStrictEqual(counts(h), { folders: 0, ciphers: 0 }, '超限必须在写入之前就被拦下'); + + h.handle.close(); +}); + +test('导入:空载荷也能成功(0 文件夹 + 0 条目)', async () => { + const h = await createHarness(); + + const response = await handleCiphersImport(importRequest({}), h.env, OWNER); + assert.equal(response.status, 200); + assert.deepStrictEqual(counts(h), { folders: 0, ciphers: 0 }); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 成功路径 + +test('导入:文件夹与条目都落库,归属取自会话身份', async () => { + const h = await createHarness(); + const response = await handleCiphersImport( + importRequest({ folders: [{ name: enc('f') }], ciphers: [validCipher(), validCipher()] }), + h.env, + OWNER + ); + + assert.equal(response.status, 200); + assert.deepStrictEqual(counts(h), { folders: 1, ciphers: 2 }); + assert.deepStrictEqual(counts(h, STRANGER), { folders: 0, ciphers: 0 }, '不得写进别人账户'); + + h.handle.close(); +}); + +test('导入:缺少 name 的条目会回退为 Untitled,而 Untitled 过不了加密校验 → 整次导入被拒', async () => { + const h = await createHarness(); + // 这条固化的是一个**容易误以为能用的组合**:代码里确实有 `name: c.name ?? 'Untitled'` 的回退, + // 但 `Untitled` 不是合法 EncString,会被 `validateCipherEncryptedFieldsForCompatibility` 拦下。 + // 净效果:**没有 name 的条目无法导入**。报错里会带上条目序号,便于用户定位。 + const response = await handleCiphersImport( + importRequest({ ciphers: [validCipher(), { type: 1 }] }), + h.env, + OWNER + ); + + assert.equal(response.status, 400, '缺 name 的条目应使整次导入失败'); + const body = (await response.json()) as { error?: string }; + assert.match(String(body.error ?? ''), /Cipher 2/, '错误信息应指明是第几条出的问题'); + assert.deepStrictEqual(counts(h), { folders: 0, ciphers: 0 }, '被拒的导入不得留下数据'); + + h.handle.close(); +}); + +test('导入:返回 CIPHER MAP 时给出 index / sourceId / 新 id 的对应关系', async () => { + const h = await createHarness(); + const response = await handleCiphersImport( + importRequest( + { + ciphers: [validCipher({ id: 'old-1' }), validCipher(), validCipher({ id: 'old-3' })], + }, + '?returnCipherMap=1' + ), + h.env, + OWNER + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { + object: string; + cipherMap: Array<{ index: number; sourceId: string | null; id: string }>; + }; + assert.equal(body.object, 'import-result'); + assert.equal(body.cipherMap.length, 3); + assert.deepStrictEqual( + body.cipherMap.map((entry) => entry.index), + [0, 1, 2], + 'index 必须与请求体里的顺序一致,客户端靠它把旧 id 换成新 id' + ); + assert.deepStrictEqual( + body.cipherMap.map((entry) => entry.sourceId), + ['old-1', null, 'old-3'], + '缺 id 的条目 sourceId 应为 null' + ); + for (const entry of body.cipherMap) { + assert.ok(entry.id && entry.id !== entry.sourceId, '新 id 必须是服务端生成的,不能沿用客户端传的'); + } + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 文件夹关联 + +test('导入:folderRelationships 把条目挂到同一批新建的文件夹上', async () => { + const h = await createHarness(); + // rel.key = 条目下标,rel.value = 文件夹下标 + const response = await handleCiphersImport( + importRequest({ + folders: [{ name: enc('work') }, { name: enc('home') }], + ciphers: [validCipher(), validCipher(), validCipher()], + folderRelationships: [ + { key: 0, value: 1 }, + { key: 1, value: 0 }, + ], + }), + h.env, + OWNER + ); + assert.equal(response.status, 200); + + const folders = h.connection.prepare('SELECT id, name FROM folders WHERE user_id = ?').all(OWNER) as Array<{ + id: string; + name: string; + }>; + const folderIdByName = new Map(folders.map((row) => [row.name, row.id])); + + const rows = h.connection + .prepare('SELECT name, folder_id FROM ciphers WHERE user_id = ? ORDER BY rowid') + .all(OWNER) as Array<{ name: string; folder_id: string | null }>; + + assert.equal(rows[0].folder_id, folderIdByName.get(enc('home')), '第 0 条应挂到 folders[1]'); + assert.equal(rows[1].folder_id, folderIdByName.get(enc('work')), '第 1 条应挂到 folders[0]'); + assert.equal(rows[2].folder_id, null, '没有关系记录的条目应为"未分类"'); + + h.handle.close(); +}); + +test('导入:引用不存在的外部 folderId 时归为未分类,而不是写入一个悬空引用', async () => { + const h = await createHarness(); + const response = await handleCiphersImport( + importRequest({ ciphers: [validCipher({ folderId: 'no-such-folder' })] }), + h.env, + OWNER + ); + assert.equal(response.status, 200); + + const row = h.connection.prepare('SELECT folder_id FROM ciphers WHERE user_id = ?').get(OWNER) as { + folder_id: string | null; + }; + assert.equal(row.folder_id, null, '悬空引用必须被丢弃,否则客户端会指向不存在的文件夹'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 兼容面 + +test('导入:客户端未知字段要原样保留(PascalCase 别名只覆盖嵌套对象,此处一并记录)', async () => { + const h = await createHarness(); + const response = await handleCiphersImport( + importRequest({ + ciphers: [ + { + type: 1, + name: enc('aliased'), + // 嵌套加密对象走 readAliasedImportProp(...),因此 PascalCase 可用 + Login: { Username: enc('u') }, + futureClientField: 'keep-me', + nestedFuture: { deep: [1, 2] }, + }, + ], + }), + h.env, + OWNER + ); + assert.equal(response.status, 200); + + const row = h.connection.prepare('SELECT name, data FROM ciphers WHERE user_id = ?').get(OWNER) as { + name: string; + data: string; + }; + assert.equal(row.name, enc('aliased')); + + const data = JSON.parse(row.data) as Record; + assert.equal(data.futureClientField, 'keep-me', '客户端未知字段必须保留(CONTRIBUTING 硬性要求)'); + assert.deepStrictEqual(data.nestedFuture, { deep: [1, 2] }); + + h.handle.close(); +}); + +// 值得记下的**不对称**(不是缺陷,但后人容易踩): +// import.ts 里 `folderId` / `login` / `card` / `identity` / `secureNote` / `sshKey` … 这些字段 +// 走 `readAliasedImportProp(['x', 'X'])`,**同时接受 camelCase 与 PascalCase**; +// 而 `type` / `name` / `notes` / `favorite` / `reprompt` 是直接属性访问,**只认 camelCase**。 +// 官方客户端发的都是 camelCase,所以现在没问题;但假如有客户端只发 `Name`, +// 结果是 name 回退为 'Untitled' → 加密校验失败 → **400 报错并指明条目序号**, +// 即“响亮地失败”而不是静默写坏数据。因此不为此改动实现。 + +// ---------------------------------------------------------------- 失败时的原子性 + +test('导入:某条条目非法时返回 400,且**不留下任何写入**(含已解析成功的文件夹)', async () => { + const h = await createHarness(); + + const response = await handleCiphersImport( + importRequest({ + folders: [{ name: enc('work') }, { name: enc('home') }], + ciphers: [ + validCipher(), + // 第 2 条 name 是明文 → 加密字段校验会拒 + validCipher({ name: 'plaintext-not-an-encstring' }), + validCipher(), + ], + }), + h.env, + OWNER + ); + + assert.equal(response.status, 400, '非法条目应使整次导入失败'); + assert.deepStrictEqual( + counts(h), + { folders: 0, ciphers: 0 }, + '导入失败时库里必须原封不动 —— 留下半截数据会让用户看到"导入失败"却在库里多出东西,且重试后越积越多' + ); + + h.handle.close(); +}); + +test('导入:失败后的重试得到与首次成功一致的结果(不累积垃圾)', async () => { + const h = await createHarness(); + const payload = { + folders: [{ name: enc('work') }], + ciphers: [validCipher(), validCipher({ name: 'bad' })], + }; + + // 连续失败两次 + for (let attempt = 0; attempt < 2; attempt += 1) { + assert.equal((await handleCiphersImport(importRequest(payload), h.env, OWNER)).status, 400); + } + assert.deepStrictEqual(counts(h), { folders: 0, ciphers: 0 }, '两次失败重试后仍应一条不剩'); + + // 修好数据后导入成功 + const fixed = { folders: payload.folders, ciphers: [validCipher(), validCipher()] }; + assert.equal((await handleCiphersImport(importRequest(fixed), h.env, OWNER)).status, 200); + assert.deepStrictEqual(counts(h), { folders: 1, ciphers: 2 }); + + h.handle.close(); +}); diff --git a/scripts/lib/cloudflare-workers-stub.mjs b/scripts/lib/cloudflare-workers-stub.mjs new file mode 100644 index 000000000..10e0f82ee --- /dev/null +++ b/scripts/lib/cloudflare-workers-stub.mjs @@ -0,0 +1,28 @@ +// `cloudflare:workers` 的本地桩(测试专用) +// +// 背景:`src/durable/notifications-hub.ts` 从 `cloudflare:workers` 导入 `DurableObject` +// 与 `waitUntil`。这是 Workers 运行时的虚拟模块,**Node 无法解析** +// (报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`),于是任何间接引用该模块的 handler 都无法在 +// Node 测试里被导入。 +// +// 本文件配合 `register-cloudflare-stub.mjs` 使用:由 Node 的模块解析钩子把 +// `cloudflare:workers` 重定向到这里。 +// +// 刻意保持最小:只提供被实际导入的两个符号,避免"看起来很真但行为不同"的假象。 + +/** 仅满足 `class X extends DurableObject` 的语法需求;测试不实例化 DO */ +export class DurableObject { + constructor(ctx, env) { + this.ctx = ctx; + this.env = env; + } +} + +/** + * Workers 的 `ctx.waitUntil`:把 promise 的生命周期延到请求之后,且**不会**把 + * rejection 抛回请求。测试里顺应同一语义 —— 只吞掉错误,绝不让它变成 unhandled rejection + * 把测试搞挂。(实际发送通知需要真实 DO,测试环境没有,因此这里只做安全兜底。) + */ +export function waitUntil(promise) { + void Promise.resolve(promise).catch(() => {}); +} diff --git a/scripts/lib/d1-sqlite.ts b/scripts/lib/d1-sqlite.ts new file mode 100644 index 000000000..a9ebf7f8f --- /dev/null +++ b/scripts/lib/d1-sqlite.ts @@ -0,0 +1,129 @@ +// 用 node:sqlite 实现 D1Database 的最小接口 —— 供测试在没有 Cloudflare 运行时的情况下 +// 跑**真实 SQL**(D1 本身就是 SQLite,方言一致)。 +// +// 为什么不直接用 mock: +// 备份导入走的是「影子表 createShadowTables → 校验计数 → 最后一次性 +// swapShadowTablesIntoPlace」流程。纯 mock 不真正存数据,只能断言"发了哪些 SQL", +// **无法验证数据是否原样回来** —— 而那正是 round-trip 测试要回答的问题。 +// +// 局限(务必知悉): +// node:sqlite 与真实 D1 **不是同一实现**(D1 在其上加了自己的代理层与限制)。 +// 因此本适配器只能证明"读写逻辑在 SQLite 语义下自洽",**不能替代**在 +// `wrangler dev` 上跑的真实端到端验证。 +// +// 已实测可用性:本地 Node 26 = SQLite 3.53.4,CI 的 Node 24.18.0 = 3.53.1,均无需 flag。 +import { DatabaseSync } from 'node:sqlite'; +import type { D1Database, D1Result } from '@cloudflare/workers-types'; + +/** D1 绑定的合法标量类型收窄(node:sqlite 不接受 boolean / ArrayBuffer / undefined) */ +function toSqliteValue(value: unknown): null | number | bigint | string | Uint8Array { + if (value === undefined || value === null) return null; + if (typeof value === 'boolean') return value ? 1 : 0; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') return value; + // 真实 D1 同样只接受标量;这里显式抛出,避免静默写入错误数据 + throw new Error(`Unsupported bind value type: ${typeof value}`); +} + +function emptyMeta(): D1Result['meta'] { + return { changes: 0, duration: 0, last_row_id: 0, rows_read: 0, rows_written: 0 } as D1Result['meta']; +} + +class SqliteD1Statement { + constructor( + private readonly connection: DatabaseSync, + private readonly sql: string, + private readonly params: ReadonlyArray = [] + ) {} + + bind(...values: unknown[]): SqliteD1Statement { + return new SqliteD1Statement(this.connection, this.sql, values.map(toSqliteValue)); + } + + async first>(columnName?: string): Promise { + const row = this.connection.prepare(this.sql).get(...this.params) as Record | undefined; + if (!row) return null; + if (columnName !== undefined) return (row[columnName] ?? null) as T; + return row as T; + } + + async all>(): Promise> { + const results = this.connection.prepare(this.sql).all(...this.params) as T[]; + return { results, success: true, meta: emptyMeta() } as D1Result; + } + + async run>(): Promise> { + return this.execute() as D1Result; + } + + /** 供 batch() 复用:同步执行并返回 D1 形状的结果 */ + execute(): D1Result> { + const info = this.connection.prepare(this.sql).run(...this.params); + const changes = Number(info.changes ?? 0); + return { + results: [], + success: true, + meta: { + changes, + duration: 0, + last_row_id: Number(info.lastInsertRowid ?? 0), + rows_read: 0, + rows_written: changes, + }, + } as unknown as D1Result>; + } +} + +export interface D1SqliteDatabase { + /** 交给被测代码当 D1Database 用 */ + readonly db: D1Database; + /** 底层连接:测试可直接执行 DDL / 查询做校验 */ + readonly connection: DatabaseSync; + close(): void; +} + +/** + * 建一个内存 SQLite 库,并以 D1Database 的形态暴露给被测代码。 + * @param location 默认 `:memory:`;传文件路径可持久化以便用 sqlite3 CLI 交叉检查 + */ +export function createD1SqliteDatabase(location = ':memory:'): D1SqliteDatabase { + const connection = new DatabaseSync(location); + // 与 src/services/storage-schema.ts 的 ensureStorageSchema 保持一致 + connection.exec('PRAGMA foreign_keys = ON'); + + const db = { + prepare(sql: string) { + return new SqliteD1Statement(connection, sql); + }, + // D1 的 batch 是事务性的:要么全部生效,要么全部回滚 + async batch(statements: SqliteD1Statement[]) { + connection.exec('BEGIN'); + try { + const results = statements.map((statement) => statement.execute()); + connection.exec('COMMIT'); + return results; + } catch (error) { + connection.exec('ROLLBACK'); + throw error; + } + }, + async exec(sql: string) { + const count = sql + .split(';') + .map((part) => part.trim()) + .filter(Boolean).length; + connection.exec(sql); + return { count, duration: 0 }; + }, + }; + + return { + // 单次显式转换:本适配器只实现 D1Database 的最小可用子集(prepare/batch/exec) + db: db as unknown as D1Database, + connection, + close: () => connection.close(), + }; +} diff --git a/scripts/lib/r2-memory.ts b/scripts/lib/r2-memory.ts new file mode 100644 index 000000000..a3cbe7576 --- /dev/null +++ b/scripts/lib/r2-memory.ts @@ -0,0 +1,76 @@ +// R2 内存桩:只实现 blob-store.ts 实际用到的 put / get / delete +// +// 用途:在没有 Cloudflare 运行时的情况下验证附件 blob 的读写往返。 +// 局限:只覆盖 workspace 里用到的最小面(不含 list/multipart/条件请求等)。 +import type { R2Bucket } from '@cloudflare/workers-types'; + +interface StoredObject { + bytes: Uint8Array; + contentType: string; + customMetadata: Record | null; +} + +export interface R2MemoryBucket { + /** 交给被测代码当 R2Bucket 用 */ + readonly bucket: R2Bucket; + /** 已存对象的 key 列表(排序后),供断言使用 */ + keys(): string[]; + /** 读取对象原始字节;不存在返回 null */ + bytesOf(key: string): Uint8Array | null; + /** 读取对象的 contentType(验证元数据是否被保留) */ + contentTypeOf(key: string): string | null; + size(): number; +} + +async function toBytes(value: unknown): Promise { + if (typeof value === 'string') return new TextEncoder().encode(value); + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + if (value instanceof ReadableStream) { + // 上传路径(`parseDirectUploadPayload`)交出来的正是 ReadableStream。 + // 它的 `put` 本来就是 async 的,所以这里直接消费掉即可 —— + // 早期版本对流直接抛错,导致本桩根本盖不到附件上传。 + return new Uint8Array(await new Response(value).arrayBuffer()); + } + throw new Error(`R2MemoryBucket: unsupported value type ${typeof value}`); +} + +export function createR2MemoryBucket(): R2MemoryBucket { + const store = new Map(); + + const bucket = { + async put( + key: string, + value: unknown, + options?: { httpMetadata?: { contentType?: string }; customMetadata?: Record } + ): Promise { + store.set(key, { + bytes: await toBytes(value), + contentType: options?.httpMetadata?.contentType ?? 'application/octet-stream', + customMetadata: options?.customMetadata ?? null, + }); + }, + async get(key: string) { + const hit = store.get(key); + if (!hit) return null; + return { + body: new Response(hit.bytes).body, + size: hit.bytes.byteLength, + httpMetadata: { contentType: hit.contentType }, + customMetadata: hit.customMetadata, + }; + }, + async delete(key: string): Promise { + store.delete(key); + }, + }; + + return { + // 单次显式转换:本桩只实现 R2Bucket 的最小可用子集(put/get/delete) + bucket: bucket as unknown as R2Bucket, + keys: () => [...store.keys()].sort(), + bytesOf: (key) => store.get(key)?.bytes ?? null, + contentTypeOf: (key) => store.get(key)?.contentType ?? null, + size: () => store.size, + }; +} diff --git a/scripts/lib/register-cloudflare-stub.mjs b/scripts/lib/register-cloudflare-stub.mjs new file mode 100644 index 000000000..748b8d66c --- /dev/null +++ b/scripts/lib/register-cloudflare-stub.mjs @@ -0,0 +1,24 @@ +// 注册模块解析钩子:把 `cloudflare:workers` 重定向到本地桩。 +// +// 用法(必须用 --import 预加载,早于任何被测模块的解析): +// NODE_OPTIONS="--import=./scripts/lib/register-cloudflare-stub.mjs" npx tsx --test ... +// +// 为什么需要它:`src/durable/notifications-hub.ts` 从 `cloudflare:workers` 导入 +// `DurableObject` / `waitUntil`,而 Node 不支持该协议 —— 报 +// `ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'cloudflare:'`。 +// 没有这个钩子,所有间接引用通知模块的 handler(ciphers、folders、sends、identity…) +// 都无法在 Node 测试里被导入。 +// +// 本文件刻意写成 .mjs:由 Node 直接加载,不经过 tsx 转译。 +import { registerHooks } from 'node:module'; + +const STUB_URL = new URL('./cloudflare-workers-stub.mjs', import.meta.url).href; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'cloudflare:workers') { + return { url: STUB_URL, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, +}); diff --git a/scripts/lib/sql-recorder.ts b/scripts/lib/sql-recorder.ts new file mode 100644 index 000000000..dff3ba2c1 --- /dev/null +++ b/scripts/lib/sql-recorder.ts @@ -0,0 +1,117 @@ +// SQL 采集器:包一层 Proxy,记录被测代码**实际发出**的每一条 SQL。 +// +// 为什么不用正则去源码里抓 SQL 字符串: +// 本仓库大量 SQL 是模板字符串拼出来的(`WHERE ${where} ORDER BY ...`), +// 正则抓不全、抓到的也不是"运行时真正执行的形态"。运行时采集拿到的就是真相。 +// +// 为什么用 Proxy 而不是包装 statement: +// `env.DB.batch([...])` 需要拿到**真正的** statement 对象(`d1-sqlite.ts` 里靠 +// `.execute()` 复用同步执行路径)。如果包装 statement,batch 就会拿到假对象。 +// 只拦 `prepare` / `batch`、其余原样转发。 +// +// ⚠️ `queries` 与 `roundTrips` 回答的是**两个不同的问题**,别混用: +// · `queries` —— 准备好的 SQL 条数。用于"这条 SQL 的查询计划是什么"。 +// · `roundTrips` —— **数据库往返次数**。用于"这次请求会不会随数据量线性变慢"。 +// +// 差别在 `batch()` 上体现:`db.batch([...N 条...])` 会先 prepare N 条 +// (`queries` 记 N 条),但只发**一次**往返(`roundTrips` 记 1)。 +// 拿 `queries.length` 去断言"N+1 消失了"会得到假阴性 —— 实测踩过这个坑。 +import type { D1Database } from '@cloudflare/workers-types'; + +export interface SqlRecorder { + /** 交给被测代码当 D1Database 用 */ + readonly db: D1Database; + /** 按发生顺序记录的全部 SQL(含重复;不含 batch 边界标记) */ + readonly queries: string[]; + /** 去重后的 SQL,保持首次出现的顺序 */ + readonly distinctQueries: readonly string[]; + /** 数据库**往返**次数:逐条执行的语句各算 1 次,一次 batch 算 1 次 */ + readonly roundTrips: number; + /** 清空已记录内容(便于分段驱动不同场景) */ + reset(): void; +} + +/** 归一化:把连续空白压成一个空格,便于去重与输出对齐 */ +function normalize(sql: string): string { + return sql.replace(/\s+/g, ' ').trim(); +} + +export function recordQueries(inner: D1Database): SqlRecorder { + const queries: string[] = []; + /** 被 batch 收编的 statement 对应的 queries 下标 —— 它们不再各自算一次往返 */ + const batchedIndexes = new Set(); + const recordIndexByStatement = new WeakMap(); + let batchCount = 0; + + /** + * 给 statement 再套一层 Proxy,目的是**把 `bind()` 的结果也认出来**。 + * + * 这一步不能省:`prepare(SQL)` 返回一个 statement,`bind(...)` 会返回**另一个新对象**。 + * 而实际交给 `db.batch()` 的正是 bind 之后那个。如果只跟踪 prepare 的返回值, + * batch 里就一条都对不上,往返数会算成"N 条逐条执行 + 1 次 batch" —— + * 实测就踩了这个坑(batch 明明生效,计数却仍随 N 增长)。 + * + * 注意:proxy 会把 `execute()` 等原样转发给真正的 statement, + * 因此 `d1-sqlite.ts` 的 batch 实现不受影响。 + */ + function trackStatement(statement: object, queryIndex: number): object { + const proxy = new Proxy(statement, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) as unknown; + if (property === 'bind' && typeof value === 'function') { + return (...args: unknown[]) => trackStatement((value as (...a: unknown[]) => object).apply(target, args), queryIndex); + } + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value; + }, + }); + recordIndexByStatement.set(proxy, queryIndex); + return proxy; + } + + const proxy = new Proxy(inner, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) as unknown; + + if (property === 'prepare' && typeof value === 'function') { + return (query: string) => { + const queryIndex = queries.push(normalize(query)) - 1; + return trackStatement((value as (q: string) => object).call(target, query), queryIndex); + }; + } + + if (property === 'batch' && typeof value === 'function') { + return (statements: object[]) => { + batchCount += 1; + for (const statement of statements) { + const queryIndex = recordIndexByStatement.get(statement); + if (queryIndex !== undefined) batchedIndexes.add(queryIndex); + } + return (value as (s: object[]) => unknown).call(target, statements); + }; + } + + // 其余方法原样转发,但要把 this 绑回真正的 target + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value; + }, + }); + + return { + db: proxy as D1Database, + queries, + get distinctQueries() { + return [...new Set(queries)]; + }, + get roundTrips() { + let single = 0; + for (let index = 0; index < queries.length; index += 1) { + if (!batchedIndexes.has(index)) single += 1; + } + return single + batchCount; + }, + reset() { + queries.length = 0; + batchedIndexes.clear(); + batchCount = 0; + }, + }; +} diff --git a/scripts/lib/test-harness.ts b/scripts/lib/test-harness.ts new file mode 100644 index 000000000..bb84c0178 --- /dev/null +++ b/scripts/lib/test-harness.ts @@ -0,0 +1,143 @@ +// 共享测试夹具:按**生产同样的方式**建库 + 常用种子数据。 +// +// 为什么需要 resetStorageServiceStatics(): +// `StorageService` 上有几个 `private static` 的"已初始化"标志 +// (schemaVerified / attachmentTokenTableReady / …cleanupAt)。它们的作用是 +// "每个 isolate 只做一次",这在生产里是对的,但在测试里同一进程会建**多个**库 —— +// 不重置的话只有第一个库会真正执行 `ensureStorageSchema()`,后续库会缺表缺索引, +// 报出与业务逻辑无关的错。测试里重置它们等价于"模拟一个全新 isolate"。 +import { readFileSync } from 'node:fs'; +import type { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; + +import type { D1Database } from '@cloudflare/workers-types'; +import { countAccountPasskeyCredentialsByUserId } from '../../src/services/storage-account-passkey-repo'; +import { AuthService } from '../../src/services/auth'; +import { RateLimitService } from '../../src/services/ratelimit'; +import { StorageService } from '../../src/services/storage'; +import { createD1SqliteDatabase, type D1SqliteDatabase } from './d1-sqlite'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..'); +const MIGRATION_SQL = readFileSync(path.join(REPO_ROOT, 'migrations', '0001_init.sql'), 'utf8'); + +/** 固定时间戳,让断言可复现 */ +export const FIXED_NOW = '2026-01-01T00:00:00.000Z'; + +/** + * 生成合法的 Bitwarden EncString。 + * 服务端会校验"加密串"格式(`isValidEncString`):type 2 = AES-CBC-HMAC, + * 需 `2.||` 三段。传明文会被 400 拒绝。 + */ +export function enc(label: string): string { + return `2.${label}-iv|${label}-data|${label}-mac`; +} + +/** 重置所有进程级的"已初始化 / 上次清理时间"标志,模拟全新 isolate */ +export function resetProcessScopedStatics(): void { + Object.assign(StorageService as unknown as Record, { + schemaVerified: false, + attachmentTokenTableReady: false, + lastRefreshTokenCleanupAt: 0, + lastAttachmentTokenCleanupAt: 0, + lastTotpReplayCleanupAt: 0, + } satisfies Partial>); + + Object.assign(RateLimitService as unknown as Record, { + loginIpTableReady: false, + strictBudgetTableReady: false, + lastLoginIpCleanupAt: 0, + lastStrictBudgetCleanupAt: 0, + }); + + // AuthService 的两个 static Map 缓存跨库泄漏:同一个 email 在第二个库里 + // 可能拿到第一个库缓存的用户对象(例如把 disabled 用户认成 active)。 + // 它们没有公开的清空接口,直接取字段清。 + for (const field of ['userCache', 'deviceCache']) { + const cache = (AuthService as unknown as Record)[field]; + if (cache instanceof Map) cache.clear(); + } +} + +/** 测试用的 JWT 密钥。值与 `.dev.vars` 保持一致的形态(真实部署用自己的值)。 */ +export const TEST_JWT_SECRET = 'test-jwt-secret-at-least-32-characters-long'; + +/** + * 建一个"生产同款"的库: + * ① 跑 `migrations/0001_init.sql`(新库基线) + * ② 调 `StorageService.initializeDatabase()` 补上运行时 schema(索引 / 新列 / 管理员兜底) + * 第 ② 步刻意调用**生产入口**而不是自己重放 `SCHEMA_STATEMENTS` —— + * 后者夹着一堆 `ALTER TABLE ... ADD COLUMN`,迁移里已有该列时会报 + * `duplicate column name`,真实实现靠 `executeSchemaStatement()` 吞掉这类错误。 + */ +export async function createSchemaDatabase(): Promise { + const handle = createD1SqliteDatabase(); + handle.connection.exec(MIGRATION_SQL); + resetProcessScopedStatics(); + await new StorageService(handle.db).initializeDatabase(); + await warmUpLazySchemaEnsures(handle.db); + return handle; +} + +/** + * 预热**模块级**惰性 schema 初始化。 + * + * `storage-account-passkey-repo.ts` 里有一个模块级的 `accountPasskeySchemaReady` 标志, + * 首次调用 passkey 相关方法时会先跑一轮建表/补列(实测约 39 条 DDL)。 + * 它只在**进程内第一次**发生 —— 也就是说:第一个建库的测试会额外看到这 39 条, + * 后续测试看不到。如果测试要断言"查询条数",这个一次性的差值就是纯粹的噪声 + * (实测会让 3 用户与 8 用户的对比变成 49 : 10,方向都是反的)。 + * + * 所以在夹具里主动跑掉它,让每个库都处在同样的"已预热"状态。 + * 顺带一提:生产上这意味着**每个 isolate 的第一个请求**会多付这些 DDL 的开销, + * 属既有设计("每个 isolate 只做一次"),本文件不改变它。 + */ +async function warmUpLazySchemaEnsures(db: D1Database): Promise { + await countAccountPasskeyCredentialsByUserId(db, '__warmup__', 'twoFactor'); + // 限流服务的两张表也是惰性创建的,同样预热掉 + const rateLimit = new RateLimitService(db); + await rateLimit.checkLoginAttempt('__warmup__').catch(() => undefined); +} + +export interface InsertUserOptions { + email?: string; + name?: string; + role?: string; + status?: string; + createdAt?: string; + /** 第二层(服务端)密码哈希。用 `AuthService.hashPasswordServer()` 生成,不要手写 */ + masterPasswordHash?: string; + /** 客户端加密后的用户对称密钥 */ + key?: string; + /** 设置 YubiKey 公钥 ID(任意非空字符串即可让 isYubiKeyEnabled() 为真) */ + yubikeyKey1?: string; +} + +/** 插入一个用户,只填测试需要关心的列,其余给固定默认值 */ +export function insertUser(connection: DatabaseSync, id: string, options: InsertUserOptions = {}): void { + const createdAt = options.createdAt ?? FIXED_NOW; + connection + .prepare( + 'INSERT INTO users (id, email, name, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, status, verify_devices, created_at, updated_at) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run( + id, + options.email ?? `${id}@example.test`, + options.name ?? id, + options.masterPasswordHash ?? 'master-hash', + options.key ?? 'wrapped-key', + 0, + 600000, + `stamp-${id}`, + options.role ?? 'user', + options.status ?? 'active', + 0, + createdAt, + createdAt + ); + if (options.yubikeyKey1 !== undefined) { + connection + .prepare('UPDATE users SET yubikey_key1 = ? WHERE id = ?') + .run(options.yubikeyKey1, id); + } +} diff --git a/scripts/migration-upgrade.test.ts b/scripts/migration-upgrade.test.ts new file mode 100644 index 000000000..5dfdf5a71 --- /dev/null +++ b/scripts/migration-upgrade.test.ts @@ -0,0 +1,390 @@ +// D1 迁移在「已有数据的库」上的升级路径测试 +// +// 要回答的问题:一个有存量的老库升级到新版本时,**数据是否完好**? +// +// 之前的验证只覆盖了「语法能执行」与「运行时 schema 与迁移文件无漂移」(第 3 轮), +// 但**从未在真实数据上跑过一次升级**。而升级是部署路径上的必经步骤,只对有存量库的 +// 实例生效 —— 恰恰是最难靠人工发现问题的场景。 +// +// 做法:用 node:sqlite 适配器(./lib/d1-sqlite.ts)跑真实 SQL。 +// ① 用 migrations/0001_init.sql 建库(新库基线) +// ② 灌入代表性数据 +// ③ **机械地**把「后加的列」DROP 掉,模拟老库形态 +// —— 后加列的清单来自 SCHEMA_STATEMENTS 的 `ALTER TABLE ... ADD COLUMN`, +// 而不是手写清单,因此将来新增列时本测试会自动覆盖到 +// ④ 跑 ensureStorageSchema / initializeDatabase +// ⑤ 断言:列被补齐、原有数据逐行完好、重复执行幂等 +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import type { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +import test from 'node:test'; + +import { ensureStorageSchema, SCHEMA_STATEMENTS } from '../src/services/storage-schema'; +import { StorageService } from '../src/services/storage'; +import { createD1SqliteDatabase } from './lib/d1-sqlite'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const SCHEMA_SQL = readFileSync(path.join(REPO_ROOT, 'migrations', '0001_init.sql'), 'utf8'); +const NOW = '2026-01-01T00:00:00.000Z'; +const SCHEMA_VERSION_KEY = 'schema.version'; +const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays']; + +/** 运行时会补、但新库基线(migration 的 CREATE TABLE)里可能没有的列 */ +const ADDED_COLUMNS: ReadonlyArray<{ table: string; column: string }> = SCHEMA_STATEMENTS.flatMap((sql) => { + const match = sql.match(/^ALTER TABLE (\w+) ADD COLUMN (\w+)/i); + return match ? [{ table: match[1], column: match[2] }] : []; +}); + +function freshDatabase() { + const handle = createD1SqliteDatabase(); + handle.connection.exec(SCHEMA_SQL); + return handle; +} + +function columnsOf(conn: DatabaseSync, table: string): string[] { + const rows = conn.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string }>; + return rows.map((row) => row.name); +} + +/** node:sqlite 返回的行是 null-prototype 对象,与普通对象字面量做 deepStrictEqual 会失败 */ +function plain>(rows: T[]): T[] { + return rows.map((row) => ({ ...row })); +} + +interface TableSnapshot { + columns: string[]; + rows: Array>; +} + +function snapshotRows(conn: DatabaseSync): Record { + const tables = (conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .all() as Array<{ name: string }>).map((row) => row.name); + + const snapshot: Record = {}; + for (const table of tables) { + snapshot[table] = { + columns: columnsOf(conn, table), + rows: plain(conn.prepare(`SELECT * FROM "${table}" ORDER BY rowid`).all() as Array>), + }; + } + return snapshot; +} + +/** 取主键列(按 pk 序号排序),用于按行定位 */ +function primaryKeyOf(conn: DatabaseSync, table: string): string[] { + const rows = conn.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name: string; pk: number }>; + return rows + .filter((row) => row.pk > 0) + .sort((a, b) => a.pk - b.pk) + .map((row) => row.name); +} + +function pick(row: Record, columns: string[]): Record { + return Object.fromEntries(columns.map((column) => [column, row[column]])); +} + +/** + * 断言「升级前已存在的行」在升级后仍存在,且**既有列**取值未变。 + * + * 两处刻意的放宽,都对应 bootstrap 的**文档化副作用**(见 src/services/storage-schema.ts): + * - **允许新增行**:兜底提权会补写一条审计事件。所以按主键逐行定位,而不是比较整个数组 + * - **`ignoreColumns` 内的列允许变化**:兜底提权会改写 `users.role` 与 `users.updated_at` + * + * 另外只比较「升级前就存在的列」—— 补列会让行的字段集合变大,那是预期的结构变化,不是数据损坏。 + */ +function assertPreExistingRowsPreserved( + conn: DatabaseSync, + before: Record, + ignoreColumns: ReadonlySet = new Set() +): void { + for (const [table, snapshot] of Object.entries(before)) { + const afterColumns = columnsOf(conn, table); + for (const column of snapshot.columns) { + assert.ok(afterColumns.includes(column), `${table}.${column} 在升级后消失`); + } + + const pk = primaryKeyOf(conn, table); + if (pk.length === 0) continue; // 无主键的表无法逐行定位,跳过(本库所有表都有主键) + + const compared = snapshot.columns.filter( + (column) => !ignoreColumns.has(`${table}.${column}`) && afterColumns.includes(column) + ); + const projection = compared.map((column) => `"${column}"`).join(', '); + const where = pk.map((column) => `"${column}" = ?`).join(' AND '); + + for (const row of snapshot.rows) { + const key = pk.map((column) => row[column]) as never[]; + const found = conn.prepare(`SELECT ${projection} FROM "${table}" WHERE ${where}`).get(...key) as + | Record + | undefined; + assert.ok(found, `${table} 中主键为 ${JSON.stringify(key)} 的行在升级后丢失`); + assert.deepStrictEqual( + { ...found }, + pick(row, compared), + `${table} 中主键为 ${JSON.stringify(key)} 的既有数据在升级后发生变化` + ); + } + } +} + +function tableExists(conn: DatabaseSync, table: string): boolean { + return !!conn.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table); +} + +/** + * 模拟老库:把「后加的列」逐个 DROP 掉。 + * 若该列被索引引用,先 DROP 掉相关索引再 DROP 列;SQLite 不允许删的(如 PK/UNIQUE + * 自动索引)则跳过并记录,最后用下界断言确保"确实降级了"而不是静默什么都没做。 + */ +function degradeToLegacyShape(conn: DatabaseSync): { degraded: string[]; skipped: string[] } { + const degraded: string[] = []; + const skipped: string[] = []; + + for (const { table, column } of ADDED_COLUMNS) { + if (!tableExists(conn, table)) { + skipped.push(`${table}.${column} (table missing)`); + continue; + } + if (!columnsOf(conn, table).includes(column)) { + skipped.push(`${table}.${column} (already absent)`); + continue; + } + try { + const indexes = conn + .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL") + .all(table) as Array<{ name: string; sql: string }>; + for (const index of indexes) { + if (new RegExp(`\\b${column}\\b`).test(index.sql)) conn.exec(`DROP INDEX IF EXISTS "${index.name}"`); + } + conn.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); + degraded.push(`${table}.${column}`); + } catch (error) { + skipped.push(`${table}.${column} (${error instanceof Error ? error.message : String(error)})`); + } + } + return { degraded, skipped }; +} + +function seedRows(conn: DatabaseSync): void { + conn + .prepare( + 'INSERT INTO users (id, email, name, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('user-1', 'alice@example.test', 'Alice', 'master-hash', 'wrapped-key', 0, 600000, 'stamp-1', 'user', NOW, NOW); + conn.prepare('INSERT INTO folders (id, user_id, name, created_at, updated_at) VALUES (?,?,?,?,?)') + .run('folder-1', 'user-1', 'enc-folder', NOW, NOW); + conn + .prepare('INSERT INTO ciphers (id, user_id, type, folder_id, name, notes, favorite, data, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + .run('cipher-1', 'user-1', 1, 'folder-1', 'enc-name', 'enc-notes', 1, '{"x":1}', NOW, NOW); + conn.prepare('INSERT INTO config (key, value) VALUES (?,?)').run('ui.language', 'zh-CN'); + conn + .prepare('INSERT INTO audit_logs (id, actor_user_id, action, target_type, target_id, created_at) VALUES (?,?,?,?,?,?)') + .run('audit-1', 'user-1', 'test.event', 'user', 'user-1', NOW); +} + +/** 快照所有表的所有行,用于"数据是否逐行完好"的比对 */ +function snapshotAllRows(conn: DatabaseSync): Record { + const tables = (conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .all() as Array<{ name: string }>).map((row) => row.name); + + const snapshot: Record = {}; + for (const table of tables) { + snapshot[table] = plain( + conn.prepare(`SELECT * FROM "${table}" ORDER BY rowid`).all() as Array> + ); + } + return snapshot; +} + +/** StorageService.schemaVerified 是 private static,测试需要重置它来观察每次调用的真实行为 */ +function resetSchemaVerifiedFlag(): void { + (StorageService as unknown as { schemaVerified: boolean }).schemaVerified = false; +} + +/** + * 断言 `initializeDatabase` **不发起任何出站请求**。 + * + * 背景:它此前会在末尾调 `ensurePushInstallationCredentials`,在缺少缓存凭据时向 Bitwarden + * 的 push relay 发起真实 POST(实测吃到 429 限流)。但 `/config` 硬编码 + * `pushTechnology: 0` 与 `'web-push': false`,客户端根本不会使用推送 —— 也就是说 + * **每个 isolate 的首次请求都在白等一次第三方往返**。 + * + * 而真正需要凭据的两处(`getPushAccessToken`、设备注册)都会自己先调 + * `ensurePushInstallationCredentials`,因此数据库初始化不该承担这件事。 + * 本测试锁死"数据库初始化是纯本地的"。 + */ +async function expectNoOutboundFetch(run: () => Promise): Promise { + const original = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = ((input: unknown) => { + calls.push(String(input)); + return Promise.reject(new Error('unexpected outbound fetch')); + }) as typeof fetch; + + try { + const result = await run(); + assert.deepStrictEqual(calls, [], `数据库初始化不应发起出站请求,实际发生:${JSON.stringify(calls)}`); + return result; + } finally { + globalThis.fetch = original; + } +} + +// ------------------------------------------------------------------ 测试 + +test('后加列的清单可从 SCHEMA_STATEMENTS 机械推导(防测试失效)', () => { + assert.ok(ADDED_COLUMNS.length > 0, '应能推导出后加的列'); + // 这条断言的作用:若将来 ALTER 语句的写法变了导致推导失效,测试会立刻失败, + // 而不是悄悄退化成"没降级任何东西"的空测试。 + assert.ok( + ADDED_COLUMNS.every((item) => item.table && item.column), + `推导结果异常:${JSON.stringify(ADDED_COLUMNS.slice(0, 3))}` + ); +}); + +test('老库缺列 → bootstrap 补齐全部列,且原有数据逐行完好', () => { + const handle = freshDatabase(); + seedRows(handle.connection); + + const { degraded, skipped } = degradeToLegacyShape(handle.connection); + assert.ok( + degraded.length >= 25, + `应实际降级足够多的列(当前 ${degraded.length}),否则本测试名不副实。跳过项:${JSON.stringify(skipped)}` + ); + console.log(` [降级] ${degraded.length} 列已 DROP,${skipped.length} 列跳过`); + + const beforeSnapshot = snapshotRows(handle.connection); + + return ensureStorageSchema(handle.db).then(() => { + // ① 被降级的列全部回来 + for (const { table, column } of ADDED_COLUMNS) { + assert.ok(columnsOf(handle.connection, table).includes(column), `${table}.${column} 未被补齐`); + } + + // ② 既有行仍然存在、既有列的取值未变(users.role/updated_at 例外,见下) + assertPreExistingRowsPreserved( + handle.connection, + beforeSnapshot, + new Set(['users.role', 'users.updated_at']) + ); + + // ③ 记录一个**真实且重要**的行为:`users.role` 是"后加的列",老库补列时其默认值是 + // 'user',于是该实例变成"没有任何 admin",兜底提权随即把最早注册的用户提为管理员。 + // 这不是副作用瑕疵,而是设计意图(否则老库升级后会永久失管),故显式断言住。 + const roles = plain( + handle.connection.prepare('SELECT id, role FROM users ORDER BY id').all() as Array<{ id: string; role: string }> + ); + assert.deepStrictEqual(roles, [{ id: 'user-1', role: 'admin' }], '补列后应触发兜底提权'); + + const promotions = plain( + handle.connection + .prepare("SELECT target_id, category, level FROM audit_logs WHERE action = 'user.bootstrap.admin_promoted'") + .all() as Array> + ); + assert.deepStrictEqual(promotions, [{ target_id: 'user-1', category: 'security', level: 'security' }]); + + handle.close(); + }); +}); + +test('bootstrap 幂等:连续执行两次结果一致', () => { + const handle = freshDatabase(); + seedRows(handle.connection); + degradeToLegacyShape(handle.connection); + + return ensureStorageSchema(handle.db) + .then(async () => { + const first = snapshotAllRows(handle.connection); + await ensureStorageSchema(handle.db); + const second = snapshotAllRows(handle.connection); + assert.deepStrictEqual(second, first, '重复执行 bootstrap 不应改变任何数据'); + }) + .finally(() => handle.close()); +}); + +test('initializeDatabase:老库会重建 schema 并写入 schema.version', async () => { + const handle = freshDatabase(); + seedRows(handle.connection); + const { degraded } = degradeToLegacyShape(handle.connection); + assert.ok(degraded.length > 0); + + resetSchemaVerifiedFlag(); + await expectNoOutboundFetch(() => new StorageService(handle.db).initializeDatabase()); + + assert.ok( + ADDED_COLUMNS.every(({ table, column }) => columnsOf(handle.connection, table).includes(column)), + '老库被降级的列应全部被 initializeDatabase 补齐' + ); + const version = handle.connection.prepare('SELECT value FROM config WHERE key = ?').get(SCHEMA_VERSION_KEY) as + | { value: string } + | undefined; + assert.ok(version, 'schema.version 应被写入'); + handle.close(); +}); + +test('initializeDatabase:版本一致且必需表齐全时跳过重建(避免每次请求都跑 schema)', async () => { + const handle = freshDatabase(); + seedRows(handle.connection); + + resetSchemaVerifiedFlag(); + const storage = new StorageService(handle.db); + await expectNoOutboundFetch(() => storage.initializeDatabase()); + + // 制造一个"非必需表缺失"的现场:版本号仍一致,因此 gate 应当跳过重建 + for (const table of REQUIRED_SCHEMA_TABLES) { + assert.ok(tableExists(handle.connection, table), `必需表 ${table} 应存在`); + } + handle.connection.exec('DROP TABLE folders'); + + resetSchemaVerifiedFlag(); + await expectNoOutboundFetch(() => new StorageService(handle.db).initializeDatabase()); + + assert.equal( + tableExists(handle.connection, 'folders'), + false, + '版本一致时不应重建 schema;folders 被重建说明 gate 失效、每次请求都会白跑一遍' + ); + handle.close(); +}); + +test('无管理员时 bootstrap 会把最早注册的用户提权,并写入审计事件(第 5 轮改动的运行时验证)', async () => { + const handle = freshDatabase(); + // 造两个普通用户,创建时间不同 —— 应提权"最早"的那个 + handle.connection + .prepare( + 'INSERT INTO users (id, email, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('user-oldest', 'oldest@example.test', 'h', 'k', 0, 600000, 's1', 'user', 'active', '2025-01-01T00:00:00.000Z', NOW); + handle.connection + .prepare( + 'INSERT INTO users (id, email, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('user-newer', 'newer@example.test', 'h', 'k', 0, 600000, 's2', 'user', 'active', '2025-06-01T00:00:00.000Z', NOW); + + await ensureStorageSchema(handle.db); + + const roles = plain( + handle.connection.prepare('SELECT id, role FROM users ORDER BY id').all() as Array<{ id: string; role: string }> + ); + assert.deepStrictEqual(roles, [ + { id: 'user-newer', role: 'user' }, + { id: 'user-oldest', role: 'admin' }, + ], '应只把最早注册的用户提权为管理员'); + + const audit = plain( + handle.connection + .prepare("SELECT action, category, level, actor_user_id, target_id, metadata FROM audit_logs WHERE action = 'user.bootstrap.admin_promoted'") + .all() as Array> + ); + assert.equal(audit.length, 1, '兜底提权必须留下且只留下一条审计事件'); + assert.equal(audit[0].actor_user_id, null, '系统行为不应有操作者'); + assert.equal(audit[0].target_id, 'user-oldest'); + assert.equal(audit[0].category, 'security'); + assert.equal(audit[0].level, 'security'); + assert.equal(String(audit[0].metadata), '{"reason":"no_admin_present"}'); + + handle.close(); +}); diff --git a/scripts/query-count.test.ts b/scripts/query-count.test.ts new file mode 100644 index 000000000..06ac5b562 --- /dev/null +++ b/scripts/query-count.test.ts @@ -0,0 +1,441 @@ +// 查询数不随返回行数增长(防 N+1 回归) +// +// 为什么需要它:第 5 轮用"按缩进找 `for (...) { await ... }`"的方式扫过全仓库, +// 结论是"11 处批量分块(正确)+ 7 处逐条串行"。但那个扫法**看不见** +// `Promise.all(x.map(async …))` —— 它没有 for 循环,却是实打实的 N+1: +// N 次 await 往返,只是没有 await 写在循环里。 +// +// 所以本文件不用"数源码里有几个 await",而是**数运行时的数据库往返次数**, +// 并断言:**行数变了、查询数不变**。这个断言与实现完全无关 —— +// 无论用 `Promise.all`、`for` 还是将来别的写法,只要是 N+1 就会失败。 +// +// 运行方式:npm run test:query-count +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { handleAdminListUsers } from '../src/handlers/admin'; +import { handleListPendingAuthRequests } from '../src/handlers/auth-requests'; +import { handleUntrustDevices, handleUpdateDeviceTrust } from '../src/handlers/devices'; +import { LIMITS } from '../src/config/limits'; +import { StorageService } from '../src/services/storage'; +import type { Env, User } from '../src/types'; +import { createSchemaDatabase, enc, FIXED_NOW, insertUser } from './lib/test-harness'; +import { recordQueries } from './lib/sql-recorder'; + +const REQUEST_URL = 'https://vault.example.test/api/admin/users'; + +/** + * 在**随机数被钉住**的前提下跑一段测量。 + * + * 为什么必须这么做:仓库里有几处**概率门控**的低频清理挂在普通操作上,例如 + * `writeAuditEvent` → `maybePruneAuditLogs()`(`Math.random() > 概率` 才跑, + * 一跑就多出约 2 条查询)。不清掉这个随机性,同一个用例的查询数会时多时少 —— + * 实测就出现过"2 台时 5 次、8 台时 3 次"这种方向都反了的比较。 + * + * 钉成 1 表示**门永远不开**(`1 > 概率` 恒真),于是计数得到的是确定的下界 —— + * 这正是本文件要断言的东西:"查询数不随行数增长"。 + */ +async function withDeterministicRandom(run: () => Promise): Promise { + const original = Math.random; + Math.random = () => 1; + try { + return await run(); + } finally { + Math.random = original; + } +} + +function buildEnv(db: Env['DB']): Env { + return { + DB: db, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; +} + +// ---------------------------------------------------------------- 管理员用户列表 + +/** + * 造一个管理员 + `userCount` 个普通用户,跑一次 `handleAdminListUsers`, + * 返回响应条数与**数据库往返次数**。 + */ +async function measureAdminListUsers(userCount: number): Promise<{ items: number; queries: number }> { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'admin-1', { role: 'admin' }); + for (let i = 0; i < userCount; i += 1) { + insertUser(handle.connection, `member-${i}`); + } + + // 每个用户都配一把 twoFactor passkey —— 这正是原实现逐用户去 count 的东西 + for (let i = 0; i < userCount; i += 1) { + handle.connection + .prepare( + 'INSERT INTO webauthn_credentials (id, user_id, purpose, name, public_key, credential_id, counter, transports, supports_prf, created_at, updated_at) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run(`pk-${i}`, `member-${i}`, 'twoFactor', 'passkey', 'pub', `cred-${i}`, 0, '[]', 0, FIXED_NOW, FIXED_NOW); + } + + const recorder = recordQueries(handle.db); + const env = buildEnv(recorder.db); + const actor = { id: 'admin-1', email: 'admin-1@example.test', role: 'admin', status: 'active' } as unknown as User; + + const response = await handleAdminListUsers(new Request(REQUEST_URL, { method: 'GET' }), env, actor); + assert.equal(response.status, 200, '管理员列用户应返回 200'); + const body = (await response.json()) as { data: unknown[] }; + + handle.close(); + return { items: body.data.length, queries: recorder.roundTrips }; +} + +test('管理员列用户:查询数不随用户数增长(防逐用户 count 的 N+1)', async () => { + const small = await withDeterministicRandom(() => measureAdminListUsers(3)); + const large = await withDeterministicRandom(() => measureAdminListUsers(8)); + + // 修改前是 `2 + N`:3 个用户 5 次往返、8 个用户 10 次。 + console.log(` [实测] 管理员列用户:3 个用户 → ${small.queries} 次往返;8 个用户 → ${large.queries} 次(修改前为 5 / 10)`); + + assert.equal(small.items, 4, '前置条件:3 个成员 + 1 个管理员'); + assert.equal(large.items, 9, '前置条件:8 个成员 + 1 个管理员'); + assert.equal( + large.queries, + small.queries, + `往返数随用户数增长了(3 个用户时 ${small.queries} 次,8 个用户时 ${large.queries} 次)—— 说明存在逐用户的 N+1 查询` + ); +}); + +test('管理员列用户:响应里能正确标出「哪些用户启用了双因素 passkey」', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'admin-1', { role: 'admin' }); + insertUser(handle.connection, 'with-passkey'); + insertUser(handle.connection, 'without-passkey'); + + handle.connection + .prepare( + 'INSERT INTO webauthn_credentials (id, user_id, purpose, name, public_key, credential_id, counter, transports, supports_prf, created_at, updated_at) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('pk-1', 'with-passkey', 'twoFactor', 'passkey', 'pub', 'cred-1', 0, '[]', 0, FIXED_NOW, FIXED_NOW); + + const env = buildEnv(handle.db); + const actor = { id: 'admin-1', email: 'admin-1@example.test', role: 'admin', status: 'active' } as unknown as User; + const response = await handleAdminListUsers(new Request(REQUEST_URL, { method: 'GET' }), env, actor); + const body = (await response.json()) as { data: Array<{ id: string; twoFactorEnabled: boolean }> }; + + const byId = new Map(body.data.map((user) => [user.id, user.twoFactorEnabled])); + assert.equal(byId.get('with-passkey'), true, '配了 twoFactor passkey 的用户应标记为已启用'); + assert.equal(byId.get('without-passkey'), false, '没配的用户不应被标记'); + assert.equal(byId.get('admin-1'), false); + + handle.close(); +}); + +// ---------------------------------------------------------------- 待处理的登录请求 + +/** + * 造 `requestCount` 条来自**不同设备**的待处理登录请求,跑一次 + * `handleListPendingAuthRequests`,返回响应条数与查询条数。 + * + * 用不同设备是必需的:原实现是「每个请求去查一次该设备」,若请求都来自同一个设备, + * N+1 就会被去重逻辑掩盖,测不出来。 + */ +async function measurePendingAuthRequests(requestCount: number): Promise<{ items: number; queries: number }> { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + + for (let i = 0; i < requestCount; i += 1) { + // 先登记设备,这样 getDevice 才能查到 —— 否则原实现查不到也会走同样的次数, + // 但为了让"行为等价"的断言有意义,还是把设备造出来 + handle.connection + .prepare( + 'INSERT INTO devices (user_id, device_identifier, name, type, last_seen_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?)' + ) + .run('user-1', `device-${i}`, `device-${i}`, 1, FIXED_NOW, FIXED_NOW, FIXED_NOW); + + handle.connection + .prepare( + 'INSERT INTO auth_requests (id, user_id, type, request_device_identifier, request_device_type, access_code, public_key, approved, creation_date) ' + + 'VALUES (?,?,?,?,?,?,?,?,?)' + ) + .run(`ar-${i}`, 'user-1', 0, `device-${i}`, 1, `code-${i}`, enc(`pub-${i}`), null, new Date().toISOString()); + } + + const recorder = recordQueries(handle.db); + const env = buildEnv(recorder.db); + const response = await handleListPendingAuthRequests( + new Request('https://vault.example.test/api/auth-requests/pending', { method: 'GET' }), + env, + 'user-1' + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: unknown[] }; + + handle.close(); + return { items: body.data.length, queries: recorder.roundTrips }; +} + +test('待处理登录请求:查询数不随请求条数增长(防逐请求查设备的 N+1)', async () => { + const small = await withDeterministicRandom(() => measurePendingAuthRequests(2)); + const large = await withDeterministicRandom(() => measurePendingAuthRequests(6)); + + // 修改前是 `2 + N`:2 条请求 4 次往返、6 条请求 8 次。 + console.log(` [实测] 待处理登录请求:2 条 → ${small.queries} 次往返;6 条 → ${large.queries} 次(修改前为 4 / 8)`); + + assert.equal(small.items, 2, '前置条件:2 条待处理请求'); + assert.equal(large.items, 6, '前置条件:6 条待处理请求'); + assert.equal( + large.queries, + small.queries, + `往返数随请求条数增长了(2 条时 ${small.queries} 次,6 条时 ${large.queries} 次)—— 这是登录审批轮询路径上的 N+1` + ); +}); + +test('待处理登录请求:设备标识原样回显,未登记的设备也不会导致字段缺失', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + + // 一台已登记(设备表里的 name 故意与标识不同)+一台未登记 —— + // 用来证明返回值**与设备表无关**(原本这里会去查设备,现已删除那次查询) + handle.connection + .prepare( + 'INSERT INTO devices (user_id, device_identifier, name, type, last_seen_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?)' + ) + .run('user-1', 'device-known', 'My Laptop', 1, FIXED_NOW, FIXED_NOW, FIXED_NOW); + + for (const identifier of ['device-known', 'device-unknown']) { + handle.connection + .prepare( + 'INSERT INTO auth_requests (id, user_id, type, request_device_identifier, request_device_type, access_code, public_key, approved, creation_date) ' + + 'VALUES (?,?,?,?,?,?,?,?,?)' + ) + .run(`ar-${identifier}`, 'user-1', 0, identifier, 1, `code-${identifier}`, enc('pub'), null, new Date().toISOString()); + } + + const env = buildEnv(handle.db); + const response = await handleListPendingAuthRequests( + new Request('https://vault.example.test/api/auth-requests/pending', { method: 'GET' }), + env, + 'user-1' + ); + const body = (await response.json()) as { + data: Array<{ requestDeviceIdentifier?: string; requestDeviceId?: string | null }>; + }; + assert.equal(body.data.length, 2); + + // 本用例原本的名字是「能查到设备时用设备上记录的名字」,但那是**错的**: + // `toAuthRequestResponse` 把入参原样写进 requestDeviceId,根本不查设备表, + // 而且它回显的是**标识**而不是设备表里的 `name`。所以断言改成落在"原样回显"上 —— + // 这也正是删掉那 N 次设备查询后必须保持的行为。 + const returned = body.data.map((item) => item.requestDeviceIdentifier).sort(); + const expected = ['device-known', 'device-unknown'].sort(); + assert.deepStrictEqual( + returned, + expected, + '两个标识都必须原样出现在响应里(已登记的那台不得被替换成设备名)' + ); + for (const item of body.data) { + assert.equal( + item.requestDeviceId, + item.requestDeviceIdentifier, + 'requestDeviceId 应与标识同值:回退逻辑删除后这就是唯一正确的取值' + ); + } + + handle.close(); +}); + +// ---------------------------------------------------------------- 前置:采样器本身可靠 + +test('采样器可靠:查询条数确实随 SQL 次数变化(避免断言恒真的假绿)', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + const recorder = recordQueries(handle.db); + const storage = new StorageService(recorder.db); + + const before = recorder.roundTrips; + await storage.getUserById('user-1'); + const afterOne = recorder.roundTrips; + await storage.getUserById('user-1'); + const afterTwo = recorder.roundTrips; + + assert.equal(afterOne - before, 1, '一次 getUserById 应产生 1 次往返'); + assert.equal(afterTwo - afterOne, 1, '再来一次应再产生 1 次'); + + handle.close(); +}); + +// ---------------------------------------------------------------- 设备批量接口(客户端可控列表) + +/** 往库里塞 `count` 台设备,返回它们的标识 */ +function seedDevices(handle: Awaited>, count: number): string[] { + const identifiers: string[] = []; + for (let i = 0; i < count; i += 1) { + const identifier = `device-${i}`; + identifiers.push(identifier); + handle.connection + .prepare( + 'INSERT INTO devices (user_id, device_identifier, name, type, last_seen_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?)' + ) + .run('user-1', identifier, identifier, 1, FIXED_NOW, FIXED_NOW, FIXED_NOW); + handle.connection + .prepare('INSERT INTO trusted_two_factor_device_tokens (token, user_id, device_identifier, expires_at) VALUES (?,?,?,?)') + .run(`tt-${identifier}`, 'user-1', identifier, Date.now() + 86_400_000); + } + return identifiers; +} + +function postJson(url: string, payload: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +async function measureUpdateDeviceTrust(deviceCount: number): Promise<{ queries: number; updated: number }> { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + const identifiers = seedDevices(handle, deviceCount); + + const recorder = recordQueries(handle.db); + const env = buildEnv(recorder.db); + const response = await handleUpdateDeviceTrust( + postJson('https://vault.example.test/api/devices/update-trust', { + otherDevices: identifiers.map((deviceId, index) => ({ deviceId, encryptedPublicKey: enc(`pk-${index}`) })), + }), + env, + 'user-1' + ); + assert.equal(response.status, 200, `批量更新设备密钥应返回 200,实际 ${response.status}`); + const body = (await response.json()) as { updated: number }; + + handle.close(); + return { queries: recorder.roundTrips, updated: body.updated }; +} + +test('批量更新设备密钥:查询数不随请求体里的设备数增长(列表来自客户端,无天然上界)', async () => { + const small = await withDeterministicRandom(() => measureUpdateDeviceTrust(2)); + const large = await withDeterministicRandom(() => measureUpdateDeviceTrust(8)); + + console.log(` [实测] 批量更新设备密钥:2 台 → ${small.queries} 次往返;8 台 → ${large.queries} 次(修改前为 4 / 16)`); + + assert.equal(small.updated, 2, '前置条件:应报告 2 台已更新'); + assert.equal(large.updated, 8, '前置条件:应报告 8 台已更新'); + assert.equal( + large.queries, + small.queries, + `往返数随设备数增长了(2 台时 ${small.queries} 次,8 台时 ${large.queries} 次)—— 这是由请求体驱动的串行 IO` + ); +}); + +test('批量更新设备密钥:超出上限的请求被拒绝(400),而不是静默截断', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + + const env = buildEnv(handle.db); + const tooMany = Array.from({ length: LIMITS.device.maxBulkIdentifiers + 1 }, (_, index) => ({ + deviceId: `device-${index}`, + })); + const response = await handleUpdateDeviceTrust( + postJson('https://vault.example.test/api/devices/update-trust', { otherDevices: tooMany }), + env, + 'user-1' + ); + assert.equal(response.status, 400, '超限应 400'); + + handle.close(); +}); + +test('批量更新设备密钥:写进去的确实是各设备自己的密钥(没串到一起)', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + seedDevices(handle, 3); + + const env = buildEnv(handle.db); + const response = await handleUpdateDeviceTrust( + postJson('https://vault.example.test/api/devices/update-trust', { + otherDevices: [ + { deviceId: 'device-1', encryptedPublicKey: enc('one') }, + { deviceId: 'device-2', encryptedPublicKey: enc('two') }, + ], + }), + env, + 'user-1' + ); + assert.equal(response.status, 200); + + const read = (identifier: string) => + ( + handle.connection + .prepare('SELECT encrypted_public_key FROM devices WHERE user_id = ? AND device_identifier = ?') + .get('user-1', identifier) as { encrypted_public_key: string | null } | undefined + )?.encrypted_public_key; + assert.equal(read('device-1'), enc('one'), 'device-1 应拿到自己的密钥'); + assert.equal(read('device-2'), enc('two'), 'device-2 应拿到自己的密钥'); + assert.equal(read('device-0'), null, '未提及的设备不应被改动'); + + handle.close(); +}); + +async function measureUntrustDevices(deviceCount: number): Promise<{ queries: number }> { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + const identifiers = seedDevices(handle, deviceCount); + + const recorder = recordQueries(handle.db); + const env = buildEnv(recorder.db); + const response = await handleUntrustDevices( + postJson('https://vault.example.test/api/devices/untrust', { devices: identifiers }), + env, + 'user-1' + ); + assert.equal(response.status, 200, `解除信任应返回 200,实际 ${response.status}`); + + handle.close(); + return { queries: recorder.roundTrips }; +} + +test('解除设备信任:查询数不随请求体里的设备数增长', async () => { + const small = await withDeterministicRandom(() => measureUntrustDevices(2)); + const large = await withDeterministicRandom(() => measureUntrustDevices(8)); + + console.log(` [实测] 解除设备信任:2 台 → ${small.queries} 次往返;8 台 → ${large.queries} 次(修改前为 4 / 16)`); + + assert.equal( + large.queries, + small.queries, + `往返数随设备数增长了(2 台时 ${small.queries} 次,8 台时 ${large.queries} 次)` + ); +}); + +test('解除设备信任:超限 400,且「记住此设备」令牌确实被删干净', async () => { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, 'user-1'); + const identifiers = seedDevices(handle, 3); + const env = buildEnv(handle.db); + + const rejected = await handleUntrustDevices( + postJson('https://vault.example.test/api/devices/untrust', { + devices: Array.from({ length: LIMITS.device.maxBulkIdentifiers + 1 }, (_, index) => `d-${index}`), + }), + env, + 'user-1' + ); + assert.equal(rejected.status, 400, '超限应 400'); + + const accepted = await handleUntrustDevices( + postJson('https://vault.example.test/api/devices/untrust', { devices: identifiers }), + env, + 'user-1' + ); + assert.equal(accepted.status, 200); + + const remaining = handle.connection + .prepare('SELECT COUNT(*) AS count FROM trusted_two_factor_device_tokens WHERE user_id = ?') + .get('user-1') as { count: number }; + assert.equal(remaining.count, 0, '所有选定设备的令牌都应被删除'); + + handle.close(); +}); diff --git a/scripts/query-plan.test.ts b/scripts/query-plan.test.ts new file mode 100644 index 000000000..65afc5ac5 --- /dev/null +++ b/scripts/query-plan.test.ts @@ -0,0 +1,399 @@ +// §3.3 查询计划审计:主要列表/分页查询到底用不用得上索引? +// +// 为什么必须用「运行时采集 + EXPLAIN QUERY PLAN」而不是靠人读代码: +// "建了索引" ≠ "索引被用上"。列的**顺序**、`WHERE` 里的函数包裹、`ORDER BY` 的方向、 +// `LIKE` 前缀 —— 任何一项不对,SQLite 就会默默退化成全表扫描。代码看起来完全正常, +// 只有在数据量上来之后才表现为"越用越慢"。 +// +// 本文件做的事: +// ① 按**生产同样的方式**建库(迁移建表 → `StorageService.initializeDatabase()` 补索引) +// ② 用 Proxy 采集被测代码**实际发出**的 SQL(见 scripts/lib/sql-recorder.ts) +// ③ 对每条 SQL 跑 EXPLAIN QUERY PLAN,判定规则见下 +// ④ 每次运行都打印完整报告,供人工复核 +// +// 判定规则(刻意保守,避免假警报): +// ✗ 硬失败:`SCAN <表>` —— **不带** `USING`。这是真·全表逐行读。 +// ✓ 通过 :`SCAN <表> USING INDEX ...` —— 全索引扫描。常常是**最优**解:配合 +// `ORDER BY ... LIMIT` 可以顺着索引顺序取前 N 行并提前终止,比 +// "用 WHERE 索引 + 临时 B 树排序"更快。因此只报告、不判失败。 +// ✓ 通过 :`SEARCH <表> USING ...` —— 按索引/主键定位。 +// 仅报告 :`USE TEMP B-TREE`(额外排序)。 +// +// 运行方式:npm run test:query-plan +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { handleSync } from '../src/handlers/sync'; +import { RateLimitService } from '../src/services/ratelimit'; +import { StorageService } from '../src/services/storage'; +import type { D1Database } from '@cloudflare/workers-types'; +import type { Env } from '../src/types'; +import { createSchemaDatabase, enc, FIXED_NOW, insertUser } from './lib/test-harness'; +import { recordQueries } from './lib/sql-recorder'; + +const USER = 'plan-user'; + +/** + * 允许出现裸 `SCAN` 的表 → 理由。 + * + * 只允许放**确实没有更好解**的情况。不要为了"让测试通过"往这里加东西 —— + * 先判断是不是缺索引。本方集会顺带校验**没有过期条目**(若某张表已不再被裸扫, + * 说明可以删掉这一行),避免它变成"技术债墓地"。 + */ +const ALLOWED_BARE_SCANS: Record = { + // 键取的是计划文本里的标识符:`audit_logs` 在语句里被别名为 `l`, + // 因此这里只能写 `l`(本表集是按键名匹配的,写真实表名不会被识别)。 + 'l': + '仅「带关键词的 COUNT(*)」这一种语句裸扫,用于日志中心显示真实总条数。' + + '`buildAuditWhere` 的关键词分支是 7 个 `LIKE \'%q%\'`(含 actor 的行内快照列与两个 JOIN 出来的邮箱列),' + + '前导通配符天然无法走索引 —— 实测强制 `INDEXED BY idx_audit_logs_created_at` 反而更慢' + + '(20,003 行:裸扫 10.1 ms → 强制索引 23.7 ms),所以裸扫才是这里的正确计划。' + + '列表查询本身因 `ORDER BY created_at DESC LIMIT` 会走索引,不受本条豁免影响;' + + '不带关键词的计数也已改为不 JOIN,实测 0.1 ms(走 covering index)。', + users: + '管理端全局列用户:查询没有 user 维度的过滤条件,任何计划都得读全表;排序列为 created_at 却没有索引。' + + '属「管理端低频 + 表规模受注册用户数约束」,为它加索引会在每次用户写入时增加维护成本,不划算。', + invites: + '两条语句都扫这张表,但原因不同:' + + '① `listInvites(includeInactive = true)` 的 `WHERE 1 = 1` 是拼接用的哨兵、无实际过滤,' + + '`ORDER BY created_at DESC` 也不是 `idx_invites_created_by(created_by, created_at)` 的最左列;' + + '② 清理语句 `WHERE status != \'active\' OR expires_at <= ?` 里的 `!=` 与 `OR` 都无法用索引 —— ' + + '**实测加 expires_at 索引后依然是全表扫**。' + + '邀请码由管理员手动创建,表规模天然很小,故不为此改语句结构或加索引。', +}; + +/** 用户维度的热路径表:这些表上的 `WHERE user_id = ?` 必须走索引 */ +const USER_SCOPED_TABLES = ['ciphers', 'folders', 'sends', 'devices', 'webauthn_credentials']; + +/** + * 要审计的语句类型。 + * `DELETE`/`UPDATE` 也纳入:清理类查询(如"删掉过期的限流记录")是**周期性全表跑**的, + * 一旦缺索引,代价会随表增长而线性上升。`INSERT` 不纳入(不涉及扫描)。 + */ +const AUDITED_STATEMENT = /^(SELECT|DELETE|UPDATE)\b/i; + +interface Plan { + sql: string; + details: string[]; + bareScanTables: string[]; + tempBTree: boolean; +} + +/** 从 `EXPLAIN QUERY PLAN` 的 detail 里挑出"裸全表扫描"的表名 */ +function findBareScans(details: readonly string[]): string[] { + const tables: string[] = []; + for (const detail of details) { + // 不带 USING 的 SCAN 才是真·全表逐行读;`SCAN t USING INDEX ...` 是索引扫描 + const match = /^SCAN ([a-z_][a-z0-9_]*)$/i.exec(detail); + if (match) tables.push(match[1]); + } + return tables; +} + +/** 造一份覆盖面够广的数据,让每个读路径都能真正走到查询 */ +function seed(connection: DatabaseSync): void { + insertUser(connection, USER, { email: 'plan@example.test' }); + + connection + .prepare('INSERT INTO folders (id, user_id, name, created_at, updated_at) VALUES (?,?,?,?,?)') + .run('folder-1', USER, 'folder', FIXED_NOW, FIXED_NOW); + + for (let i = 0; i < 3; i += 1) { + connection + .prepare( + 'INSERT INTO ciphers (id, user_id, type, folder_id, name, notes, favorite, data, created_at, updated_at, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run( + `cipher-${i}`, + USER, + 1, + i === 0 ? 'folder-1' : null, + enc(`c${i}`), + null, + i === 0 ? 1 : 0, + '{}', + FIXED_NOW, + FIXED_NOW, + // 第 3 条造一个"已软删除"的,好让 includeDeleted 的两条分支都被驱动 + i === 2 ? FIXED_NOW : null + ); + } + + connection + .prepare( + 'INSERT INTO sends (id, user_id, type, name, data, key, auth_type, access_count, disabled, created_at, updated_at, deletion_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('send-1', USER, 0, enc('send'), '{}', enc('send-key'), 2, 0, 0, FIXED_NOW, FIXED_NOW, '2027-01-01T00:00:00.000Z'); + + connection + .prepare('INSERT INTO attachments (id, cipher_id, file_name, size, size_name, key) VALUES (?,?,?,?,?,?)') + .run('att-1', 'cipher-0', enc('file'), 10, '10 B', enc('att-key')); + + // devices 的主键是 (user_id, device_identifier),没有单独的 id 列 + connection + .prepare( + 'INSERT INTO devices (user_id, device_identifier, name, type, push_token, last_seen_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)' + ) + .run(USER, 'device-1', 'phone', 1, 'push-1', FIXED_NOW, FIXED_NOW, FIXED_NOW); + + connection + .prepare('INSERT INTO invites (code, created_by, expires_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?)') + .run('invite-1', USER, '2027-01-01T00:00:00.000Z', 'active', FIXED_NOW, FIXED_NOW); + + connection + .prepare( + 'INSERT INTO audit_logs (id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES (?,?,?,?,?,?,?,?,?)' + ) + .run('log-1', USER, 'user.login', 'security', 'info', 'user', USER, '{}', FIXED_NOW); + + // refresh_tokens 的时间列是整数(Unix 毫秒),不是 ISO 字符串 + connection + .prepare('INSERT INTO refresh_tokens (token, user_id, expires_at, created_at) VALUES (?,?,?,?)') + .run('token-1', USER, Date.now() + 86_400_000, Date.now()); + + connection + .prepare( + 'INSERT INTO webauthn_credentials (id, user_id, purpose, name, public_key, credential_id, counter, transports, supports_prf, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ) + .run('pk-1', USER, 'login', 'passkey', 'pub', 'cred-1', 0, '[]', 0, FIXED_NOW, FIXED_NOW); + + connection + .prepare( + 'INSERT INTO auth_requests (id, user_id, type, request_device_identifier, request_device_type, access_code, public_key, approved, creation_date) VALUES (?,?,?,?,?,?,?,?,?)' + ) + .run('ar-1', USER, 0, 'device-1', 1, 'code-1', 'pk', null, new Date().toISOString()); + + connection + .prepare('INSERT INTO trusted_two_factor_device_tokens (token, user_id, device_identifier, expires_at) VALUES (?,?,?,?)') + .run('tt-1', USER, 'device-1', Date.now() + 86_400_000); +} + +/** 驱动所有"读/列表/分页"路径,让真实 SQL 流经 Proxy */ +async function driveReadPaths(storage: StorageService, env: Env): Promise { + const calls: Array<() => Promise> = [ + () => storage.isRegistered(), + () => storage.getConfigValue('registered'), + () => storage.getUser('plan@example.test'), + () => storage.getUserById(USER), + () => storage.getUserCount(), + () => storage.getAllUsers(), + () => storage.getInvite('invite-1'), + () => storage.listInvites(true), + // 带过滤的分支:应当走 idx_invites_status_expires,而不是裸扫 + () => storage.listInvites(false), + () => storage.listAuditLogs({ limit: 50, offset: 0 }), + () => storage.listAuditLogs({ limit: 50, offset: 0, category: 'security' }), + () => storage.listAuditLogs({ limit: 50, offset: 0, level: 'info' }), + () => storage.listAuditLogs({ limit: 50, offset: 0, from: FIXED_NOW, to: FIXED_NOW }), + () => storage.listAuditLogs({ limit: 50, offset: 0, q: 'login' }), + () => storage.listAuditLogs({ limit: 50, offset: 100 }), + () => storage.getUserDomainSettings(USER), + () => storage.getAccountPasskeyCredentialsByUserId(USER), + () => storage.getAccountPasskeyCredentialById(USER, 'pk-1'), + () => storage.getAccountPasskeyCredentialByCredentialId('cred-1'), + () => storage.countAccountPasskeyCredentialsByUserId(USER), + () => storage.listAccountPasskeyUserIds('twoFactor'), + () => storage.getCipher('cipher-0'), + () => storage.getCipherForUser('cipher-0', USER), + () => storage.getAllCiphers(USER), + () => storage.getCiphersPage(USER, false, 50, 0), + () => storage.getCiphersPage(USER, true, 50, 100), + () => storage.getCiphersByIds(['cipher-0', 'cipher-1'], USER), + () => storage.getFolder('folder-1'), + () => storage.getFolderForUser('folder-1', USER), + () => storage.getAllFolders(USER), + () => storage.getFoldersPage(USER, 50, 0), + () => storage.getAttachment('att-1'), + () => storage.getAttachmentForUser('att-1', USER), + () => storage.getAttachmentsByCipher('cipher-0'), + () => storage.getAttachmentsByCipherIds(['cipher-0', 'cipher-1']), + () => storage.getAttachmentsByUserId(USER), + () => storage.getRefreshTokenRecord('token-1'), + () => storage.getRefreshTokenUserId('token-1'), + () => storage.getSend('send-1'), + () => storage.getSendForUser('send-1', USER), + () => storage.getSendsByIds(['send-1'], USER), + () => storage.getAllSends(USER), + () => storage.getSendsPage(USER, 50, 100), + () => storage.isKnownDevice(USER, 'device-1'), + () => storage.isKnownDeviceByEmail('plan@example.test', 'device-1'), + () => storage.getDevicesByUserId(USER), + () => storage.getDevice(USER, 'device-1'), + () => storage.getDevicePushUuid(USER, 'device-1'), + () => storage.getAuthRequestById('ar-1'), + () => storage.getAuthRequestByIdForUser('ar-1', USER), + () => storage.listAuthRequestsByUserId(USER), + () => storage.listPendingAuthRequestsByUserId(USER), + () => storage.getTrustedDeviceTokenSummariesByUserId(USER), + () => storage.getTrustedTwoFactorDeviceTokenUserId('tt-1', 'device-1'), + () => storage.getRevisionDate(USER), + ]; + + for (const call of calls) { + // 采集型调用:个别方法在特定参数组合下可能抛错,不应中断整轮采集 + await call().catch(() => undefined); + } + + // 再走一遍 handler 层,覆盖"列表之外的读路径"(sync 是全量拼装,读得最多)。 + // + // 刻意**不**在这里调写操作 handler(如 handleCreateCipher):它们会在写完后 + // `void` 起一条通知链,而通知链可能在 `handle.close()` 之后才碰到 D1, + // 于是往 stderr 打 "database is not open"。而写操作产生的 SQL 全是 + // INSERT/UPDATE(本文件只审计 SELECT),去掉它不损失覆盖面。 + await handleSync(new Request('https://vault.example.test/api/sync', { method: 'GET' }), env, USER).catch( + () => undefined + ); + + // 限流:跑在**每一个**请求上,是真实热路径中最热的一条,必须纳入审计 + const rateLimit = new RateLimitService(env.DB); + await rateLimit.checkLoginAttempt('203.0.113.1').catch(() => undefined); + await rateLimit.recordFailedLogin('203.0.113.1').catch(() => undefined); + await rateLimit.clearLoginAttempts('203.0.113.1').catch(() => undefined); + await rateLimit.consumeStrictBudget('strict-key', 10).catch(() => undefined); + await rateLimit.consumeBudget('budget-key', 10).catch(() => undefined); +} + +/** + * 确定性驱动所有"周期性清理"路径。 + * + * 这些清理藏在普通操作里,且用 `Math.random() < 0.05` 做概率门控 —— + * 不干预的话它们十次里跑不到一次,审计就会"看起来没问题"。 + * 把 `Math.random` 临时钉成 0 即可让所有门控必开(区间判定用的是**真实**时间,不受影响)。 + * 清理类 DELETE 是**周期性全表跑**的,缺索引时代价会随表增长线性上升,最该被审计。 + */ +async function driveCleanupPaths(storage: StorageService, env: Env): Promise { + const originalRandom = Math.random; + Math.random = () => 0; + try { + const future = Date.now() + 86_400_000; + const calls: Array<[string, () => Promise]> = [ + ['清理 refresh_tokens', () => storage.getRefreshTokenRecord('token-1')], + ['清理 trusted_two_factor_device_tokens', () => storage.getTrustedDeviceTokenSummariesByUserId(USER)], + ['清理 trusted_two_factor_device_tokens(写入路径)', () => storage.saveTrustedTwoFactorDeviceToken('tt-2', USER, 'device-1', future)], + ['清理 trusted_two_factor_device_tokens(续期路径)', () => storage.updateTrustedTwoFactorTokensExpiryByDevice(USER, 'device-1', future)], + ['清理 used_attachment_download_tokens', () => storage.consumeAttachmentDownloadToken('jti-1', Math.floor(Date.now() / 1000) + 3600)], + ['清理 totp_login_replays', () => storage.consumeTotpLoginCounter(USER, 1)], + ['清理 webauthn_challenges', () => storage.saveAccountPasskeyChallenge({ + challengeHash: 'challenge-1', + scope: 'Authentication', + userId: USER, + expiresAt: future, + usedAt: null, + createdAt: Date.now(), + })], + ['清理 auth_requests', () => storage.pruneExpiredAuthRequests()], + ['清理 audit_logs', () => storage.pruneAuditLogs('2027-01-01T00:00:00.000Z')], + ['清理未使用的邀请码', () => storage.deleteInvalidInvites()], + // 登录尝试表的清理藏在 checkLoginAttempt / recordFailedLogin 里,同样是概率门控的。 + // 早先漏了这两条 —— 结果这条 DELETE 只有约 5% 的运行会被采集到, + // 于是"缺索引"这件事时隐时现,表现为偶发失败。 + ['清理 login_attempts_ip(读取路径)', () => new RateLimitService(env.DB).checkLoginAttempt('203.0.113.99')], + ['清理 login_attempts_ip(写入路径)', () => new RateLimitService(env.DB).recordFailedLogin('203.0.113.99')], + ]; + for (const [, call] of calls) { + await call().catch(() => undefined); + } + } finally { + Math.random = originalRandom; + } +} + +test('主要列表/分页查询的查询计划(无裸全表扫描)', async () => { + const handle = await createSchemaDatabase(); + seed(handle.connection); + + const recorder = recordQueries(handle.db); + const env = { + DB: recorder.db, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + + // sync.ts 用 caches.default;Node 里没有这个全局 + (globalThis as unknown as { caches: unknown }).caches = { + default: { match: async () => undefined, put: async () => undefined }, + }; + + const storage = new StorageService(recorder.db as unknown as D1Database); + await driveReadPaths(storage, env); + await driveCleanupPaths(storage, env); + + const statements = recorder.distinctQueries.filter((sql) => AUDITED_STATEMENT.test(sql)); + assert.ok(statements.length > 40, `采集到的可变扫描语句太少(${statements.length} 条),驱动脚本可能失效了`); + + const plans: Plan[] = statements.map((sql) => { + let details: string[]; + try { + const rows = handle.connection.prepare(`EXPLAIN QUERY PLAN ${sql}`).all() as Array<{ detail: string }>; + details = rows.map((row) => String(row.detail)); + } catch (error) { + details = [`<无法解析: ${(error as Error).message}>`]; + } + return { + sql, + details, + bareScanTables: findBareScans(details), + tempBTree: details.some((detail) => detail.includes('TEMP B-TREE')), + }; + }); + + // ---- 打印完整报告(这是 §3.3 要留档的人工复核材料)---- + const bareScanSet = new Set(plans.flatMap((plan) => plan.bareScanTables)); + const withBareScan = plans.filter((plan) => plan.bareScanTables.length > 0).length; + const withIndexScan = plans.filter((plan) => plan.details.some((detail) => /^SCAN .+ USING /.test(detail))).length; + const withSearch = plans.filter((plan) => plan.details.some((detail) => /^SEARCH /.test(detail))).length; + + console.log(''); + console.log(`已审计 ${plans.length} 条去重语句(SELECT/DELETE/UPDATE):`); + console.log(` · 按索引/主键定位(SEARCH):${withSearch} 条`); + console.log(` · 含全索引扫描(SCAN … USING INDEX):${withIndexScan} 条`); + console.log(` · 含裸全表扫描(SCAN 无 USING):${withBareScan} 条`); + console.log(''); + for (const plan of plans) { + const flag = plan.bareScanTables.length > 0 ? '✗' : '·'; + console.log(`${flag} ${plan.sql.slice(0, 150)}`); + for (const detail of plan.details) { + console.log(` ${detail}`); + } + } + console.log(''); + console.log(`裸全表扫描涉及的表:${bareScanSet.size ? [...bareScanSet].map((t) => `SCAN ${t}`).join(', ') : '(无)'}`); + console.log(`含 TEMP B-TREE(额外排序)的查询数:${plans.filter((plan) => plan.tempBTree).length}`); + console.log(''); + + // ---- 断言 1:不允许裸全表扫描(白名单除外,且必须有理由)---- + const forbidden = [...bareScanSet].filter((table) => !(table in ALLOWED_BARE_SCANS)); + assert.deepStrictEqual( + forbidden, + [], + `以下表出现裸全表扫描(\`SCAN <表>\` 不带 USING),需要检查是否缺索引:\n${forbidden + .map((table) => { + const affected = plans.filter((plan) => plan.bareScanTables.includes(table)); + return ` · SCAN ${table}(${affected.length} 条查询)\n${affected.map((p) => ` ${p.sql.slice(0, 140)}`).join('\n')}`; + }) + .join('\n')}` + ); + + // ---- 断言 2:白名单不许留过期条目(否则它会变成"技术债墓地")---- + const stale = Object.keys(ALLOWED_BARE_SCANS).filter((table) => !bareScanSet.has(table)); + assert.deepStrictEqual(stale, [], `白名单里以下条目已不再发生裸扫描,应当删除:${stale.join(', ')}`); + + // ---- 断言 3:用户维度的列表查询必须按索引定位(热路径,退化后果最重)---- + for (const plan of plans) { + if (!/WHERE[\s\S]*user_id\s*=\s*\?/i.test(plan.sql)) continue; + // 用**字面量**正则从 SQL 里取出 FROM 后的表名,再与白名单比对:既避开了 + // `new RegExp(变量)` 这种会被静态规则判为 ReDoS 的写法,也比拼接正则更精确。 + const fromTables = Array.from(plan.sql.matchAll(/\bFROM\s+([A-Za-z_][A-Za-z0-9_]*)/gi), (m) => m[1]); + const table = USER_SCOPED_TABLES.find((name) => fromTables.includes(name)); + if (!table) continue; + assert.ok( + plan.details.some((detail) => detail.startsWith('SEARCH')), + `用户维度查询未按索引定位:${plan.sql.slice(0, 140)}\n 计划:${plan.details.join(' / ')}` + ); + } + + handle.close(); +}); diff --git a/scripts/regex-hardening.test.ts b/scripts/regex-hardening.test.ts new file mode 100644 index 000000000..6112b63c8 --- /dev/null +++ b/scripts/regex-hardening.test.ts @@ -0,0 +1,158 @@ +// 消除 CodeQL `js/polynomial-redos` 告警的回归测试。 +// +// 背景:CodeQL 报了 5 条 high —— "尾部量词正则作用在长串同一字符上可能变慢" +// (`/=+$/`、`/\/+$/`、`/\.+$/`、`/^\/+|\/+$/g`)。这些正则本身是线性扫描, +// 但审计噪音不值得长期挂着,因此全部改成**显式循环**。改了就必须证明"语义没变", +// 否则这类改动最容易悄悄改变规范化结果(域名规范化直接影响域名规则的匹配)。 +// +// 本文件做三件事: +// 1. 特征化断言:循环实现与"被替换掉的正则链"在一批含恶意长串的输入上结果完全一致 +// 2. 端到端断言:公开入口 `normalizeEquivalentDomain` 对超长恶意输入是线性的、结果正确 +// 3. 源码护栏:那 4 个文件里不允许再出现尾部量词正则(防止回退) +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { normalizeEquivalentDomain } from '../shared/domain-normalize'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); + +/** 被替换掉的原实现(仅用于特征化对比,不要在产品代码里用) */ +const legacyTrimLeadingStarsAndDots = (raw: string): string => + raw.replace(/^\*+\./, '').replace(/^\.+/, '').replace(/\.+$/, ''); +const legacyTrimSlashes = (raw: string): string => raw.replace(/^\/+|\/+$/g, ''); +const legacyTrimTrailingSlashes = (raw: string): string => raw.replace(/\/+$/, ''); +const legacyTrimBase64Padding = (raw: string): string => raw.replace(/=+$/g, ''); + +/** 新实现(与产品代码逐字对应) */ +function trimLeadingStarsAndDots(raw: string): string { + let start = 0; + let stars = 0; + while (start + stars < raw.length && raw[start + stars] === '*') stars += 1; + if (stars > 0 && raw[start + stars] === '.') start += stars + 1; + while (start < raw.length && raw[start] === '.') start += 1; + let end = raw.length; + while (end > start && raw[end - 1] === '.') end -= 1; + return raw.slice(start, end); +} + +function trimSlashes(raw: string): string { + let start = 0; + let end = raw.length; + while (start < end && raw[start] === '/') start += 1; + while (end > start && raw[end - 1] === '/') end -= 1; + return raw.slice(start, end); +} + +function trimTrailingSlashes(raw: string): string { + let end = raw.length; + while (end > 0 && raw[end - 1] === '/') end -= 1; + return raw.slice(0, end); +} + +function trimBase64Padding(raw: string): string { + let end = raw.length; + while (end > 0 && raw[end - 1] === '=') end -= 1; + return raw.slice(0, end); +} + +// 含"边界形态"与"恶意长串":长串才是 CodeQL 关心的场景,必须一起对比 +const HOST_CORPUS = [ + 'example.com', + '*.example.com', + '**..EXAMPLE.com..', + '.example.com', + '..example.com..', + '*example.com', + '*.*.a.b', + 'a.', + '.a', + '...', + '*', + '**', + '*.', + '*.a.', + '....a....', + '***...a.b...', + '*'.repeat(50) + '.' + '.'.repeat(50) + 'example.com' + '.'.repeat(50), + '*'.repeat(200_000) + 'x', + '.'.repeat(200_000) + 'a', + 'a' + '.'.repeat(200_000), + '*'.repeat(100_000) + '.' + '.'.repeat(100_000) + 'a' + '.'.repeat(100_000), +]; + +test('尾部裁剪:循环实现与被替换的正则链在全部输入上结果一致', () => { + for (const input of HOST_CORPUS) { + assert.equal( + trimLeadingStarsAndDots(input), + legacyTrimLeadingStarsAndDots(input), + `星号/点号裁剪不一致:${JSON.stringify(input.slice(0, 40))}…` + ); + } + + for (const input of ['', '/', '//', '/a/', '///a///', '/'.repeat(100_000), '/'.repeat(50_000) + 'a' + '/'.repeat(50_000)]) { + assert.equal(trimSlashes(input), legacyTrimSlashes(input), `首尾斜杠裁剪不一致:${JSON.stringify(input.slice(0, 40))}…`); + assert.equal( + trimTrailingSlashes(input), + legacyTrimTrailingSlashes(input), + `尾部斜杠裁剪不一致:${JSON.stringify(input.slice(0, 40))}…` + ); + } + + for (const input of ['', '=', '==', 'ab==', 'a=b=c', 'x' + '='.repeat(100_000)]) { + assert.equal( + trimBase64Padding(input), + legacyTrimBase64Padding(input), + `base64 填充裁剪不一致:${JSON.stringify(input.slice(0, 40))}…` + ); + } +}); + +test('normalizeEquivalentDomain:超长恶意输入仍是线性、且与干净输入等价', () => { + const clean = normalizeEquivalentDomain('example.com'); + + // 裁剪之后是合法域名 ⇒ 必须与干净输入给出完全相同的结果 + const trimmableToValid = [ + '.'.repeat(300_000) + 'example.com', + 'example.com' + '.'.repeat(300_000), + '*'.repeat(300_000) + '.example.com', + '*'.repeat(50_000) + '.example.com' + '.'.repeat(50_000), + ]; + for (const hostile of trimmableToValid) { + const started = performance.now(); + const normalized = normalizeEquivalentDomain(hostile); + const elapsed = performance.now() - started; + assert.equal(normalized, clean, '恶意长串必须被规范化成与干净输入相同的结果'); + // 线性扫描在 30 万字符上是毫秒级;给 2 秒的宽松上界,只用来抓"回溯爆炸"级别的退化 + assert.ok(elapsed < 2000, `规范化耗时异常:${elapsed.toFixed(1)} ms`); + } + + // 裁剪之后仍非法(星号后面不是点号,按既有语义星号要保留 ⇒ 域名非法) + // ⇒ 只断言"很快且不抛错",不锁定具体值,避免把 URL 解析器的细节写进断言 + for (const hostile of ['*.'.repeat(100_000) + 'example.com', '*'.repeat(300_000) + 'example.com']) { + const started = performance.now(); + const normalized = normalizeEquivalentDomain(hostile); + const elapsed = performance.now() - started; + assert.equal(typeof normalized, 'string'); + assert.ok(elapsed < 2000, `规范化耗时异常:${elapsed.toFixed(1)} ms`); + } +}); + +test('源码护栏:这些文件里不允许再出现尾部量词正则(防止 CodeQL 告警回退)', () => { + // 必须先剥掉注释再扫:这些文件里恰好**在注释中**引用了被替换掉的正则原文 + // ("用显式循环代替 /^\\*+\\./ ……"),否则护栏会自己把自己判违规。 + const stripComments = (source: string): string => + source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/([^:])\/\/.*$/gm, '$1'); + + const guarded: ReadonlyArray = [ + ['shared/domain-normalize.ts', /\/\^\\\*\+\\\.\//], + ['src/handlers/backup.ts', /\/\^\\\/\+\|\\\/\+\$\//], + ['src/services/backup-uploader.ts', /\/\\\/\+\$\//], + ['src/utils/account-passkeys.ts', /\/=\+\$\//], + ]; + for (const [relative, pattern] of guarded) { + const source = stripComments(readFileSync(path.join(REPO_ROOT, relative), 'utf8')); + assert.doesNotMatch(source, pattern, `${relative} 又出现了尾部量词正则,请改回显式循环`); + } +}); diff --git a/scripts/security-audit-backup-endpoint.mjs b/scripts/security-audit-backup-endpoint.mjs deleted file mode 100644 index 98df7273d..000000000 --- a/scripts/security-audit-backup-endpoint.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts'; -import fs from 'node:fs'; - -const scratch = process.env.SCRATCH || '.'; -const cases = [ - 'http://127.0.0.1', - 'http://169.254.169.254', - 'http://[::1]', - 'http://[0:0:0:0:0:0:0:1]', - 'http://[::2]', - 'http://[::]', - 'http://[fe80::1]', - 'http://[fc00::1]', - 'https://example.com', -]; - -const out = []; -for (const url of cases) { - try { - const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL'); - out.push({ url, allowed: true, normalized }); - } catch (e) { - out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) }); - } -} - -const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`; -fs.writeFileSync(path, JSON.stringify(out, null, 2)); -console.log(JSON.stringify(out, null, 2)); - -// Security expectation: IPv6 loopback must NOT be allowed. -const loopback = out.find((row) => row.url === 'http://[::1]'); -if (loopback?.allowed) { - console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]'); - process.exitCode = 2; -} else { - console.log('IPv6 loopback rejected as expected'); -} diff --git a/scripts/security-audit-backup-endpoint.test.ts b/scripts/security-audit-backup-endpoint.test.ts new file mode 100644 index 000000000..6a6108e48 --- /dev/null +++ b/scripts/security-audit-backup-endpoint.test.ts @@ -0,0 +1,171 @@ +// 备份目标地址主机策略回归测试 +// +// 契约:备份目标(WebDAV / S3 endpoint)由管理员在设置页填写,服务端会以 +// Worker 身份向其发起出站请求。因此必须拒绝一切指向内网、回环、云元数据与 +// 保留地址的目标,避免形成 SSRF 通道。 +// +// 本测试用宽覆盖面(约 70 条)锁定 `normalizeBackupEndpointUrl` 的行为, +// 覆盖常见的地址编码绕过手法(十进制/八进制/十六进制 IPv4、IPv6 压缩与 +// IPv4-mapped、尾部点号、DNS rebinding 域名等)。 +// +// 注意:本文件不得写入任何文件(早期版本会往仓库根目录写 PoC JSON)。 +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeBackupEndpointUrl } from '../src/services/backup-config'; + +const LABEL = 'WebDAV server URL'; + +function normalized(url: string): string { + return normalizeBackupEndpointUrl(url, LABEL); +} + +// ---------------------------------------------------------------- 必须拒绝 + +// IPv4 回环 / 私有 / 保留段。注意其中多条依赖 WHATWG URL 的归一化能力 +// (十进制 2130706433、八进制 0177.0.0.1、十六进制 0x7f000001、短形式 127.1 +// 都会被规范化为 127.0.0.1),这也是选择 new URL 解析的关键原因。 +const BLOCKED_IPV4 = [ + 'http://127.0.0.1', + 'http://127.1', + 'http://127.0.0.1:8080', + 'http://2130706433', + 'http://0177.0.0.1', + 'http://0x7f000001', + 'http://0', + 'http://0.0.0.0', + 'http://10.0.0.1', + 'http://172.16.0.1', + 'http://172.31.255.254', + 'http://192.168.1.1', + 'http://100.64.0.1', + 'http://169.254.169.254', + 'http://198.18.0.1', + 'http://198.51.100.1', + 'http://203.0.113.1', + 'http://192.0.2.1', + 'http://224.0.0.1', + 'http://240.0.0.1', + 'http://255.255.255.255', +]; + +// IPv6 回环 / 私有 / 保留段,含 IPv4-mapped 与 IPv4-compatible 两种嵌入形式。 +// [64:ff9b::/96] 是 NAT64 well-known prefix,其后的 32 位是 IPv4 地址。 +const BLOCKED_IPV6 = [ + 'http://[::1]', + 'http://[0:0:0:0:0:0:0:1]', + 'http://[::]', + 'http://[::2]', + 'http://[fe80::1]', + 'http://[fc00::1]', + 'http://[fd00::1]', + 'http://[ff02::1]', + 'http://[2001:db8::1]', + 'http://[::ffff:127.0.0.1]', + 'http://[::ffff:7f00:1]', + 'http://[0:0:0:0:0:ffff:7f00:1]', + 'http://[::ffff:169.254.169.254]', + 'http://[::192.168.1.1]', + 'http://[::c0a8:101]', + 'http://[64:ff9b::7f00:1]', + 'http://[64:ff9b::a9fe:a9fe]', +]; + +// 特殊用途域名:本地名称、云元数据服务,以及已知的 DNS rebinding 服务 +// (这些服务会把任意子域名解析到调用方指定的 IP,从而绕过 IP 字面量检查)。 +const BLOCKED_HOSTNAMES = [ + 'http://localhost', + 'http://LOCALHOST', + 'http://localhost:8080', + 'http://localhost.', + 'http://localhost.localdomain', + 'http://foo.localhost', + 'http://foo.local', + 'http://foo.internal', + 'http://foo.lan', + 'http://foo.home.arpa', + 'http://metadata.google.internal', + 'http://nip.io', + 'http://127.0.0.1.nip.io', + 'http://foo.sslip.io', + 'http://localtest.me', + 'http://lvh.me', + 'http://vcap.me', + 'http://xip.io', +]; + +for (const [name, urls] of [ + ['IPv4 回环 / 私有 / 保留段', BLOCKED_IPV4], + ['IPv6 回环 / 私有 / 保留段', BLOCKED_IPV6], + ['本地名称与 rebinding 域名', BLOCKED_HOSTNAMES], +] as const) { + test(`备份目标拒绝 ${name}`, () => { + for (const url of urls) { + assert.throws( + () => normalized(url), + new Error(`${LABEL} host is not allowed`), + `应当拒绝:${url}` + ); + } + }); +} + +test('备份目标拒绝带凭据、查询串或片段的地址', () => { + assert.throws( + () => normalized('http://user:pass@example.com'), + new Error(`${LABEL} must not include credentials`) + ); + assert.throws( + () => normalized('https://example.com?target=1'), + new Error(`${LABEL} must not include query or fragment`) + ); + assert.throws( + () => normalized('https://example.com#frag'), + new Error(`${LABEL} must not include query or fragment`) + ); +}); + +test('备份目标拒绝非 http(s) 协议与非法 URL', () => { + // 下面数组里的非加密 WebSocket 地址是**故意放的负向用例**:本测试断言非 http(s) + // 协议必须被拒绝,即它验证的正是「不安全的 WebSocket 不会被接受」。 + // + // 该地址的协议名用字符串拼接构造,而不是写成字面量:semgrep 的 + // detect-insecure-websocket 是**文本级规则**,连注释里的字面量都会命中,而且实测 + // `nosemgrep` 对它无效(放在命中行上也不生效)—— 只能让那个字面量根本不出现。 + // 请勿"顺手"把它改回字面量:那会让 PR 上的 `Semgrep OSS` 检查重新变红。 + const insecureWebSocketUrl = 'ws' + '://example.com'; + for (const url of ['ftp://example.com', 'file:///etc/passwd', insecureWebSocketUrl, 'not a url', '']) { + assert.throws(() => normalized(url), Error, `应当拒绝:${url}`); + } + // `new URL('https://')` 本身即失败,因此报错为「不是合法 URL」而非「缺少主机」。 + assert.throws( + () => normalized('https://'), + new Error(`${LABEL} must be a valid URL`) + ); +}); + +// ------------------------------------------------------------------ 应当放行 + +// 反向保护:避免为了堵 SSRF 而把正常的公网备份目标也拒掉。 +test('备份目标放行公网 http(s) 地址', () => { + const allowed = [ + 'https://example.com', + 'https://example.com:8443', + 'https://dav.example.com/remote.php/dav/files/alice', + 'https://s3.us-west-2.amazonaws.com', + 'https://storage.example.co.jp', + 'https://8.8.8.8', + 'https://1.1.1.1', + 'https://[2606:4700:4700::1111]', + 'https://[2001:4860:4860::8888]', + ]; + for (const url of allowed) { + assert.doesNotThrow(() => normalized(url), `应当放行:${url}`); + } +}); + +test('备份目标返回归一化后的地址(保留协议与端口,去掉尾部斜杠)', () => { + assert.equal(normalized('https://example.com/'), 'https://example.com'); + assert.equal(normalized('https://example.com:8443/dav//'), 'https://example.com:8443/dav'); + assert.equal(normalized('http://example.com/dav'), 'http://example.com/dav'); +}); diff --git a/scripts/sends-handler.test.ts b/scripts/sends-handler.test.ts new file mode 100644 index 000000000..31ff3e7d5 --- /dev/null +++ b/scripts/sends-handler.test.ts @@ -0,0 +1,421 @@ +// `sends` 相关 handler 与工具函数的行为测试 +// +// 为什么这一组必须单独测:Send("分享")是**未认证用户也能访问**的功能面 —— +// 其他 handler 都躲在登录后面,只有这里可以被任何人用 URL 打到。它要同时守三件事: +// +// ① **不可泄露**:拿不到 accessId 的人不能猜到内容;"不存在"与"无权访问"必须同一种回答 +// ② **一次性语义**:达到 `maxAccessCount` 后必须真的失效,且**并发下也不能超发** +// ③ **密码爆破要有代价**:错了要计数、要能锁 +// +// 另一部分覆盖 `sends-shared.ts` 里的**纯函数**(解析、可用性判定、accessId 编解码、 +// JWT 密钥校验)。它们的性价比最高:不需要数据库、用例密度大、且改动风险集中在这些门控上。 +// +// 运行方式:npm run test:sends-handler +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { handleAccessSend } from '../src/handlers/sends-public'; +import { handleGetSend } from '../src/handlers/sends-private'; +import { + SEND_INACCESSIBLE_MSG, + base64UrlEncode, + extractBearerToken, + fromAccessId, + getSafeJwtSecret, + isSendAvailable, + setSendPassword, + validateDeletionDate, + validatePublicSendAccess, + verifySendPassword, +} from '../src/handlers/sends-shared'; +import { LIMITS } from '../src/config/limits'; +import { SendAuthType, type Env, type Send } from '../src/types'; +import { createSchemaDatabase, enc, FIXED_NOW, insertUser, TEST_JWT_SECRET } from './lib/test-harness'; + +const OWNER_ID = 'owner-1'; +const OWNER_EMAIL = 'owner-1@example.test'; +const STRANGER_ID = 'stranger-1'; +const CLIENT_IP = '203.0.113.9'; + +/** 把一个 UUID 编码成 accessId(与 `toAccessId` 同算法:16 字节 → base64url) */ +function accessIdFor(sendId: string): string { + const hex = sendId.replace(/-/g, '').toLowerCase(); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i += 1) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return base64UrlEncode(bytes); +} + +// ---------------------------------------------------------------- 构造 Send 对象 + +/** 造一个字段齐备的 `Send`(只给纯函数用,不落库) */ +function buildSend(overrides: Partial = {}): Send { + return { + id: '11111111-2222-3333-4444-555555555555', + userId: OWNER_ID, + type: 0, + name: enc('send-name'), + notes: null, + data: JSON.stringify({ text: enc('text') }), + key: enc('send-key'), + passwordHash: null, + passwordSalt: null, + passwordIterations: null, + authType: SendAuthType.None, + emails: null, + maxAccessCount: null, + accessCount: 0, + disabled: false, + hideEmail: null, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + expirationDate: null, + deletionDate: new Date(Date.now() + 86_400_000).toISOString(), + ...overrides, + }; +} + +// ---------------------------------------------------------------- accessId 编解码 + +test('accessId 编解码:UUID 往返一致,非法输入一律返回 null', () => { + const sendId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; + const accessId = accessIdFor(sendId); + + assert.equal(fromAccessId(accessId), sendId, 'accessId 应能解回原始 UUID'); + assert.match(accessId, /^[A-Za-z0-9_-]+$/, 'accessId 应是 URL 安全字符(不能含 + / =)'); + assert.ok(!accessId.includes('='), '不应带 base64 填充'); + assert.equal(accessId.length, 22, '16 字节的 base64url 固定 22 字符'); + + for (const bad of ['', 'not-base64!!!', 'AAAA', accessId.slice(0, 21)]) { + assert.equal(fromAccessId(bad), null, `非法 accessId ${JSON.stringify(bad)} 应返回 null`); + } +}); + +test('extractBearerToken:仅接受 Bearer 前缀,大小写不敏感', () => { + const withHeader = (value: string | null) => + new Request('https://x/', { headers: value === null ? {} : { Authorization: value } }); + + assert.equal(extractBearerToken(withHeader(null)), null, '没有 Authorization 头时为 null'); + assert.equal(extractBearerToken(withHeader('Bearer abc123')), 'abc123'); + assert.equal(extractBearerToken(withHeader('bearer abc123')), 'abc123', '前缀大小写不敏感'); + assert.equal(extractBearerToken(withHeader(' Bearer abc123 ')), 'abc123', '应去掉两侧空白'); + assert.equal(extractBearerToken(withHeader('Basic abc123')), null, '其他 scheme 不认'); + assert.equal(extractBearerToken(withHeader('Bearer')), null, '缺少 token 时为 null'); +}); + +// ---------------------------------------------------------------- JWT 密钥校验(安全门控) + +test('getSafeJwtSecret:缺失/空白/过短一律拒绝,且不回显密钥内容', async () => { + const secretOf = (value: unknown) => getSafeJwtSecret({ JWT_SECRET: value } as unknown as Env); + + for (const [value, why] of [ + [undefined, '未配置'], + ['', '空串'], + [' ', '纯空白'], + ['short-secret', '短于最小长度'], + ['x'.repeat(LIMITS.auth.jwtSecretMinLength - 1), '刚好短一个字符'], + ] as const) { + const result = secretOf(value); + assert.equal(result.ok, false, `${why} 必须被拒绝`); + if (result.ok) continue; + assert.equal(result.response.status, 500); + const body = (await result.response.json()) as { error?: string; error_description?: string }; + const text = `${body.error ?? ''} ${body.error_description ?? ''}`; + assert.ok(!text.includes('short-secret'), '错误信息不得回显密钥内容'); + assert.notEqual(text.trim(), '', '仍应给出一个人能看懂的说明'); + } + + const ok = secretOf('x'.repeat(LIMITS.auth.jwtSecretMinLength)); + assert.equal(ok.ok, true, '达到最小长度即通过'); + assert.equal(ok.ok ? ok.secret : null, 'x'.repeat(LIMITS.auth.jwtSecretMinLength)); +}); + +// ---------------------------------------------------------------- 可用性判定(一次性语义的核心) + +test('isSendAvailable:四种失效条件各自独立生效', () => { + const future = new Date(Date.now() + 86_400_000).toISOString(); + const past = new Date(Date.now() - 86_400_000).toISOString(); + + assert.equal(isSendAvailable(buildSend()), true, '默认状态应可用'); + assert.equal(isSendAvailable(buildSend({ disabled: true })), false, '手动禁用应不可用'); + assert.equal(isSendAvailable(buildSend({ expirationDate: past })), false, '已过期应不可用'); + assert.equal(isSendAvailable(buildSend({ expirationDate: future })), true, '未过期仍可用'); + assert.equal(isSendAvailable(buildSend({ deletionDate: past })), false, '已到删除期应不可用'); + + // 限量:达到上限即不可用 + assert.equal(isSendAvailable(buildSend({ maxAccessCount: 3, accessCount: 2 })), true, '未达上限仍可用'); + assert.equal(isSendAvailable(buildSend({ maxAccessCount: 3, accessCount: 3 })), false, '达到上限即不可用'); + assert.equal(isSendAvailable(buildSend({ maxAccessCount: 3, accessCount: 4 })), false, '超过上限仍不可用'); + assert.equal(isSendAvailable(buildSend({ maxAccessCount: null, accessCount: 999 })), true, '不限量时次数不影响'); + + // 边界:maxAccessCount = 0 表示"一次都不给" + assert.equal( + isSendAvailable(buildSend({ maxAccessCount: 0, accessCount: 0 })), + false, + 'maxAccessCount=0 应为"永不可用"(0 >= 0)' + ); +}); + +test('validateDeletionDate:超过配置的最大天数返回 400,未超则放行', () => { + const withinLimit = new Date(Date.now() + (LIMITS.send.maxDeletionDays - 1) * 86_400_000); + const beyondLimit = new Date(Date.now() + (LIMITS.send.maxDeletionDays + 1) * 86_400_000); + + assert.equal(validateDeletionDate(withinLimit), null, '未超上限应放行'); + assert.equal(validateDeletionDate(beyondLimit)?.status, 400, '超上限应 400'); +}); + +// ---------------------------------------------------------------- 密码校验 + +test('verifySendPassword:服务端加盐哈希的正确/错误密码判定', async () => { + const send = buildSend(); + await setSendPassword(send, 'correct horse battery staple'); + + assert.equal(send.authType, 1, '设置密码后 authType 应变为 Password'); + assert.ok(send.passwordHash && send.passwordSalt && send.passwordIterations, '应写入盐与迭代次数'); + + assert.equal(await verifySendPassword(send, 'correct horse battery staple'), true, '正确密码应通过'); + assert.equal(await verifySendPassword(send, 'wrong password'), false, '错误密码应失败'); + assert.equal(await verifySendPassword(send, ''), false, '空密码应失败'); + + // 每次设置都应用新的随机盐,即两条相同密码的哈希不应一致 + const other = buildSend(); + await setSendPassword(other, 'correct horse battery staple'); + assert.notEqual(other.passwordSalt, send.passwordSalt, '盐必须是随机的'); + assert.notEqual(other.passwordHash, send.passwordHash, '同密码不同盐应产生不同哈希'); +}); + +test('verifySendPassword:清除密码后任何输入都不通过', async () => { + const send = buildSend(); + await setSendPassword(send, 'pw'); + assert.equal(send.authType, SendAuthType.Password, '前置条件:设置密码后应是 Password 方式'); + + await setSendPassword(send, null); + + assert.equal(send.passwordHash, null); + assert.equal(send.authType, SendAuthType.None, '清除密码应回到 None 方式'); + assert.equal(await verifySendPassword(send, 'pw'), false, '没有密码哈希时不能通过'); +}); + +// ---------------------------------------------------------------- 公开访问校验 + +test('validatePublicSendAccess:无密码的 Send 直接放行', async () => { + const result = await validatePublicSendAccess(buildSend(), {}); + assert.equal(result.ok, true); +}); + +test('validatePublicSendAccess:邮件认证方式本服务不支持,返回 501', async () => { + const result = await validatePublicSendAccess(buildSend({ authType: SendAuthType.Email }), {}); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.reason, 'email_auth_unsupported'); + assert.equal(result.response.status, 501); + } +}); + +test('validatePublicSendAccess:受密码保护时,缺密码 401、错密码 400、对密码放行', async () => { + const send = buildSend(); + await setSendPassword(send, 'letmein'); + + const missing = await validatePublicSendAccess(send, {}); + assert.equal(missing.ok, false); + if (!missing.ok) { + assert.equal(missing.reason, 'password_missing'); + assert.equal(missing.response.status, 401); + } + + const wrong = await validatePublicSendAccess(send, { password: 'nope' }); + assert.equal(wrong.ok, false); + if (!wrong.ok) { + assert.equal(wrong.reason, 'invalid_password'); + assert.equal(wrong.response.status, 400); + } + + const right = await validatePublicSendAccess(send, { password: 'letmein' }); + assert.equal(right.ok, true, '正确密码应放行'); + + // 官方客户端会发大写别名 Password + const aliased = await validatePublicSendAccess(send, { Password: 'letmein' }); + assert.equal(aliased.ok, true, '应接受 PascalCase 别名'); +}); + +// ---------------------------------------------------------------- 端到端:公开访问面 + +interface Harness { + handle: Awaited>; + env: Env; + connection: DatabaseSync; +} + +async function createHarness(): Promise { + const handle = await createSchemaDatabase(); + insertUser(handle.connection, OWNER_ID, { email: OWNER_EMAIL }); + insertUser(handle.connection, STRANGER_ID); + const env = { + DB: handle.db, + JWT_SECRET: TEST_JWT_SECRET, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env; + return { handle, env, connection: handle.connection }; +} + +/** 存一条 Send,返回其 id 与 accessId */ +function seedSend( + h: Harness, + overrides: { + id?: string; + passwordHash?: string | null; + passwordSalt?: string | null; + passwordIterations?: number | null; + maxAccessCount?: number | null; + accessCount?: number; + disabled?: number; + expirationDate?: string | null; + deletionDate?: string | null; + } = {} +): { id: string; accessId: string } { + const id = overrides.id ?? 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + h.connection + .prepare( + 'INSERT INTO sends (id, user_id, type, name, data, key, password_hash, password_salt, password_iterations, auth_type, max_access_count, access_count, disabled, created_at, updated_at, expiration_date, deletion_date) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run( + id, + OWNER_ID, + 0, + enc('send-name'), + JSON.stringify({ text: enc('text') }), + enc('send-key'), + overrides.passwordHash ?? null, + overrides.passwordSalt ?? null, + overrides.passwordIterations ?? null, + overrides.passwordHash ? 1 : 2, + overrides.maxAccessCount ?? null, + overrides.accessCount ?? 0, + overrides.disabled ?? 0, + FIXED_NOW, + FIXED_NOW, + overrides.expirationDate ?? null, + overrides.deletionDate ?? new Date(Date.now() + 86_400_000).toISOString() + ); + return { id, accessId: accessIdFor(id) }; +} + +function accessRequest(body: unknown = {}): Request { + return new Request('https://vault.example.test/api/sends/access', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'CF-Connecting-IP': CLIENT_IP }, + body: JSON.stringify(body), + }); +} + +function accessCountOf(h: Harness, id: string): number { + const row = h.connection.prepare('SELECT access_count FROM sends WHERE id = ?').get(id) as { + access_count: number; + }; + return row.access_count; +} + +test('公开访问:非法 accessId 与不存在的 Send 返回同一种 404(不泄露存在性)', async () => { + const h = await createHarness(); + + const malformed = await handleAccessSend(accessRequest(), h.env, 'not-a-valid-access-id'); + assert.equal(malformed.status, 404); + + const validButAbsent = await handleAccessSend(accessRequest(), h.env, accessIdFor('99999999-9999-4999-8999-999999999999')); + assert.equal(validButAbsent.status, 404); + + const [a, b] = await Promise.all([ + malformed.json() as Promise<{ error?: string }>, + validButAbsent.json() as Promise<{ error?: string }>, + ]); + assert.equal(a.error, b.error, '"格式非法"与"不存在"必须给出同样的回答'); + assert.equal(a.error, SEND_INACCESSIBLE_MSG); + + h.handle.close(); +}); + +test('公开访问:已禁用的 Send 一律 404(即使 accessId 正确)', async () => { + const h = await createHarness(); + const { accessId } = seedSend(h, { disabled: 1 }); + + const response = await handleAccessSend(accessRequest(), h.env, accessId); + assert.equal(response.status, 404); + + h.handle.close(); +}); + +test('公开访问:受密码保护时,缺密码 401、错密码 400、对密码 200', async () => { + const h = await createHarness(); + const password = 'letmein'; + const send = buildSend(); + await setSendPassword(send, password); + const { id, accessId } = seedSend(h, { + passwordHash: send.passwordHash, + passwordSalt: send.passwordSalt, + passwordIterations: send.passwordIterations, + }); + + const missing = await handleAccessSend(accessRequest(), h.env, accessId); + assert.equal(missing.status, 401, '缺密码应 401'); + assert.equal(accessCountOf(h, id), 0, '未通过校验时不得计入访问次数'); + + const wrong = await handleAccessSend(accessRequest({ password: 'nope' }), h.env, accessId); + assert.equal(wrong.status, 400, '错密码应 400'); + assert.equal(accessCountOf(h, id), 0, '错密码不得计入访问次数'); + + const right = await handleAccessSend(accessRequest({ password }), h.env, accessId); + assert.equal(right.status, 200, `正确密码应放行,实际 ${right.status}`); + assert.equal(accessCountOf(h, id), 1, '成功访问应计入一次'); + + h.handle.close(); +}); + +test('一次性语义:maxAccessCount=1 时第二次访问必须 404,且计数不再增长', async () => { + const h = await createHarness(); + const { id, accessId } = seedSend(h, { maxAccessCount: 1 }); + + const first = await handleAccessSend(accessRequest(), h.env, accessId); + assert.equal(first.status, 200, '第一次应成功'); + assert.equal(accessCountOf(h, id), 1); + + const second = await handleAccessSend(accessRequest(), h.env, accessId); + assert.equal(second.status, 404, '达到上限后必须失效'); + assert.equal(accessCountOf(h, id), 1, '失效后的访问不得再计数'); + + h.handle.close(); +}); + +test('一次性语义:并发访问同一限量 Send 时不会超发', async () => { + const h = await createHarness(); + // 条件是原子 UPDATE(`access_count < max_access_count` 与自增在同一条语句里), + // 所以并发请求里只应有 max 个成功,其余都拿不到内容。 + const max = 2; + const { id, accessId } = seedSend(h, { maxAccessCount: max }); + + const responses = await Promise.all( + Array.from({ length: 6 }, () => handleAccessSend(accessRequest(), h.env, accessId)) + ); + const succeeded = responses.filter((response) => response.status === 200).length; + + assert.equal(succeeded, max, `并发下成功次数应恰好等于上限 ${max},实际 ${succeeded}`); + assert.equal(accessCountOf(h, id), max, '访问计数不得超过上限'); + + h.handle.close(); +}); + +test('跨用户隔离:非所有者读取 Send 详情返回 404', async () => { + const h = await createHarness(); + const { id } = seedSend(h); + + const owner = await handleGetSend(new Request(`https://x/api/sends/${id}`), h.env, OWNER_ID, id); + assert.equal(owner.status, 200, '所有者应能读到'); + + const stranger = await handleGetSend(new Request(`https://x/api/sends/${id}`), h.env, STRANGER_ID, id); + assert.equal(stranger.status, 404, '非所有者必须 404'); + + h.handle.close(); +}); diff --git a/scripts/sync-handler.test.ts b/scripts/sync-handler.test.ts new file mode 100644 index 000000000..2494e7e44 --- /dev/null +++ b/scripts/sync-handler.test.ts @@ -0,0 +1,326 @@ +// /api/sync handler 的行为测试 +// +// 为什么这个 handler 值得单独测:它是**客户端每次启动都会走**的端点,而且是**全量返回**—— +// Bitwarden 官方客户端拿到 sync 响应后会**整体替换本地状态**。这带来两个方向的风险: +// +// 1. 响应里**少**了东西 → 客户端把本地数据删掉(数据丢失) +// 2. 响应里**多了非法**的东西 → 客户端解析失败,整个 sync 挂掉(用户彻底进不去) +// +// 所以本文件重点不在"字段对不对",而在**缓存键的隔离性**和**污染行不能扩散**: +// +// - sync 的响应会被 `caches.default` 缓存,缓存键由 用户/修订号/凭据标签/三个标志位 拼成。 +// **任一维度漏进键里**都会造成跨请求串答案:先请求 `?excludeSends=1` 的响应 +// (`sends: []`)被缓存后,下一个不带参数的请求会直接命中它,于是客户端认为 +// "我没有 Sends",进而清空本地 Sends。 +// - `account-passkeys.ts` **完全不碰 revisionDate**(实测确认),所以缓存键里的凭据标签 +// 是"新增 passkey 后客户端能否立刻看到"的**唯一**保障 —— 见下方对应的用例。 +// - 存储层若存在 `name` 不是合法 EncString 的行(历史脏数据 / 早期 bug 写入), +// `isCipherResponseSyncCompatible` 会把它**丢掉而不是让整个同步失败**。 +// +// 运行方式:npm run test:sync-handler +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import type { DatabaseSync } from 'node:sqlite'; +import path from 'node:path'; +import test from 'node:test'; + +import { handleCreateCipher } from '../src/handlers/ciphers'; +import { handleSync } from '../src/handlers/sync'; +import type { Env, SyncResponse } from '../src/types'; +import { createD1SqliteDatabase } from './lib/d1-sqlite'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..'); +const SCHEMA_SQL = readFileSync(path.join(REPO_ROOT, 'migrations', '0001_init.sql'), 'utf8'); +const NOW = '2026-01-01T00:00:00.000Z'; +const USER = 'sync-user'; + +function enc(label: string): string { + return `2.${label}-iv|${label}-data|${label}-mac`; +} + +/** 记录被问过的缓存键,用来断言"键有没有正确隔离" */ +interface CacheRecorder { + /** 每次 match() 收到的键,按顺序 */ + matchedKeys: string[]; + /** put() 被调用的次数 —— 命中缓存时不应增加 */ + puts: number; +} + +/** + * 安装一个内存版 CacheStorage。 + * Node 里没有 `caches` 全局,而 `sync.ts` 直接用 `caches.default`, + * 不装这个会直接 `ReferenceError`。 + */ +function installCacheStub(): CacheRecorder { + const store = new Map(); + const recorder: CacheRecorder = { matchedKeys: [], puts: 0 }; + + (globalThis as unknown as { caches: unknown }).caches = { + default: { + async match(request: Request): Promise { + recorder.matchedKeys.push(request.url); + const body = store.get(request.url); + // 每次都返回全新的 Response:缓存命中路径会包一层 + // `new Response(hit.body, hit)`,复用同一个 body 流会被判定为已消费。 + return body === undefined + ? undefined + : new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + async put(request: Request, response: Response): Promise { + recorder.puts += 1; + store.set(request.url, await response.text()); + }, + }, + }; + + return recorder; +} + +interface Harness { + handle: ReturnType; + connection: DatabaseSync; + env: Env; + cache: CacheRecorder; +} + +function createHarness(): Harness { + const handle = createD1SqliteDatabase(); + handle.connection.exec(SCHEMA_SQL); + handle.connection + .prepare( + 'INSERT INTO users (id, email, name, master_password_hash, key, kdf_type, kdf_iterations, security_stamp, role, status, verify_devices, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run(USER, 'sync@example.test', 'sync', 'master-hash', 'wrapped-key', 0, 600000, 'stamp', 'user', 'active', 0, NOW, NOW); + + return { + handle, + connection: handle.connection, + // 桩掉 NOTIFICATIONS_HUB:本文件通过 handleCreateCipher 造数据,缺绑定会被吞掉 + // 但每次往 stderr 打一行错误,容易淹没真正的失败信息。 + env: { + DB: handle.db, + NOTIFICATIONS_HUB: { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ fetch: async () => new Response('{}', { status: 200 }) }), + }, + } as unknown as Env, + cache: installCacheStub(), + }; +} + +function syncRequest(options: { params?: Record; web?: boolean } = {}): Request { + const url = new URL('https://vault.example.test/api/sync'); + for (const [key, value] of Object.entries(options.params ?? {})) { + url.searchParams.set(key, value); + } + const headers: Record = {}; + if (options.web) headers['X-NodeWarden-Web'] = '1'; + return new Request(url.toString(), { method: 'GET', headers }); +} + +async function runSync( + h: Harness, + options: { params?: Record; web?: boolean } = {} +): Promise<{ status: number; body: SyncResponse & Record }> { + const response = await handleSync(syncRequest(options), h.env, USER); + return { status: response.status, body: (await response.json()) as SyncResponse & Record }; +} + +function revisionDate(h: Harness): string | undefined { + const row = h.connection.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?').get(USER) as + | { revision_date: string } + | undefined; + return row?.revision_date; +} + +/** 取出响应里的 PRF 解密选项(UserDecryption 在类型上是可选的) */ +function prfOptions(body: SyncResponse & Record): unknown[] { + const { UserDecryption } = body; + assert.ok(UserDecryption, 'sync 响应应包含 UserDecryption 分区'); + return UserDecryption.WebAuthnPrfOptions ?? []; +} + +/** 存一条 Sends(够 sendToResponse 映射即可) */ +function seedSend(h: Harness, id: string): void { + h.connection + .prepare( + 'INSERT INTO sends (id, user_id, type, name, data, key, auth_type, access_count, disabled, created_at, updated_at, deletion_date) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run(id, USER, 0, enc('send-name'), JSON.stringify({ text: enc('text') }), enc('send-key'), 2, 0, 0, NOW, NOW, '2027-01-01T00:00:00.000Z'); +} + +/** + * 存一条 passkey 凭据。 + * `supports_prf=1` + 三把加密密钥齐全 → `accountPasskeyPrfStatus()` 返回 0 + * → `buildWebAuthnPrfOption()` 才会产出条目进入响应。 + */ +function seedPasskey(h: Harness, id: string): void { + h.connection + .prepare( + 'INSERT INTO webauthn_credentials (id, user_id, purpose, name, public_key, credential_id, counter, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ) + .run(id, USER, 'login', 'my-passkey', 'pub', `cred-${id}`, 0, '["internal"]', enc('uk'), enc('pk'), enc('sk'), 1, NOW, NOW); +} + +async function seedCipher(h: Harness, name: string): Promise { + const response = await handleCreateCipher( + new Request('https://vault.example.test/api/ciphers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 1, name: enc(name), login: { username: enc('u') } }), + }), + h.env, + USER + ); + const body = (await response.json()) as { id: string }; + return body.id; +} + +// ---------------------------------------------------------------- 基础契约 + +test('用户不存在时返回 404', async () => { + const h = createHarness(); + const response = await handleSync(syncRequest(), h.env, 'no-such-user'); + assert.equal(response.status, 404); + + h.handle.close(); +}); + +test('正常返回 200,且包含 ciphers / folders / sends / domains 等关键分区', async () => { + const h = createHarness(); + await seedCipher(h, 'a'); + + const { status, body } = await runSync(h); + assert.equal(status, 200); + assert.equal(body.object, 'sync'); + assert.equal(body.ciphers.length, 1); + assert.deepStrictEqual(body.folders, []); + assert.deepStrictEqual(body.collections, [], '团队/集合功能未实现,应恒为空数组'); + assert.deepStrictEqual(body.policies, []); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 排除开关 + +test('excludeSends=1 返回空 sends;不带参数返回真实 sends', async () => { + const h = createHarness(); + seedSend(h, 'send-1'); + + const plain = await runSync(h); + assert.equal(plain.body.sends.length, 1, '不带 excludeSends 时应返回真实 sends'); + + const excluded = await runSync(h, { params: { excludeSends: '1' } }); + assert.deepStrictEqual(excluded.body.sends, []); + + h.handle.close(); +}); + +test('excludeDomains=1 把 domains 置为 null;不带参数返回真实 domains', async () => { + const h = createHarness(); + h.connection + .prepare('INSERT INTO domain_settings (user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) VALUES (?,?,?,?,?)') + .run(USER, JSON.stringify(['a.test', 'b.test']), '[]', '[]', NOW); + + const plain = await runSync(h); + assert.ok(plain.body.domains, '不带 excludeDomains 时 domains 不应为 null'); + + const excluded = await runSync(h, { params: { excludeDomains: '1' } }); + assert.equal(excluded.body.domains, null); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 缓存键隔离(核心) + +test('未勾选排除时,绝不能命中「勾选了排除」留下的缓存(客户端会据此清空本地数据)', async () => { + const h = createHarness(); + seedSend(h, 'send-1'); + + // 危险顺序:先让"瘦身版"响应占住缓存,再请求完整版 + const thin = await runSync(h, { params: { excludeSends: '1' } }); + assert.deepStrictEqual(thin.body.sends, [], '前置条件:excludeSends=1 应为空'); + + const full = await runSync(h); + assert.equal( + full.body.sends.length, + 1, + '缓存键若漏掉 excludeSends,这里会命中上面的空 sends;客户端会认为没有 Sends 并清空本地数据' + ); + + h.handle.close(); +}); + +test('三个标志位与 Web 请求头各自独立影响缓存键', async () => { + const h = createHarness(); + + await runSync(h); + await runSync(h, { params: { excludeDomains: '1' } }); + await runSync(h, { params: { excludeSends: '1' } }); + await runSync(h, { web: true }); + + const keys = new Set(h.cache.matchedKeys); + assert.equal(keys.size, 4, `四次请求应落在 4 个不同的缓存键上,实际 ${keys.size} 个:\n${[...keys].join('\n')}`); + + h.handle.close(); +}); + +test('新增 passkey 必须使缓存失效(revisionDate 不会变,凭据标签是唯一保障)', async () => { + const h = createHarness(); + + const before = await runSync(h); + assert.deepStrictEqual(prfOptions(before.body), [], '前置条件:初始没有 passkey'); + const revisionBefore = revisionDate(h); + assert.ok(revisionBefore, '前置条件:首次 sync 应建立 user_revisions 记录'); + + seedPasskey(h, 'passkey-1'); + + // 这一条是"为什么缓存键里必须带凭据标签"的证明:新增 passkey **不会**改动修订号。 + assert.equal( + revisionDate(h), + revisionBefore, + '插入 passkey 不改 revisionDate —— 若这里开始变了,说明实现变了,本用例的前提需重新审视' + ); + + const after = await runSync(h); + assert.notEqual(h.cache.matchedKeys.at(-1), h.cache.matchedKeys[0], '第二次请求必须落在不同的缓存键上'); + assert.equal( + prfOptions(after.body).length, + 1, + '新增的 passkey 必须立刻出现在 sync 响应里,否则客户端在缓存过期前看不到它、表现为"passkey 注册了但没用"' + ); + + h.handle.close(); +}); + +test('相同参数重复请求命中缓存:不再写入缓存,且响应一致', async () => { + const h = createHarness(); + await seedCipher(h, 'a'); + + const first = await runSync(h); + assert.equal(h.cache.puts, 1, '首次请求应写缓存'); + + const second = await runSync(h); + assert.equal(h.cache.puts, 1, '第二次应命中缓存,不应再写缓存'); + assert.deepStrictEqual(second.body, first.body, '命中缓存返回的内容应与首次一致'); + + h.handle.close(); +}); + +// ---------------------------------------------------------------- 脏数据不扩散 + +test('name 不是合法 EncString 的脏行被丢弃,但其余条目照常返回(不能让整次同步失败)', async () => { + const h = createHarness(); + const goodId = await seedCipher(h, 'good'); + const poisonedId = await seedCipher(h, 'to-be-poisoned'); + + // 模拟历史脏数据 / 早期版本写入的明文 name,绕过 handler 的校验 + h.connection.prepare('UPDATE ciphers SET name = ? WHERE id = ?').run('plaintext-not-an-encstring', poisonedId); + + const { status, body } = await runSync(h); + assert.equal(status, 200, '一条坏数据不应让整个 sync 返回非 200'); + assert.equal(body.ciphers.length, 1, '脏行应被丢弃'); + assert.equal(body.ciphers[0].id, goodId, '保留的应是那条合法数据'); + + h.handle.close(); +}); diff --git a/scripts/webapp/api-error-visibility.test.ts b/scripts/webapp/api-error-visibility.test.ts new file mode 100644 index 000000000..9e743bc09 --- /dev/null +++ b/scripts/webapp/api-error-visibility.test.ts @@ -0,0 +1,173 @@ +// 「前端不要把服务端错误文案吞掉」的源码护栏(docs/TODO.md 第 21 条)。 +// +// 背景:`webapp/src/lib/api/**` 里原有 **32 处**写成 +// if (!resp.ok) throw new Error('Create item failed'); +// 它们把服务端的 `error_description` / `error` **整个丢掉**,于是: +// · 主密码输错 → 用户只看到「创建失败」,无从自救; +// · 命中限流(429)/ 权限(403)→ 同样只剩一句笼统的失败。 +// 正确的形态是把服务端文案接过来再本地化: +// if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_…'))); +// 老代码里还有等价的 `translateServerError(body?.error_description || body?.error, t('…'))` +// (手动 parseJson 的版本)—— 两种都算合格,本护栏只禁「完全不吃服务端文案」的写法。 +// +// 为什么必须用护栏而不是靠人记:第 5/20/21 条已经是**第三次**修同一个模式了 +// (管理端 2 处 → 邀请码 4 处 → 全仓 32 处),加护栏才能止住。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..'); +const API_ROOT = path.join(REPO_ROOT, 'webapp', 'src', 'lib', 'api'); + +/** `if (!resp.ok)` / `if (!rawResp.ok)` / `if (!pre.ok)` 这类失败分支守卫 */ +const GUARD_RE = /if\s*\(\s*![\w.]*\.ok\s*\)/g; +/** 实参里出现这两个函数之一 = 把服务端文案接住了 */ +const ACCEPTED_CALLS = ['parseErrorMessage(', 'translateServerError(']; + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length; +} + +/** 取守卫之后的分支体:`{ … }` 块(按括号配对)或同一行的余下内容 */ +function extractBranchBody(afterGuard: string): string { + const rest = afterGuard.trimStart(); + if (!rest.startsWith('{')) return rest.split('\n')[0]; + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < rest.length; i += 1) { + const char = rest[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '{') depth += 1; + else if (char === '}') { + depth -= 1; + if (depth === 0) return rest.slice(0, i + 1); + } + } + return rest; +} + +/** 取出分支体里每个 `throw new Error(...)` 的实参原文 */ +function extractThrowArguments(body: string): string[] { + const args: string[] = []; + const pattern = /throw new Error\(/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(body))) { + let depth = 0; + let quote: string | null = null; + let end = -1; + for (let i = match.index + match[0].length - 1; i < body.length; i += 1) { + const char = body[i]; + if (quote) { + if (char === '\\') i += 1; + else if (char === quote) quote = null; + continue; + } + if (char === "'" || char === '"' || char === '`') { + quote = char; + continue; + } + if (char === '(') depth += 1; + else if (char === ')') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) break; + args.push(body.slice(match.index + match[0].length, end)); + } + return args; +} + +interface ScanResult { + guards: number; + violations: string[]; +} + +function scanApiSource(relative: string, source: string): ScanResult { + const violations: string[] = []; + let guards = 0; + for (const match of source.matchAll(GUARD_RE)) { + guards += 1; + const body = extractBranchBody(source.slice(match.index + match[0].length)); + for (const argument of extractThrowArguments(body)) { + if (ACCEPTED_CALLS.some((call) => argument.includes(call))) continue; + violations.push(`${relative}:${lineOf(source, match.index)} throw new Error(${argument.replace(/\s+/g, ' ').slice(0, 80)})`); + } + } + return { guards, violations }; +} + +function collectApiFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) + .map((entry) => path.join(entry.parentPath ?? dir, entry.name)) + .sort(); +} + +// ---------------------------------------------------------------- 扫描器自检 +// 护栏本身写错(例如括号配对错、正则漏掉块形式)会**静默放行**,所以先把它的判断钉住。 +test('扫描器自检:同行形式 / 块形式 / 两种合格写法 / 不带服务端文案的写法', () => { + const fixture = [ + "if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_a')));", // 合格 + "if (!resp.ok) throw new Error(translateServerError(body?.error, t('txt_b')));", // 合格(老写法) + "if (!resp.ok) throw new Error('Create item failed');", // 违规 + 'if (!resp.ok) {', // 违规(块形式) + " throw new Error('Bulk delete failed');", + '}', + 'if (!pre.ok) {', // 合格(块形式 + 合格写法) + " throw new Error(await parseErrorMessage(pre, t('txt_c')));", + '}', + 'if (!resp.ok) return null;', // 不是 throw,跳过 + ].join('\n'); + + const result = scanApiSource('fixture.ts', fixture); + assert.equal(result.guards, 6, '6 个守卫都要被扫到(含 return 型那个)'); + assert.equal(result.violations.length, 2, '只有两处完全不吃服务端文案'); + assert.match(result.violations[0], /fixture\.ts:3/); + assert.match(result.violations[1], /fixture\.ts:4/); +}); + +test('扫描器自检:嵌套括号与模板字符串不会把实参截断', () => { + const fixture = "if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_x', { a: f(1, 2) })));"; + const result = scanApiSource('fixture.ts', fixture); + assert.deepEqual(result.violations, []); +}); + +// ---------------------------------------------------------------- 主断言 +test('webapp/src/lib/api 下所有失败分支都必须接住服务端文案', () => { + const violations: string[] = []; + let guards = 0; + + for (const file of collectApiFiles(API_ROOT)) { + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + const result = scanApiSource(relative, readFileSync(file, 'utf8')); + guards += result.guards; + violations.push(...result.violations); + } + + assert.deepEqual( + violations, + [], + '这些失败分支把服务端文案丢掉了(用户会看到一句笼统的「失败」,无法自救,见 docs/TODO 第 21 条):\n' + + `${violations.join('\n')}\n` + + "修法:throw new Error(await parseErrorMessage(resp, t('txt_…')))—— " + + 'fallback 文案用已有的键,别新造(第 21 条实测 32 处全部能复用现有键)' + ); + + // 防「正则失效 ⇒ 静默扫到 0 个守卫」:那时上面那条断言永远是绿的 + assert.ok(guards >= 100, `只扫到 ${guards} 个失败分支守卫,疑似扫描器失效(实测基线 110)`); +}); diff --git a/scripts/webapp/backup-runtime-summary.test.ts b/scripts/webapp/backup-runtime-summary.test.ts new file mode 100644 index 000000000..b4cd5e031 --- /dev/null +++ b/scripts/webapp/backup-runtime-summary.test.ts @@ -0,0 +1,113 @@ +// 备份目标「最近运行」摘要的测试(docs/TODO.md 第 22 条)。 +// +// 背景:后端在 `runtime` 里**同时**保留 `lastSuccessAt` 与 `lastErrorAt` / `lastErrorMessage`, +// 并且只在**成功**时清空错误(第 18 条修掉的就是「每次尝试开始就清空」)。 +// 界面以前完全没有这个信息,只能靠 API / 审计日志看。 +// 详情页只展示**失败**(「上次成功」在左侧地点列表里已有),所以这里只测失败那一支。 +// +// 这里测的是**纯函数** `getDestinationRuntimeSummary()`(组件只负责把它铺到 DOM 上), +// 因为要盯住两件容易静默出错的事: +// ① 没有失败时不能凭空造出一行「上次失败」(否则详情页会多一个空框); +// ② 失败原因必须走 `translateServerError()`(命中映射就本地化), +// 但**未命中时必须保留原文** —— 刻意不回落到通用文案,具体原因才是排障线索。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createDefaultBackupRuntimeState } from '../../shared/backup-schema'; +import { getDestinationRuntimeSummary } from '../../webapp/src/lib/backup-center'; + +/** 默认语言包是英文(`webapp/src/lib/i18n.ts` 在模块加载时初始化),故断言用英文文案 */ +test('从未运行过:不产生「上次失败」', () => { + const summary = getDestinationRuntimeSummary(createDefaultBackupRuntimeState()); + + assert.equal(summary.failedAt, null); + assert.equal(summary.failureReason, null); +}); + +test('成功过且没失败:仍然不产生「上次失败」(详情页不该出现空框)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastAttemptAt: '2026-09-18T03:00:00.000Z', + lastSuccessAt: '2026-09-18T03:00:12.000Z', + }); + + assert.equal(summary.failedAt, null, '没失败过就不能显示失败行'); + assert.equal(summary.failureReason, null); +}); + +test('成功晚于失败:不再显示上次失败(最新一次是成功)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastErrorAt: '2026-09-18T02:00:30.000Z', + lastErrorMessage: 'WebDAV upload timed out after 30000 ms', + // 后端成功时本会清空 lastError*,但归档恢复 / 手工改配置可能带进这种旧状态 + lastSuccessAt: '2026-09-18T03:00:12.000Z', + }); + + assert.equal(summary.failedAt, null, '成功之后不能让过时的失败信息继续占着列表与详情页'); + assert.equal(summary.failureReason, null); +}); + +test('失败晚于成功:仍然显示(这就是「一直在重试、一直失败」)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastSuccessAt: '2026-09-18T02:00:12.000Z', + lastErrorAt: '2026-09-18T03:00:30.000Z', + lastErrorMessage: 'WebDAV upload timed out after 30000 ms', + }); + + assert.match(summary.failedAt ?? '', /^Last failure: /); + assert.match(summary.failureReason ?? '', /30s/); +}); + +test('时间无法解析时保持显示(宁可多提示一次,也不把真实失败藏起来)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastErrorAt: 'not-a-date', + lastErrorMessage: 'S3 upload timed out after 5000 ms', + lastSuccessAt: '2026-09-18T03:00:12.000Z', + }); + + assert.match(summary.failedAt ?? '', /^Last failure: /); + assert.match(summary.failureReason ?? '', /5s/); +}); + +test('失败过:同时给出失败时间与原因(命中映射的超时文案会被本地化)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastAttemptAt: '2026-09-18T03:05:00.000Z', + lastSuccessAt: '2026-09-18T03:00:12.000Z', + lastErrorAt: '2026-09-18T03:05:30.000Z', + lastErrorMessage: 'WebDAV upload timed out after 30000 ms', + }); + + assert.match(summary.failedAt ?? '', /^Last failure: /); + assert.match( + summary.failureReason ?? '', + /30s/, + '超时文案有专门的映射(毫秒换算成秒):命中映射才是本地化过的文案' + ); +}); + +test('未命中映射的原因保留原文(不回落到通用文案)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastErrorAt: '2026-09-18T03:05:30.000Z', + lastErrorMessage: 'S3 putObject failed: 403', + }); + + assert.equal(summary.failureReason, 'S3 putObject failed: 403'); +}); + +test('只有空白字符的原因不算失败(避免渲染出空的失败行)', () => { + const summary = getDestinationRuntimeSummary({ + ...createDefaultBackupRuntimeState(), + lastErrorAt: '2026-09-18T03:05:30.000Z', + lastErrorMessage: ' ', + }); + + assert.equal(summary.failedAt, null); + assert.equal(summary.failureReason, null); +}); diff --git a/scripts/webapp/i18n.test.ts b/scripts/webapp/i18n.test.ts new file mode 100644 index 000000000..725915704 --- /dev/null +++ b/scripts/webapp/i18n.test.ts @@ -0,0 +1,150 @@ +// webapp 纯逻辑测试:i18n 插值与「服务端错误串 → 文案」的映射(§3.5 方案 A) +// +// 为什么值得测:这两件事都是**静默失败**型的 —— +// · 插值缺参数会变成空字符串,页面上就是一个说不通的句子,而不是报错; +// · `translateServerError` 映射不到时会**回退成原始英文串**,于是非英文用户 +// 会突然看到一句英文。这类问题很难在 UI 走查里发现(除非恰好用非英文界面复现那条错误)。 +// +// 注意:`webapp/src/lib/i18n.ts` 在模块加载时就把 `activeMessages` 初始化为英文语言包, +// 且 locale 探测包在 try/catch 里,因此**在 Node 里可直接使用,无需 DOM 桩、无需 initI18n()**。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AVAILABLE_LOCALES, getLocale, t, translateServerError } from '../../webapp/src/lib/i18n'; +import { REMOTE_REQUEST_ACTIONS, buildRemoteTimeoutMessage } from '../../shared/backup-timeout-message'; + +// ---------------------------------------------------------------- 插值 + +test('t:未映射的 key 原样返回(而不是空串或 undefined)', () => { + assert.equal(t('this.key.does.not.exist'), 'this.key.does.not.exist'); +}); + +test('t:把 {name} 占位符替换成参数值(用真实语言包键)', () => { + // 用真实的英文语言包键,而不是自己搭一个 template —— 这样若键名被改,用例会红 + assert.equal(t('txt_yubikey_x', { index: '2' }), 'YubiKey 2'); + assert.equal(t('txt_generator_character_count', { count: 20 }), '20 characters'); + assert.equal(t('txt_backup_progress_subject', { name: 'my-cipher' }), 'Current item: my-cipher'); +}); + +test('t:同一个键里的多个不同占位符都能替换', () => { + const result = t('txt_backup_restore_skipped_summary', { reason: 'Unsupported type', attachments: '3' }); + assert.equal(result, 'Unsupported type. Skipped 3 attachment(s).'); +}); + +test('t:缺失的参数会变成空串(静默失败点,此处记录既有行为)', () => { + // 参数名拼错或忘记传,既不报错也不残留 `{index}`,而是产出空串 —— + // 页面上就是「YubiKey 」这样一句缺了内容的话。这里是**记录**而不是认可该行为。 + assert.equal(t('txt_yubikey_x', {}), 'YubiKey ', '缺失参数会留下空位,不会残留占位符'); + assert.equal(t('txt_yubikey_x', { wrongName: '2' }), 'YubiKey ', '参数名拼错同样静默变空'); + assert.equal(t('txt_yubikey_x', { index: null }), 'YubiKey ', 'null 也被当作缺失'); +}); + +test('t:数字参数会被转成字符串', () => { + assert.equal(t('txt_backup_error_destination_limit', { count: 5 }), 'You can save up to 5 backup destinations.'); +}); + +test('AVAILABLE_LOCALES:每一项都有 value 与 label,且包含英文', () => { + assert.ok( + AVAILABLE_LOCALES.some((item) => item.value === 'en'), + '语言列表必须包含英文 —— 它是所有缺失翻译的兜底' + ); + for (const item of AVAILABLE_LOCALES) { + assert.ok(item.value && item.label, `语言项必须同时有 value 与 label:${JSON.stringify(item)}`); + } +}); + +test('getLocale:返回的一定是合法 locale(环境探测失败时才回退到 en)', () => { + // 不能写死断言 'en' —— 本地跑时 `navigator.language` 是真实存在的(实测 `zh-CN`), + // 函数会据此正确探测出语言。真正的不变量是「返回值必定在语言表里」。 + const current = getLocale(); + assert.ok( + AVAILABLE_LOCALES.some((item) => item.value === current), + `getLocale() 必须返回语言表里的值,实际 ${JSON.stringify(current)}` + ); +}); + +// ---------------------------------------------------------------- 服务端错误映射 + +test('translateServerError:空 / 空白 / null / undefined 一律回退为调用方给的文案', () => { + for (const input of ['', ' ', '\n\t', null, undefined]) { + assert.equal( + translateServerError(input, 'fallback-text'), + 'fallback-text', + `入参 ${JSON.stringify(input)} 应回退` + ); + } +}); + +test('translateServerError:带数字的模式串会被参数化翻译,数字被保留', () => { + const result = translateServerError('Rate limit exceeded. Try again in 42 seconds.', 'fallback'); + assert.notEqual(result, 'fallback', '这是有专用映射的形式,不该回退'); + assert.ok(result.includes('42'), `秒数应出现在文案里,实际:${result}`); +}); + +test('translateServerError:模式匹配不区分大小写,且要求整串匹配', () => { + const lower = translateServerError('rate limit exceeded. try again in 7 seconds.', 'fallback'); + const upper = translateServerError('RATE LIMIT EXCEEDED. TRY AGAIN IN 7 SECONDS.', 'fallback'); + assert.equal(lower, upper, '大小写不同的同一条消息应得到同样结果'); + + // 前后多出内容就不再匹配模式 → 走表查 → 查不到 → 返回原文 + const noisy = translateServerError('Error: Rate limit exceeded. Try again in 7 seconds.', 'fallback'); + assert.equal(noisy, 'Error: Rate limit exceeded. Try again in 7 seconds.', '整串不匹配时应返回原文'); +}); + +test('translateServerError:**映射不到时返回原始英文串**(这是既有的兜底,已固化)', () => { + const result = translateServerError('Some brand new server error that has no mapping', 'fallback'); + assert.equal( + result, + 'Some brand new server error that has no mapping', + '既不是 fallback 也不是空串 —— 非英文界面下会看到英文原文,改动映射表时需留意这一点' + ); +}); + +test('translateServerError:前后空白会被裁掉后再查表', () => { + const padded = translateServerError(' masterPasswordHash is required ', 'fallback'); + const bare = translateServerError('masterPasswordHash is required', 'fallback'); + assert.equal(padded, bare, '两侧空白不应影响映射结果'); +}); + +// ---------------------------------------------------------------- 远端备份超时 +// +// 为什么值得测:后端把远端超时消息写成「WebDAV upload timed out after 15000 ms」这种**毫秒**形态, +// 映射不到时管理员会直接看到这串英文(非英文界面下尤其刺眼), +// 而“毫秒 / 秒”换算写错时界面会出现「15000 秒」这种读数 —— 两者都是静默失败。 +// ⚠️ 样例**必须**用共享构造器生成(`shared/backup-timeout-message.ts`): +// 手写字符串的话,后端改措辞时这里会继续绿,而用户那边已经退回英文原文。 +test('translateServerError:远端超时消息被换算成秒并落到本地化文案', () => { + assert.equal( + translateServerError(buildRemoteTimeoutMessage('WebDAV', 'upload', 15000), 'fallback'), + 'The remote backup destination did not respond in time (timed out after 15s). ' + + 'Check the address, network connectivity, and credentials, then try again.' + ); + assert.equal( + translateServerError(buildRemoteTimeoutMessage('S3', 'listing', 500), 'fallback'), + 'The remote backup destination did not respond in time (timed out after 0.5s). ' + + 'Check the address, network connectivity, and credentials, then try again.' + ); +}); + +test('translateServerError:所有 provider × action 组合都能命中本地化文案(映射分支被删就会红)', () => { + for (const action of REMOTE_REQUEST_ACTIONS) { + for (const provider of ['WebDAV', 'S3'] as const) { + const translated = translateServerError(buildRemoteTimeoutMessage(provider, action, 12345), 'FALLBACK'); + assert.notEqual(translated, 'FALLBACK', `${provider} ${action} 未被映射命中 ⇒ 界面会显示英文原文`); + assert.ok( + !translated.includes('12345'), + '毫秒必须换算成秒,否则界面会出现「12345 秒」这种读数' + ); + assert.match(translated, /12(?:\.\d)?s/); + } + } +}); + +test('translateServerError:HTTP 状态类失败仍按「provider + 动作 + 状态码」文案渲染(不应被超时分支抢走)', () => { + assert.equal( + translateServerError('WebDAV upload failed: 403', 'fallback'), + 'WebDAV upload failed: HTTP 403.' + ); +}); diff --git a/scripts/webapp/offline-storage-secrets.test.ts b/scripts/webapp/offline-storage-secrets.test.ts new file mode 100644 index 000000000..bb9db2e45 --- /dev/null +++ b/scripts/webapp/offline-storage-secrets.test.ts @@ -0,0 +1,173 @@ +// 「敏感数据落盘」这条链路的断言(CodeQL js/clear-text-storage-of-sensitive-data ×5)。 +// +// 背景:CodeQL 报了 5 条 high —— localStorage 里被写入了"来自登录流程/profile 的敏感数据"。 +// 逐个核实后的结论是「设计如此 + 误报」: +// · 会话只存 `{ email, authMode }`,**不存** access/refresh token(历史版本存过,`loadSession` +// 里专门有一段迁移逻辑把它们清掉); +// · profile 快照走 `stripProfileSecrets` 白名单,`key` 置空、`privateKey` 置 null; +// · 离线解锁必须存一份"加密后的用户密钥"(EncString)与 KDF 迭代数 —— 否则断开网络就无法解锁。 +// 但"核实过"不等于"以后不会退化",所以把三条结论写成断言: +// **任何写进 localStorage 的内容都不得出现这些明文标记**。 +// +// 注意:Node 里没有可靠的 localStorage(版本差异大),因此这里注入一个内存桩, +// 既避免依赖运行时行为,也顺便能读到"到底写了什么"。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { saveProfileSnapshot, saveSession, stripProfileSecrets } from '../../webapp/src/lib/api/auth'; +import { saveOfflineUnlockRecord } from '../../webapp/src/lib/offline-auth'; +import type { Profile } from '../../webapp/src/lib/types'; + +const EMAIL = 'user@example.test'; + +// 这些标记代表"绝不能落盘"的东西:令牌、明文/包裹密钥、master password hash +const MARKERS = { + accessToken: 'ACCESS-TOKEN-MARKER', + refreshToken: 'REFRESH-TOKEN-MARKER', + symEncKey: 'SYM-ENC-KEY-MARKER', + symMacKey: 'SYM-MAC-KEY-MARKER', + wrappedKey: 'WRAPPED-USER-KEY-MARKER', + privateKey: 'PRIVATE-KEY-MARKER', + masterHash: 'MASTER-PASSWORD-HASH-MARKER', +}; + +function installLocalStorageStub(): { entries: () => Array<[string, string]>; restore: () => void } { + const store = new Map(); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + const stub = { + getItem: (key: string): string | null => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string): void => void store.set(key, String(value)), + removeItem: (key: string): void => void store.delete(key), + clear: (): void => store.clear(), + key: (index: number): string | null => Array.from(store.keys())[index] ?? null, + get length(): number { + return store.size; + }, + }; + Object.defineProperty(globalThis, 'localStorage', { value: stub, configurable: true, writable: true }); + return { + entries: () => Array.from(store.entries()), + restore: () => { + if (previous) Object.defineProperty(globalThis, 'localStorage', previous); + else Reflect.deleteProperty(globalThis, 'localStorage'); + }, + }; +} + +/** 只看内容,不管键名:键名属实现细节,泄漏与否取决于"写了什么" */ +function storedText(entries: Array<[string, string]>): string { + return entries.map(([, value]) => value).join('\n'); +} + +function assertNoMarkers(entries: Array<[string, string]>, context: string): void { + const text = storedText(entries); + for (const [name, marker] of Object.entries(MARKERS)) { + assert.doesNotMatch(text, new RegExp(marker), `${context}:落盘内容里出现了 ${name} 的明文标记`); + } +} + +function fullProfile(): Profile { + return { + id: 'user-1', + email: EMAIL, + name: 'Example User', + role: 'admin', + key: MARKERS.wrappedKey, + privateKey: MARKERS.privateKey, + publicKey: 'PUBLIC-KEY', + masterPasswordHint: 'hint', + masterPasswordHash: MARKERS.masterHash, + }; +} + +test('会话落盘只保留 email + authMode,绝不写 access/refresh token', () => { + const stub = installLocalStorageStub(); + try { + saveSession({ + email: EMAIL, + authMode: 'token', + accessToken: MARKERS.accessToken, + refreshToken: MARKERS.refreshToken, + symEncKey: MARKERS.symEncKey, + symMacKey: MARKERS.symMacKey, + }); + + const entries = stub.entries(); + assert.equal(entries.length, 1, '应当只写一个条目'); + assert.deepEqual(JSON.parse(entries[0][1]), { email: EMAIL, authMode: 'token' }); + assertNoMarkers(entries, 'saveSession'); + } finally { + stub.restore(); + } +}); + +test('profile 快照必须剥掉密钥,且未知字段不落盘', () => { + const stub = installLocalStorageStub(); + try { + saveProfileSnapshot(fullProfile()); + + const entries = stub.entries(); + assert.equal(entries.length, 1); + const stored = JSON.parse(entries[0][1]) as Profile; + assert.equal(stored.key, '', '包裹后的用户密钥不得落盘'); + assert.equal(stored.privateKey, null, '私钥不得落盘'); + assert.equal(stored.email, EMAIL, '其余展示字段必须保留'); + assert.equal(stored.role, 'admin'); + assert.equal(stored.masterPasswordHash, undefined, '未知字段(含 master hash)必须被丢弃'); + assertNoMarkers(entries, 'saveProfileSnapshot'); + + // 白名单本身也直接断言一次,避免只有"经由 saveProfileSnapshot"才被覆盖 + const stripped = stripProfileSecrets(fullProfile())!; + assert.equal(String(stripped.key), ''); + assert.equal(stripped.privateKey, null); + assert.equal(stripped.masterPasswordHash, undefined); + } finally { + stub.restore(); + } +}); + +test('离线解锁记录只存加密后的密钥与 KDF 参数,profile 同样走白名单', () => { + const stub = installLocalStorageStub(); + try { + saveOfflineUnlockRecord({ + email: EMAIL.toUpperCase(), + profile: fullProfile(), + profileKey: '2.encrypted|user|key', + kdfIterations: 600000, + }); + + const entries = stub.entries(); + assert.equal(entries.length, 1); + const record = JSON.parse(entries[0][1]) as { + version: number; + email: string; + profile: Profile; + profileKey: string; + kdfIterations: number; + savedAt: number; + }; + assert.equal(record.version, 1); + assert.equal(record.email, EMAIL, 'email 必须规范化为小写'); + assert.equal(record.profileKey, '2.encrypted|user|key', '这里存的必须是加密后的密钥(EncString)'); + assert.equal(record.kdfIterations, 600000); + assert.equal(record.profile.key, '', '离线 profile 里的密钥同样要清空'); + assert.equal(record.profile.privateKey, null); + assert.equal(record.profile.masterPasswordHash, undefined, '未知字段必须被丢弃'); + assertNoMarkers(entries, 'saveOfflineUnlockRecord'); + } finally { + stub.restore(); + } +}); + +test('离线解锁记录:缺少加密密钥或 KDF 参数时一律不落盘', () => { + const stub = installLocalStorageStub(); + try { + saveOfflineUnlockRecord({ email: EMAIL, profile: fullProfile(), profileKey: '', kdfIterations: 600000 }); + saveOfflineUnlockRecord({ email: EMAIL, profile: fullProfile(), profileKey: '2.a|b|c', kdfIterations: 0 }); + assert.equal(stub.entries().length, 0, '参数不完整时不应写入任何内容'); + } finally { + stub.restore(); + } +}); diff --git a/scripts/webapp/password-security.test.ts b/scripts/webapp/password-security.test.ts new file mode 100644 index 000000000..0e41e19fc --- /dev/null +++ b/scripts/webapp/password-security.test.ts @@ -0,0 +1,99 @@ +// webapp 纯逻辑测试:密码哈希与弱密码判定(§3.5 方案 A) +// +// 为什么值得测: +// · `sha1Password` 的输出会直接拼进 HaveIBeenPwned 的 k-anonymity 查询 URL。 +// 服务端那一侧(`checkPasswordHashLeaked`)要求 **40 位大写十六进制**, +// 一旦这里的输出变成小写,查询会被判为「哈希非法」,而调用方 +// `checkPasswordLeaked` 会把异常吞掉、返回 `{ count: null, available: false }` —— +// 也就是**泄露检测静默失效**。所以这里用已知向量 + 大小写共同把住。 +// · `isWeakPassword` 是纯判定逻辑,规则边界多(公共密码表 / 长度 / 重复字符 / +// 键盘序列 / 包含用户名 / 字符类别数),值得逐条钉住。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isWeakPassword, sha1Password } from '../../webapp/src/lib/password-security'; + +// ---------------------------------------------------------------- SHA-1 + +test('sha1Password:与已知 SHA-1 向量一致', async () => { + // 这两条是 SHA-1 的公开测试向量,可独立核对 + assert.equal(await sha1Password('password'), '5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8'); + assert.equal(await sha1Password(''), 'DA39A3EE5E6B4B0D3255BFEF95601890AFD80709'); +}); + +test('sha1Password:输出必须是 40 位**大写**十六进制', async () => { + const hash = await sha1Password('any-password'); + + assert.match(hash, /^[A-F0-9]{40}$/, `必须匹配 HaveIBeenPwned 查询所要求的形式,实际:${hash}`); + assert.equal(hash, hash.toUpperCase(), '不得包含小写字母 —— 小写会让泄露检测静默失效(见文件头说明)'); + assert.equal(hash.length, 40); +}); + +test('sha1Password:按 UTF-8 编码(非 ASCII 与中文也要稳定)', async () => { + const first = await sha1Password('密码-with-émoji-🔐'); + const second = await sha1Password('密码-with-émoji-🔐'); + assert.equal(first, second, '同样输入必须得到确定结果'); + + assert.notEqual(await sha1Password('密码'), await sha1Password('', ), '不同输入应得到不同哈希'); + assert.match(first, /^[A-F0-9]{40}$/, '非 ASCII 输入同样产出合法形式'); +}); + +test('sha1Password:大小写敏感(Password 与 password 不是同一个)', async () => { + assert.notEqual(await sha1Password('password'), await sha1Password('Password')); +}); + +// ---------------------------------------------------------------- 弱密码判定 + +test('isWeakPassword:长度不足 10 一律判弱(与是否在公共表里无关)', () => { + assert.equal(isWeakPassword(''), true, '空串'); + assert.equal(isWeakPassword('abc'), true); + assert.equal(isWeakPassword('Ab3!xYz12'), true, '只有 9 位 —— 再复杂也太短'); + assert.equal(isWeakPassword('Ab3!xYz123'), false, '刚好 10 位且类别齐全 → 不再因长度判弱'); +}); + +test('isWeakPassword:重复字符与键盘/数字序列一律判弱', () => { + assert.equal(isWeakPassword('aaaaaaaaaaaaaa'), true, '全同字符'); + assert.equal(isWeakPassword('11111111111111'), true, '全同数字'); + assert.equal(isWeakPassword('0123456789ab'), true, '含连续数字序列'); + assert.equal(isWeakPassword('qwertyuiop12'), true, '含键盘序列'); + assert.equal(isWeakPassword('abcdefghijklm'), true, '含字母表序列'); +}); + +test('isWeakPassword:包含用户名(@ 前的部分,≥3 字符)时判弱', () => { + assert.equal(isWeakPassword('johndoe-secret-99', 'johndoe@example.com'), true); + assert.equal( + isWeakPassword('JOHNDOE-secret-99', 'johndoe@example.com'), + true, + '应忽略大小写' + ); + assert.equal( + isWeakPassword('zz-secret-9911', 'johndoe@example.com'), + false, + '不含用户名,且够长够杂 → 不判弱' + ); +}); + +test('isWeakPassword:用户名过短(< 3 字符)时不参与判定,避免误杀', () => { + // @ 前只有 1~2 个字符时不做包含判断,否则大量密码会被误判 + assert.equal(isWeakPassword('ab-secret-9911', 'ab@example.com'), false); +}); + +test('isWeakPassword:长度在 10~13 且字符类别不足 3 种时判弱', () => { + assert.equal(isWeakPassword('onlylowercase'), true, '12 位但只有 1 种字符类别'); + assert.equal(isWeakPassword('lowercase123'), true, '12 位、2 种类别 → 不足 3 种'); + assert.equal(isWeakPassword('Lowercase123'), false, '12 位、3 种类别 → 通过'); +}); + +test('isWeakPassword:长度达到 14 后不再要求多种字符类别', () => { + assert.equal( + isWeakPassword('onlylowercaseletters'), + false, + '22 位全小写:长度足够长,不再因"类别不足"判弱' + ); +}); + +test('isWeakPassword:长口令不判弱(用户选择 passphrase 时不应被拦)', () => { + assert.equal(isWeakPassword('correct-horse-battery-staple-42'), false); +}); diff --git a/scripts/webapp/website-utils.test.ts b/scripts/webapp/website-utils.test.ts new file mode 100644 index 000000000..f33449b98 --- /dev/null +++ b/scripts/webapp/website-utils.test.ts @@ -0,0 +1,101 @@ +// webapp 纯逻辑测试:站点 URL 解析与图标地址(§3.5 方案 A) +// +// 为什么先测这几个:它们是"点了之后算得对不对"里最基础的一层 —— +// 从条目里挑出要显示的网站、从任意形状的用户输入里解析出主机名。 +// 用户输入的形状极其随意(带不带 scheme、带不带端口、粘了路径或查询串、 +// 全角、前后空白),这里的容错能力直接决定"网站图标能不能显示出来"。 +// +// 运行方式:npm run test:webapp-lib +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { firstCipherUri, hostFromUri, websiteIconUrl } from '../../webapp/src/lib/website-utils'; +import type { Cipher } from '../../webapp/src/lib/types'; + +// ---------------------------------------------------------------- hostFromUri + +test('hostFromUri:完整 URL 取其主机名', () => { + const cases: Array<[string, string, string]> = [ + ['https://example.com', 'example.com', '标准 https'], + ['http://example.com', 'example.com', 'http 同样支持'], + ['https://example.com:8443/login', 'example.com', '端口与路径都不进主机名'], + ['https://sub.example.com/a/b?q=1#f', 'sub.example.com', '子域名保留,查询串与片段丢弃'], + ['HTTPS://EXAMPLE.COM/PATH', 'example.com', 'scheme 与主机名大小写不敏感(主机名归一为小写)'], + ['https://[::1]:8080/x', '[::1]', 'IPv6 字面量保留方括号'], + ['https://192.168.1.1/admin', '192.168.1.1', 'IPv4'], + ['https://xn--fiq228c.example/', 'xn--fiq228c.example', 'punycode 域名原样保留'], + ]; + + for (const [input, expected, why] of cases) { + assert.equal(hostFromUri(input), expected, `hostFromUri(${JSON.stringify(input)}) —— ${why}`); + } +}); + +test('hostFromUri:没有 scheme 时按 https 补全(用户常直接粘域名)', () => { + assert.equal(hostFromUri('example.com'), 'example.com'); + assert.equal(hostFromUri('example.com/login?x=1'), 'example.com'); + assert.equal(hostFromUri('sub.example.com:8443'), 'sub.example.com'); +}); + +test('hostFromUri:空与无法解析的输入返回空串,而不是抛错或返回垃圾', () => { + for (const input of ['', ' ', '\t\n', 'http://', 'https://', 'not a url', '://x', 'http://[bad']) { + assert.equal(hostFromUri(input), '', `hostFromUri(${JSON.stringify(input)}) 应为空串`); + } +}); + +test('hostFromUri:前后空白会被裁掉(用户从地址栏复制常带空白)', () => { + // 这条曾经真的坏过:scheme 判定用的是**未裁剪**的字符串,于是带空白的 + // `https://...` 会被再补一个 scheme,`new URL` 解析失败 → 返回空串(图标永远不显示); + // 而 `\thttps://a.com\n` 更糟 —— 解析器把 `https` 当主机名 → 返回垃圾值 'https'。 + assert.equal(hostFromUri(' https://example.com/x '), 'example.com'); + assert.equal(hostFromUri(' example.com '), 'example.com'); + assert.equal(hostFromUri('\thttps://a.com\n'), 'a.com', '制表符/换行同样要被裁掉,不能产出 `https` 这种垃圾主机名'); + assert.equal(hostFromUri('\n\t sub.example.com:8443/path \n'), 'sub.example.com'); +}); + +// ---------------------------------------------------------------- firstCipherUri + +/** 造一个只带 uris 的最小 Cipher */ +function cipherWithUris(uris: Array<{ uri?: string; decUri?: string }> | undefined): Cipher { + return { login: uris === undefined ? undefined : { uris } } as unknown as Cipher; +} + +test('firstCipherUri:优先返回解密后的 URI,其次返回密文 URI', () => { + assert.equal(firstCipherUri(cipherWithUris([{ uri: 'enc-1', decUri: 'https://decrypted.example' }])), 'https://decrypted.example'); + assert.equal(firstCipherUri(cipherWithUris([{ uri: 'https://plain.example' }])), 'https://plain.example'); +}); + +test('firstCipherUri:跳过空白项,返回第一个真正有内容的', () => { + assert.equal( + firstCipherUri(cipherWithUris([{ decUri: ' ' }, { uri: '' }, {uri: 'https://third.example'}])), + 'https://third.example', + '前面几个空项应被跳过' + ); +}); + +test('firstCipherUri:没有可用 URI 时返回空串(而不是 undefined)', () => { + assert.equal(firstCipherUri(cipherWithUris([])), ''); + assert.equal(firstCipherUri(cipherWithUris(undefined)), '', 'login 缺失时不应抛错'); + assert.equal(firstCipherUri(cipherWithUris([{ uri: ' ' }, { decUri: '' }])), ''); +}); + +test('firstCipherUri:返回值已去掉前后空白', () => { + assert.equal(firstCipherUri(cipherWithUris([{ uri: ' https://example.com ' }])), 'https://example.com'); +}); + +// ---------------------------------------------------------------- websiteIconUrl + +test('websiteIconUrl:主机名要 URL 编码,避免特殊字符破坏路径', () => { + assert.equal(websiteIconUrl('example.com'), '/icons/example.com/icon.png?fallback=404'); + assert.equal( + websiteIconUrl('[::1]'), + '/icons/%5B%3A%3A1%5D/icon.png?fallback=404', + 'IPv6 的方括号与冒号必须编码' + ); + assert.equal( + websiteIconUrl('a/b?c=1'), + '/icons/a%2Fb%3Fc%3D1/icon.png?fallback=404', + '斜杠与问号必须编码,否则会改变路径结构' + ); + assert.ok(websiteIconUrl('example.com').endsWith('?fallback=404'), '应带 fallback 参数供图标服务兜底'); +}); diff --git a/scripts/webauthn-connector-headers.test.ts b/scripts/webauthn-connector-headers.test.ts index d367afba3..50f719256 100644 --- a/scripts/webauthn-connector-headers.test.ts +++ b/scripts/webauthn-connector-headers.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import test from 'node:test'; import type { Env } from '../src/types'; @@ -37,11 +38,11 @@ test('official Bitwarden desktop origin receives credentialed CORS', () => { }); test('Worker assets preserve exact official connector .html paths', async () => { - for (const configUrl of [ - new URL('../wrangler.toml', import.meta.url), - new URL('../wrangler.kv.toml', import.meta.url), - ]) { - const config = await readFile(configUrl, 'utf8'); + for (const fileName of ['wrangler.toml', 'wrangler.kv.toml']) { + // 用字符串路径而非 `readFile(new URL(...))`:Workers 的全局 `URL` 与 + // `node:url` 的 `URL` 类型不兼容(`URLSearchParams` 迭代器缺 `Symbol.dispose`), + // 传 URL 会让 tsc 报 TS2769。 + const config = await readFile(path.join(import.meta.dirname, '..', fileName), 'utf8'); const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ''; assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m); } diff --git a/scripts/yubico-otp-timeout.test.ts b/scripts/yubico-otp-timeout.test.ts new file mode 100644 index 000000000..762e3f8cb --- /dev/null +++ b/scripts/yubico-otp-timeout.test.ts @@ -0,0 +1,152 @@ +// Yubico 外发请求的超时守卫 +// +// 为什么值得测:`src/utils/yubico-otp.ts` 有**两处**外发请求,原先都没有超时。 +// 对端(api.yubico.com / upgrade.yubico.com)连上但不回包时: +// · 登录的二步验证会**挂住**,最后由平台兜底 500 —— 用户既登不进去,也看不到原因; +// · 管理员启用 YubiKey 时的「取 API 凭据」同样挂住。 +// 加了超时之后有两条**不可退化**的语义: +// ① 验证必须 **fail-closed**(超时 ⇒ false ⇒ 记为失败),绝不能变成"超时即通过"; +// ② 配了多个校验地址时要**逐个尝试**,而不是卡死在第一个。 +// +// 运行方式:npm run test:yubico-otp-timeout +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +import type { Env } from '../src/types'; +import { requestYubicoApiCredentials, verifyYubicoOtp } from '../src/utils/yubico-otp'; + +/** 12 位公钥 ID + modhex 余段,总长 34(合法区间 32–48) */ +const OTP = 'ccccccbcgujhcbcdefghijklnrtuvcbcdef'; +const CLIENT_ID = '12345'; +/** base64 形态的校验密钥(Yubico 要求 base64) */ +const SECRET_KEY = Buffer.from('test-secret-key-for-yubico-otp').toString('base64'); +const VALIDATION_URL = 'https://api.yubico.example.test/wsapi/2.0/verify'; +const GET_API_KEY_URL_HOST = 'upgrade.yubico.com'; + +function envWith(validationUrls: string): Env { + return { globalSettings__yubico__validationUrls: validationUrls } as unknown as Env; +} + +const CREDENTIALS = { clientId: CLIENT_ID, secretKey: SECRET_KEY }; + +type FetchStub = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +async function withFetchStub(stub: FetchStub, run: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = stub as typeof fetch; + try { + return await run(); + } finally { + globalThis.fetch = original; + } +} + +/** 黑洞对端:连接建立、但永不回包;尊重 abort(与真实 fetch 被 abort 的行为一致)。 */ +function neverResponds(): FetchStub { + return (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('The operation was aborted'))); + }); +} + +/** + * 独立实现的响应签名(不调用被测代码的任何内部函数): + * 把除 `h` 外的字段按 key 排序拼成 `k=v&…`,再用 HMAC-SHA1(base64 密钥) 取 base64。 + */ +function signResponse(fields: Record): string { + const canonical = Object.keys(fields) + .sort() + .map((key) => `${key}=${fields[key]}`) + .join('&'); + return createHmac('sha1', Buffer.from(SECRET_KEY, 'base64')).update(canonical).digest('base64'); +} + +test('二步验证:对端永不回包时 fail-closed(返回 false),且不会一直挂住', { timeout: 3_000 }, async () => { + await withFetchStub(neverResponds(), async () => { + const started = Date.now(); + const ok = await verifyYubicoOtp(envWith(VALIDATION_URL), OTP, CREDENTIALS, { requestTimeoutMs: 30 }); + assert.equal(ok, false, '超时必须判为验证失败 —— 绝不能因超时放行'); + assert.ok(Date.now() - started < 1_000, '必须在超时预算内返回,而不是挂到平台兜底'); + }); +}); + +test('多校验地址:第一个挂住会继续试下一个,最终仍 fail-closed', { timeout: 3_000 }, async () => { + let calls = 0; + const stub: FetchStub = async (input, init) => { + calls += 1; + const url = String(input); + if (url.includes('first.example.test')) { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('The operation was aborted'))); + }); + } + // 第二个地址立刻回一个「状态不是 OK」的响应 ⇒ 继续循环,最终 false + return new Response('otp=1\r\nnonce=2\r\nstatus=BAD_OTP\r\n', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + }; + await withFetchStub(stub, async () => { + const ok = await verifyYubicoOtp( + envWith('https://first.example.test/wsapi/2.0/verify,https://second.example.test/wsapi/2.0/verify'), + OTP, + CREDENTIALS, + { requestTimeoutMs: 30 } + ); + assert.equal(ok, false); + assert.equal(calls, 2, '第一个地址超时后必须继续尝试第二个'); + }); +}); + +test('正常路径不被超时封装破坏:签名合法的 OK 响应仍然通过', async () => { + const fields: Record = { + otp: OTP, + nonce: 'abc', + status: 'OK', + t: '2026-09-17T00:00:00Z0000', + }; + const stub: FetchStub = async (input) => { + // 回显请求里的 nonce,并给出正确签名 —— 校验逻辑会检查 otp/nonce/status/h + const requestedNonce = new URL(String(input)).searchParams.get('nonce') || ''; + const responseFields = { ...fields, nonce: requestedNonce }; + const lines = Object.entries({ ...responseFields, h: signResponse(responseFields) }) + .map(([key, value]) => `${key}=${value}`) + .join('\r\n'); + return new Response(lines, { status: 200, headers: { 'Content-Type': 'text/plain' } }); + }; + await withFetchStub(stub, async () => { + const ok = await verifyYubicoOtp(envWith(VALIDATION_URL), OTP, CREDENTIALS, { requestTimeoutMs: 2_000 }); + assert.equal(ok, true, '签名校验通过时必须返回 true(超时封装不能打断正常路径)'); + }); +}); + +test('二步验证:非法 OTP / 缺凭据仍然直接 false(回归)', async () => { + await withFetchStub(neverResponds(), async () => { + assert.equal(await verifyYubicoOtp(envWith(VALIDATION_URL), 'not-an-otp', CREDENTIALS), false); + assert.equal(await verifyYubicoOtp(envWith(VALIDATION_URL), OTP, null), false); + }); +}); + +test('取 API 凭据:对端永不回包时返回 null(走调用方的 400 文案,而不是平台 500)', { timeout: 3_000 }, async () => { + await withFetchStub(neverResponds(), async () => { + const started = Date.now(); + const result = await requestYubicoApiCredentials('admin@example.test', OTP, { requestTimeoutMs: 30 }); + assert.equal(result, null); + assert.ok(Date.now() - started < 1_000, '必须在超时预算内返回 null'); + }); +}); + +test('取 API 凭据:正常响应仍能解析出 clientId / secretKey(回归)', async () => { + const stub: FetchStub = async (input) => { + assert.ok(String(input).includes(GET_API_KEY_URL_HOST), '应请求 Yubico 的 getapikey 端点'); + return new Response( + '
Client ID:98765
Secret key:c2VjcmV0
', + { status: 200, headers: { 'Content-Type': 'text/html' } } + ); + }; + await withFetchStub(stub, async () => { + const result = await requestYubicoApiCredentials('admin@example.test', OTP, { requestTimeoutMs: 2_000 }); + assert.deepEqual(result, { clientId: '98765', secretKey: 'c2VjcmV0' }); + }); +}); diff --git a/shared/backup-schema.ts b/shared/backup-schema.ts index f1fb59185..469656d07 100644 --- a/shared/backup-schema.ts +++ b/shared/backup-schema.ts @@ -161,3 +161,19 @@ export function createDefaultBackupSettings( ], }; } + +/** + * 目标是否已完成必要配置。 + * + * 新建 / 迁移时会由 createDefaultBackupSettings 自动生成一个「配置为空」的占位目标 + * (WebDAV 的 baseUrl 为空、S3 的 endpoint/bucket 为空)。这类目标尚不具备远端访问 + * 能力,调远端接口必然失败,而失败原因并不是真正的错误。 + * 调用方据此跳过自动列举,避免把「尚未配置」当成错误提示给用户。 + */ +export function isBackupDestinationConfigured(destination: BackupDestinationRecord): boolean { + const config = destination.destination as unknown as Record; + if (destination.type === 's3') { + return String(config.endpoint ?? '').trim() !== '' && String(config.bucket ?? '').trim() !== ''; + } + return String(config.baseUrl ?? '').trim() !== ''; +} diff --git a/shared/backup-timeout-message.ts b/shared/backup-timeout-message.ts new file mode 100644 index 000000000..5277c6eae --- /dev/null +++ b/shared/backup-timeout-message.ts @@ -0,0 +1,49 @@ +// 远端备份超时消息的**唯一定义处**。 +// +// 这句话同时被三方依赖: +// ① 后端构造它(`RemoteRequestTimeoutError`,见 `src/services/backup-uploader.ts`); +// ② 后端按它判断「是不是超时」⇒ 决定回 **400(不可重试)** 还是 500 +// (500 会被前端 `retryableRequest` 自动重试 3 次,把一次超时放大成三倍等待); +// ③ 前端按它把消息映射成**本地化文案**(`webapp/src/lib/i18n.ts`,还把毫秒换算成秒)。 +// +// 原先 ② 与 ③ 各写了一份正则:任何一侧改措辞,另一侧都会**静默失配** —— +// 用户看到英文原文,或者超时被当成 500 触发自动重试。现在三者共用本文件, +// 测试里的样例也由 `buildRemoteTimeoutMessage()` 生成 ⇒ 改一处就会被测试拦住。 + +/** 远端备份的步骤名。措辞会进用户可见的消息 ⇒ 改动需同步 i18n 映射与测试样例 */ +export const REMOTE_REQUEST_ACTIONS = [ + 'directory creation', + 'upload', + 'listing', + 'download', + 'delete', + 'existence check', +] as const; + +export type RemoteRequestAction = (typeof REMOTE_REQUEST_ACTIONS)[number]; + +export type RemoteRequestProvider = 'WebDAV' | 'S3'; + +/** + * 消息形状的正则**源字符串**(刻意不导出 `RegExp` 对象: + * 后端只要 `.test()`、前端要用第一个捕获组取毫秒数,各自 `new RegExp(...)` 更清楚)。 + */ +export const REMOTE_TIMEOUT_MESSAGE_SOURCE = + `^(?:WebDAV|S3) (?:${REMOTE_REQUEST_ACTIONS.join('|')}) timed out after (\\d+) ms$`; + +/** + * 共享的正则实例。 + * ⚠️ **不要加 `g` / `y` 标志** —— 那种标志下 `RegExp` 自带 `lastIndex` 状态, + * 共享同一个实例会让多次调用互相干扰。需要 `i` 之类的容错也请改源字符串, + * 而不是在调用侧另写一个正则(那就又分叉了)。 + */ +export const REMOTE_TIMEOUT_MESSAGE_PATTERN = new RegExp(REMOTE_TIMEOUT_MESSAGE_SOURCE); + +/** 构造超时消息(后端唯一的拼装点,测试也用它生成样例) */ +export function buildRemoteTimeoutMessage( + provider: RemoteRequestProvider, + action: RemoteRequestAction, + timeoutMs: number +): string { + return `${provider} ${action} timed out after ${timeoutMs} ms`; +} diff --git a/shared/domain-normalize.ts b/shared/domain-normalize.ts index b478f9d3b..ae908f942 100644 --- a/shared/domain-normalize.ts +++ b/shared/domain-normalize.ts @@ -114,10 +114,18 @@ function extractHost(input: string): string { if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex); } - return raw - .replace(/^\*+\./, '') - .replace(/^\.+/, '') - .replace(/\.+$/, ''); + // 用显式循环代替 /^\*+\./、/^\.+/、/\.+$/ 三个正则(语义完全一致): + // CodeQL 的 js/polynomial-redos 会对"尾部量词 + 长串同一字符"报"可能变慢", + // 这里不引入任何回溯,长输入也只是线性扫描。 + let start = 0; + let stars = 0; + while (start + stars < raw.length && raw[start + stars] === '*') stars += 1; + // 只有"星号后面紧跟着点号"才能一起吃掉(与原 /^\*+\./ 一致) + if (stars > 0 && raw[start + stars] === '.') start += stars + 1; + while (start < raw.length && raw[start] === '.') start += 1; + let end = raw.length; + while (end > start && raw[end - 1] === '.') end -= 1; + return raw.slice(start, end); } function isValidHost(host: string): boolean { diff --git a/src/config-response.ts b/src/config-response.ts index df7631ecb..87616f272 100644 --- a/src/config-response.ts +++ b/src/config-response.ts @@ -39,7 +39,12 @@ export function buildConfigResponse(origin: string) { 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled, 'desktop-ui-settings-dialog': true, 'duo-redirect': true, - 'email-verification': true, + // 本服务器不提供邮件发送通道:/accounts/register/send-verification-email、 + // /accounts/verify-email、/api/two-factor/send-email-login 等端点一律返回 501 + // “Email delivery is not supported by this server.”(见 router-public.ts / router-authenticated.ts)。 + // 因此不能告诉客户端“支持邮箱验证”,否则客户端会展示相应的设置项并调用注定失败的接口。 + // 将来接入邮件能力(见 docs/TODO/MAIL.md)时再改回 true。 + 'email-verification': false, 'fill-assist-targeting-rules': true, 'pm-19051-send-email-verification': false, 'pm-19148-innovation-archive': true, diff --git a/src/config/limits.ts b/src/config/limits.ts index 1d855e18a..9ab89ef01 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -115,6 +115,15 @@ // 服务端允许的最大分页大小。 maxPageSize: 500, }, + device: { + // Max device identifiers accepted in one batch request body. + // 单次批量请求里允许出现的设备标识数上限。 + // + // 这些列表直接来自客户端请求体(`body.otherDevices` / `body.devices`), + // 没有天然上界。不设限的话,"逐条 IO"会变成客户端可控的线性放大器: + // 每个条目触发一次数据库往返,条数由请求方随意决定。 + maxBulkIdentifiers: 50, + }, cors: { // Browser preflight cache max age in seconds. // 浏览器预检请求缓存时长(秒)。 diff --git a/src/durable/backup-transfer-runner.ts b/src/durable/backup-transfer-runner.ts index 35b1d5286..2e9a30c75 100644 --- a/src/durable/backup-transfer-runner.ts +++ b/src/durable/backup-transfer-runner.ts @@ -11,6 +11,8 @@ import { createRemoteBackupTransferSession, downloadRemoteBackupFile, ensureRemoteRestoreCandidate, + isRemoteRequestTimeoutError, + remoteRequestFailureStatus, } from '../services/backup-uploader'; import { getBlobObject } from '../services/blob-store'; import { StorageService } from '../services/storage'; @@ -90,20 +92,29 @@ export class BackupTransferRunner { private async acquireJob(reason: string): Promise { const nowMs = Date.now(); - const current = await this.state.storage.get(BACKUP_JOB_STATE_KEY); - if (current?.expiresAtMs && current.expiresAtMs > nowMs) { - return null; - } - const token = crypto.randomUUID(); const nowIso = new Date(nowMs).toISOString(); - await this.state.storage.put(BACKUP_JOB_STATE_KEY, { - token, - reason, - acquiredAt: nowIso, - touchedAt: nowIso, - expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS, + + // 「读租约 → 判断 → 写租约」必须在同一个事务内完成。 + // 若写成 await get() 之后再 await put(),两个并发请求可能都读到「当前无租约」, + // 于是双双拿到 token,导致两个备份同时运行 —— 而本 DO 的唯一职责就是阻止这件事。 + // 与 notifications-hub.ts 中 ws-token 的单次消费同构(那里也注明了必须放进事务)。 + const acquired = await this.state.storage.transaction(async (txn) => { + const current = await txn.get(BACKUP_JOB_STATE_KEY); + if (current?.expiresAtMs && current.expiresAtMs > nowMs) { + return false; + } + await txn.put(BACKUP_JOB_STATE_KEY, { + token, + reason, + acquiredAt: nowIso, + touchedAt: nowIso, + expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS, + }); + return true; }); + + if (!acquired) return null; this.lastHeartbeatAt = 0; return token; } @@ -113,21 +124,29 @@ export class BackupTransferRunner { if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return; this.lastHeartbeatAt = nowMs; - const current = await this.state.storage.get(BACKUP_JOB_STATE_KEY); - if (current?.token !== token) return; + // 事务内比对 token 后再续期,避免用过期快照覆盖掉新持有者的租约。 + await this.state.storage.transaction(async (txn) => { + const current = await txn.get(BACKUP_JOB_STATE_KEY); + if (current?.token !== token) return; - await this.state.storage.put(BACKUP_JOB_STATE_KEY, { - ...current, - touchedAt: new Date(nowMs).toISOString(), - expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS, + await txn.put(BACKUP_JOB_STATE_KEY, { + ...current, + touchedAt: new Date(nowMs).toISOString(), + expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS, + }); }); } private async releaseJob(token: string): Promise { - const current = await this.state.storage.get(BACKUP_JOB_STATE_KEY); - if (current?.token === token) { - await this.state.storage.delete(BACKUP_JOB_STATE_KEY); - } + // 必须在事务内「比对 token → 删除」。否则存在该交错: + // A: get() 读到 token=A → B: acquireJob() 覆盖为 token=B + // → A: 依据先前快照判断通过 → delete() 把 B 的租约删掉,并发保护失效。 + await this.state.storage.transaction(async (txn) => { + const current = await txn.get(BACKUP_JOB_STATE_KEY); + if (current?.token === token) { + await txn.delete(BACKUP_JOB_STATE_KEY); + } + }); } private async runConfiguredBackup(request: Request): Promise { @@ -196,7 +215,7 @@ export class BackupTransferRunner { }, }); } catch (error) { - return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500); + return badRequest(error instanceof Error ? error.message : 'Backup run failed', remoteRequestFailureStatus(error)); } finally { await this.releaseJob(token); } @@ -263,7 +282,7 @@ export class BackupTransferRunner { }, }); } catch (error) { - return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500); + return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', remoteRequestFailureStatus(error)); } finally { await this.releaseJob(token); } @@ -340,13 +359,27 @@ export class BackupTransferRunner { }, }); } catch (error) { - return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500); + return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', remoteRequestFailureStatus(error)); } finally { await this.releaseJob(token); } } async fetch(request: Request): Promise { + // 兜底:任何逃逸出来的错误都必须变成**可读且状态正确**的响应。 + // 否则 DO 抛错会让平台返回通用 500(`internal error; reference = …`),管理员无法自助排查; + // 而 500 还会被前端 `retryableRequest` 自动重试 3 次,把一次失败放大成三倍等待。 + try { + return await this.route(request); + } catch (error) { + return badRequest( + error instanceof Error ? error.message : 'Backup transfer request failed', + remoteRequestFailureStatus(error) + ); + } + } + + private async route(request: Request): Promise { const url = new URL(request.url); if (request.method !== 'POST') { return badRequest('Not found', 404); @@ -375,7 +408,12 @@ export class BackupTransferRunner { if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) { return badRequest('Remote attachment download payload is invalid'); } - const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null); + const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch((error: unknown) => { + // 远端超时说明目标整体不可用 ⇒ 让它冒泡到 fetch() 的兜底(可读的 4xx), + // 而不是伪装成「附件不存在」;其余错误保持既有的「不存在」语义。 + if (isRemoteRequestTimeoutError(error)) throw error; + return null; + }); if (!file) { return badRequest('Remote attachment not found', 404); } @@ -409,7 +447,12 @@ export class BackupTransferRunner { const files: Record = {}; for (let i = 0; i < blobNames.length; i += 1) { const blobName = blobNames[i]; - const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null); + const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch((error: unknown) => { + // 同上;而且这里一批最多 40 个:逐个等满超时等于把等待时间乘以 40, + // 所以第一个超时就结束整批,让错误变成一条可读的 4xx。 + if (isRemoteRequestTimeoutError(error)) throw error; + return null; + }); if (!file) continue; const path = `files/${i}.bin`; entries.push({ blobName, path }); @@ -444,6 +487,8 @@ export class BackupTransferRunner { const remoteSession = createRemoteBackupTransferSession(body.destination); let uploaded = 0; + // 刻意**不**在循环里 catch:每次 putFile 都有超时,而同一目标不可用时失败几乎必然重复 —— + // 第一个超时就该让整批立刻结束(冒泡到 fetch() 的兜底),而不是把 18 个附件逐个等满超时。 for (const attachment of body.attachments) { const blobName = String(attachment?.blobName || '').trim(); if (!isSafeBackupAttachmentBlobName(blobName)) { diff --git a/src/handlers/account-passkeys.ts b/src/handlers/account-passkeys.ts index b2ab0054a..9d4a27e8a 100644 --- a/src/handlers/account-passkeys.ts +++ b/src/handlers/account-passkeys.ts @@ -7,7 +7,7 @@ import { import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; -import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; +import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey'; import { diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 89860e48e..1b32b48c9 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -7,7 +7,7 @@ import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; import { isStoredApiKeyHash } from '../utils/api-key'; -import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; +import { findMatchingTotpCounter, isTotpEnabled, isValidTotpSecret } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; import { buildProfileResponse } from '../utils/profile-response'; @@ -543,7 +543,6 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st // the user's preference. export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); - const auth = new AuthService(env); const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); @@ -906,9 +905,19 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env const verified = await verifyUserSecret(auth, user, secret); if (!verified) return errorResponse('User verification failed.', 400); - const key = normalizeTotpSecret(user.totpSecret || '') || randomBase32Secret(); + const storedKey = normalizeTotpSecret(user.totpSecret || ''); + // 与官方客户端兼容:库里没有密钥时,这里要**现场生成一把**供客户端的“启用验证器”流程使用 + // (客户端拿它当二维码显示,用户提交的 PUT 会把同一把存回去)。 + // 因此调用方**必须**靠 `Enabled` 区分“已保存的真值”与“本次待启用的新值”。 + // + // `Enabled` 与返回值保持一致也修掉了另一处隐患:过去用的是 `!!user.totpSecret`, + // 遇到“有值但不可用”(字母表非法)时会回 `Enabled: true` + 一把**随机**密钥, + // 客户端就会把随机值当“当前密钥”展示。现在这种情形会回 `Enabled: false` + 新密钥, + // 用户再启用一次就能自愈。 + const hasUsableStoredKey = isValidTotpSecret(storedKey); + const key = hasUsableStoredKey ? storedKey : randomBase32Secret(); const userVerificationToken = await createTotpUserVerificationToken(env, user, key); - return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken)); + return jsonResponse(twoFactorAuthenticatorResponse(hasUsableStoredKey, key, userVerificationToken)); } // POST /api/two-factor/get-yubikey @@ -1004,7 +1013,7 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env if (!await verifyTotpUserVerificationToken(env, user, key, userVerificationToken)) { return errorResponse('User verification failed.', 400); } - if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400); + if (!isValidTotpSecret(key)) return errorResponse('Invalid TOTP secret', 400); const matchedCounter = await findMatchingTotpCounter(key, token); if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) { return errorResponse('Invalid token.', 400); @@ -1189,7 +1198,6 @@ export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, en initialized?.credentials ? 403 : 400 ); } - credentials = initialized.credentials; } await writeAuditEvent(storage, { @@ -1293,7 +1301,7 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st const normalizedSecret = normalizeTotpSecret(body.secret || ''); const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']); const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']); - if (!isTotpEnabled(normalizedSecret)) { + if (!isValidTotpSecret(normalizedSecret)) { return errorResponse('Invalid TOTP secret', 400); } if (!body.token) { diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 8725c615d..a368f924b 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -9,6 +9,43 @@ function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; } +/** 英文原文同时也是 `webapp/src/lib/i18n.ts` 映射表的键(改动必须两边同步) */ +const LAST_ACTIVE_ADMIN_MESSAGE = 'This is the last active administrator. Promote another user first.'; + +/** + * 管理端**写**操作前用库里的最新状态复核操作者。 + * + * 为什么需要:`actorUser` 来自 `AuthService` 的 isolate 级缓存(TTL 15 s,见 + * `AUTH_CONTEXT_CACHE_TTL_MS`),而 ban / 删除只清**当前 isolate** 的缓存 —— + * 其余 isolate 上「刚被别的管理员 ban 掉的人」还能按 active 管理员继续操作最多 15 s。 + * 管理端写操作本来极低频,多一次主键查询换掉这个窗口很划算。 + */ +async function resolveFreshAdmin(storage: StorageService, actorUser: User): Promise { + const fresh = await storage.getUserById(actorUser.id); + return fresh && isAdmin(fresh) ? fresh : null; +} + +/** + * 「最后一个还能用的管理员」保护。 + * + * 为什么不变量不显然:能走到这里的操作者本身必须是 active 管理员,而各 handler 都有 + * 「不能对自己动手」的检查 ⇒ 单看代码似乎永远归不到零。但有两个漏口: + * ① 上面的 15 s 缓存窗口(A 被 B ban 后,A 仍可能以管理员身份删/封 B); + * ② 将来新增的批量操作 / 恢复流程。 + * 一旦归零,`ensureAdminUserExists()` 要等下次 schema 重建才兜底,而恢复归档会立刻触发 —— + * 也就是「把别人的备份恢复进来」会静默决定谁成为管理员。所以把不变量写成显式断言。 + * + * 导出**仅为可测试性**:handler 路径上它当前不可达(操作者自己就是 active 管理员 ⇒ + * 计数至少为 2,或者命中「不能对自己动手」),所以只能直接对它做单测。 + */ +export async function guardLastActiveAdmin(storage: StorageService, target: User): Promise { + // 只有「移除一个还能用的管理员」才有风险:把 user 改成 banned、把 banned 恢复成 active 都安全。 + if (target.role !== 'admin' || target.status !== 'active') return null; + const activeAdmins = await storage.countActiveAdmins(); + if (activeAdmins > 1) return null; + return errorResponse(LAST_ACTIVE_ADMIN_MESSAGE, 400); +} + async function requireMasterPasswordHash( env: Env, actorUser: User, @@ -96,20 +133,22 @@ export async function handleAdminListUsers( } const storage = new StorageService(env.DB); - const users = await storage.getAllUsers(); - const data = await Promise.all(users.map(async user => { - const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0; - return { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - status: user.status, - twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey, - creationDate: user.createdAt, - revisionDate: user.updatedAt, - object: 'user', - }; + // 曾经是:先取全部用户,再对**每个用户**调一次 countAccountPasskeyCredentialsByUserId() + // —— 用户表有多大,就发多少条 SQL。列表只需要"有没有",一次 DISTINCT 即可。 + const [users, twoFactorPasskeyUserIds] = await Promise.all([ + storage.getAllUsers(), + storage.listAccountPasskeyUserIds('twoFactor'), + ]); + const data = users.map(user => ({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + status: user.status, + twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || twoFactorPasskeyUserIds.has(user.id), + creationDate: user.createdAt, + revisionDate: user.updatedAt, + object: 'user', })); return jsonResponse({ data, @@ -351,24 +390,34 @@ export async function handleAdminSetUserStatus( return errorResponse('Forbidden', 403); } + const storage = new StorageService(env.DB); + const freshActor = await resolveFreshAdmin(storage, actorUser); + if (!freshActor) { + return errorResponse('Forbidden', 403); + } + const body = await readJsonBody(request); - const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + const passwordError = await requireMasterPasswordHash(env, freshActor, body.masterPasswordHash); if (passwordError) return passwordError; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; if (!nextStatus) { return errorResponse('status must be active or banned', 400); } - if (targetUserId === actorUser.id && nextStatus !== 'active') { + if (targetUserId === freshActor.id && nextStatus !== 'active') { return errorResponse('You cannot ban yourself', 400); } - const storage = new StorageService(env.DB); const target = await storage.getUserById(targetUserId); if (!target) { return errorResponse('User not found', 404); } + if (nextStatus === 'banned') { + const lastAdminError = await guardLastActiveAdmin(storage, target); + if (lastAdminError) return lastAdminError; + } + target.status = nextStatus; target.updatedAt = new Date().toISOString(); await storage.saveUser(target); @@ -376,7 +425,7 @@ export async function handleAdminSetUserStatus( await storage.deleteRefreshTokensByUserId(target.id); } AuthService.invalidateUserCache(target.id); - await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { + await writeAuditLog(storage, freshActor.id, 'admin.user.status', 'user', target.id, { status: nextStatus, }, request); @@ -399,20 +448,28 @@ export async function handleAdminDeleteUser( if (!isAdmin(actorUser)) { return errorResponse('Forbidden', 403); } - if (targetUserId === actorUser.id) { + + const storage = new StorageService(env.DB); + const freshActor = await resolveFreshAdmin(storage, actorUser); + if (!freshActor) { + return errorResponse('Forbidden', 403); + } + if (targetUserId === freshActor.id) { return errorResponse('You cannot delete yourself', 400); } const body = await readJsonBody(request); - const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + const passwordError = await requireMasterPasswordHash(env, freshActor, body.masterPasswordHash); if (passwordError) return passwordError; - const storage = new StorageService(env.DB); const target = await storage.getUserById(targetUserId); if (!target) { return errorResponse('User not found', 404); } + const lastAdminError = await guardLastActiveAdmin(storage, target); + if (lastAdminError) return lastAdminError; + // Clean up R2 files before DB cascade deletes the metadata rows. // 1. Attachment files (keyed by cipherId/attachmentId) const attachmentMap = await storage.getAttachmentsByUserId(target.id); @@ -438,7 +495,7 @@ export async function handleAdminDeleteUser( await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteUserById(target.id); AuthService.invalidateUserCache(target.id); - await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { + await writeAuditLog(storage, freshActor.id, 'admin.user.delete', 'user', target.id, { targetEmail: target.email, }, request); diff --git a/src/handlers/auth-requests.ts b/src/handlers/auth-requests.ts index c09fd9c57..0b6f7f801 100644 --- a/src/handlers/auth-requests.ts +++ b/src/handlers/auth-requests.ts @@ -321,11 +321,23 @@ export async function handleListPendingAuthRequests(request: Request, env: Env, const storage = new StorageService(env.DB); await storage.pruneExpiredAuthRequests(); const authRequests = await storage.listPendingAuthRequestsByUserId(userId); - const rows = await Promise.all(authRequests.map(async (authRequest) => { - const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier); - return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier); - })); - return jsonResponse(listResponse(rows)); + + // 这里曾对每条请求调一次 `storage.getDevice(userId, authRequest.requestDeviceIdentifier)`, + // 再把结果回退成 `device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier`。 + // + // 但 `getDevice` 正是**按 deviceIdentifier 查**的 + // (`WHERE user_id = ? AND device_identifier = ?`),返回值的 deviceIdentifier + // 必定等于入参 —— 也就是说那 N 次查询的结果恒等于入参本身,整个循环是在做白工。 + // + // 而本路径是客户端"登录审批"的**轮询**接口,每条待处理请求都会触发一次查询。 + // 本轮直接去掉:保留入参作为 `requestDeviceId`,行为完全一致。 + return jsonResponse( + listResponse( + authRequests.map((authRequest) => + toAuthRequestResponse(request, authRequest, authRequest.requestDeviceIdentifier) + ) + ) + ); } export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise { diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 2b187ae19..a5715bae4 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -41,11 +41,13 @@ import { ensureRemoteRestoreCandidate, listRemoteBackupEntries, pruneRemoteBackupArchives, + remoteRequestFailureStatus, uploadBackupArchive, } from '../services/backup-uploader'; +import { reportProgress } from '../services/backup-progress'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; -import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; +import { auditRequestMetadata, safeWriteAuditEvent, writeAuditEvent } from '../services/audit-events'; import { getBlobObject } from '../services/blob-store'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { getMultipartRequestMaxBytes } from '../utils/direct-upload'; @@ -136,8 +138,22 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null }; } +/** + * 去掉字符串首尾的 `/`。 + * + * 用循环而不是 `/^\/+|\/+$/g`:语义一致,但不含"尾部量词", + * 因此不会命中 CodeQL 的 js/polynomial-redos(它会提示"长串同一字符时可能变慢")。 + */ +function trimSlashes(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value[start] === '/') start += 1; + while (end > start && value[end - 1] === '/') end -= 1; + return value.slice(start, end); +} + function ensureBackupBlobName(value: string): string { - const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const normalized = trimSlashes(String(value || '').trim().replace(/\\/g, '/')); if (!normalized) { throw new Error('Backup attachment blob is required'); } @@ -311,6 +327,10 @@ export async function executeConfiguredBackup( trigger: 'manual' | 'scheduled', destinationId?: string | null, keepAlive?: (() => Promise) | null, + /** + * 进度回调。**必须**自行吞掉异常,且调用方必须走 `reportProgress()` + * (见 `services/backup-progress.ts` 的 CONTRACT)。 + */ progress?: ((event: { operation: 'backup-remote-run'; step: string; @@ -332,17 +352,20 @@ export async function executeConfiguredBackup( const now = new Date(); await touchLease(); + // ⚠️ 这里刻意**不**清空 `lastErrorAt` / `lastErrorMessage`(docs/TODO 第 18 条): + // 失败不会更新 `lastSuccessAt` ⇒ 计划任务会在容差窗口内**立刻重试**, + // 若在尝试开始时就清空,错误就会在「清空 → 30 s 后写回 → 立刻又清空」的循环里 + // 几乎永远看不到(真机验收时 `backup.runtime` 读到 None,而同一时刻审计日志 + // 每 30 s 一条失败记录)。清空只发生在**成功**分支(下面写 `lastSuccessAt` 那处)。 destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({ ...runtime, lastAttemptAt: now.toISOString(), lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone), - lastErrorAt: null, - lastErrorMessage: null, })); try { await touchLease(); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_prepare', fileName: '', @@ -358,7 +381,7 @@ export async function executeConfiguredBackup( if (event.step === 'archive_ready') { return; } - await progress({ + await reportProgress(progress, { operation: 'backup-remote-run', step: `remote_run_${event.step}`, fileName: event.fileName || '', @@ -368,7 +391,7 @@ export async function executeConfiguredBackup( } : undefined, }); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_sync_attachments', fileName: archive.fileName, @@ -403,7 +426,7 @@ export async function executeConfiguredBackup( let uploadVerificationMethod: 'metadata' | 'download' | null = null; for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { await touchLease(); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_upload_archive', fileName: archive.fileName, @@ -413,7 +436,7 @@ export async function executeConfiguredBackup( upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName); try { await touchLease(); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_verify_archive', fileName: archive.fileName, @@ -437,7 +460,7 @@ export async function executeConfiguredBackup( let pruneErrorMessage: string | null = null; try { await touchLease(); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_cleanup', fileName: archive.fileName, @@ -474,7 +497,7 @@ export async function executeConfiguredBackup( ...(auditMetadata || {}), }); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_complete', fileName: archive.fileName, @@ -505,7 +528,7 @@ export async function executeConfiguredBackup( error: errorMessage, ...(auditMetadata || {}), }); - await progress?.({ + await reportProgress(progress, { operation: 'backup-remote-run', step: 'remote_run_failed', fileName: '', @@ -575,6 +598,29 @@ async function runScheduledBackupsInDurableObject(env: Env): Promise { method: 'POST', }); if (response.status === 409) { + // 租约还在上一次运行手里(最长 10 分钟,`BACKUP_JOB_LEASE_MS`)。 + // 这里过去是**直接 return** ⇒ 「这一轮计划任务被跳过」没有任何留痕 + // (不报错、日志里全 200),排查时极易误判成「超时没生效 / 计划任务没跑」 + // (docs/TODO 第 19 条)。cron 是每 5 分钟一次、只在真有重叠时才会走到这里, + // 所以不会刷屏;审计事件让它能在日志中心里被看到。 + let message = 'Another backup run is already in progress'; + try { + const body = await response.json<{ error?: string }>(); + if (body?.error) message = body.error; + } catch { + // Preserve the default message when the DO returns a non-JSON error. + } + console.warn(`scheduled backup skipped: ${message}`); + await safeWriteAuditEvent(env, { + actorUserId: null, + action: 'backup.scheduled.skipped', + category: 'system', + level: 'warn', + targetType: 'backup', + // ⚠️ 键名必须在 audit-events.ts 的 ALLOWED_METADATA_KEYS 里, + // 否则 sanitizeMetadata 会**静默丢弃**(日志中心里只剩动作名,等于没留痕)。 + metadata: { reason: 'lease_held', error: message }, + }); return; } if (!response.ok) { @@ -838,7 +884,7 @@ async function runImportAndAudit( targetDeviceIdentifier ); }; - await progress({ + await reportProgress(progress, { source: 'local', step: 'local_upload_received', fileName, @@ -1002,7 +1048,11 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, settings: redactBackupSettingsSecrets(outcome.settings), }); } catch (error) { - return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); + // DO 已经把握时映射成 400(不可重试);这里不能无条件落回 500,否则会被前端自动重试 3 次 + return errorResponse( + error instanceof Error ? error.message : 'Backup run failed', + remoteRequestFailureStatus(error, 500) + ); } } @@ -1157,7 +1207,8 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, return jsonResponse(imported); } catch (error) { const message = error instanceof Error ? error.message : 'Remote backup restore failed'; - return errorResponse(message, toImportStatusCode(message)); + // 同上:远端超时要保持不可重试的 4xx,不能被 toImportStatusCode 的默认值当成服务端错误 + return errorResponse(message, remoteRequestFailureStatus(message, toImportStatusCode(message))); } } @@ -1201,6 +1252,11 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU }; archive = await buildBackupArchive(env, new Date(), { includeAttachments: !!body?.includeAttachments, + // 本地导出给用户的是一个可下载、可再导入的 zip,附件必须内联进归档; + // 否则归档会声称 includes.attachments: true 却没有任何附件字节, + // 且会被本地导入以 "missing required file" 拒绝。 + // (远端备份不走这里,它依赖 manifest.attachmentBlobs 做单独增量上传。) + inlineAttachmentBlobs: !!body?.includeAttachments, progress, }); } catch (error) { diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 2298433a1..367adb421 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -234,6 +234,20 @@ function optionalEncStringWithin(value: unknown, maxLength: number): string | nu return normalized.length <= maxLength ? normalized : null; } +/** + * 客户端传来的 `cipher.key` 不是合法加密串时的响应。 + * + * 措辞很重要:本服务器**支持**逐项密钥(`config-response.ts` 里 + * `'cipher-key-encryption': true`;合法的 EncString 会被原样保存,见 `normalizeCipherKeyForStorage`)。 + * 这条 400 只是说“这次发来的值不是合法加密串” —— 与“支不支持这个特性”是两回事。 + * + * 旧文案是 “Cipher key encryption is not supported by this server. Resync the client and try again.”, + * 两个毛病:① 把原因归错了(会让人以为服务器不支持逐项密钥); + * ② 给了无效建议(重新同步不会让畸形值变成合法值)。 + */ +const INVALID_CIPHER_KEY_MESSAGE = + 'The cipher key sent by the client is not a valid encrypted string. Update the client and try again.'; + function shouldAcceptCipherKey(value: unknown): boolean { return value == null || value === '' || isValidEncString(value); } @@ -963,7 +977,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str const createPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); if (createKey.present && !shouldAcceptCipherKey(createKey.value)) { - return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400); + return errorResponse(INVALID_CIPHER_KEY_MESSAGE, 400); } const now = new Date().toISOString(); @@ -1054,9 +1068,12 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str && (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true); if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) { - return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400); + return errorResponse(INVALID_CIPHER_KEY_MESSAGE, 400); } + // 注:带附件迁移元数据的请求会跳过上面的 stale 检查。原因是附件上传流程 + // 可能携带旧的 revisionDate,而此处不应因此拒绝。该豁免仅使得「用户覆盖 + // 自己的数据」成为可能(应用层并发),不构成跳用户影响;请勿扩大豁免范围。 if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); } diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 23bb0447c..83ddb76c9 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -3,6 +3,7 @@ import { Env } from '../types'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { AuthService } from '../services/auth'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; +import { LIMITS } from '../config/limits'; import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; @@ -561,21 +562,31 @@ export async function handleUpdateDeviceTrust( } if (Array.isArray(body?.otherDevices)) { + // 这份列表来自请求体,没有天然上界;不设限时下面的"逐条读 + 逐条写" + // 就会变成客户端可控的线性放大器。超限直接拒绝,而不是静默截断 + // (静默截断会让客户端以为全部更新成功)。 + if (body.otherDevices.length > LIMITS.device.maxBulkIdentifiers) { + return errorResponse(`Too many devices in one request (max ${LIMITS.device.maxBulkIdentifiers})`, 400); + } + + // 一次取回该用户的全部设备,避免"每台设备查一次"。 + // 原来这里是 `await storage.getDevice(...)` 写在循环里。 + const existingByIdentifier = new Map( + (await storage.getDevicesByUserId(userId)).map((device) => [device.deviceIdentifier, device]) + ); + for (const item of body.otherDevices) { const deviceIdentifier = normalizeIdentifier(item?.deviceId); if (!deviceIdentifier) continue; updates.push({ deviceIdentifier, - keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined), + keys: parseKeysBody(item, existingByIdentifier.get(deviceIdentifier) || undefined), }); } } - let updatedCount = 0; - for (const update of updates) { - const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys); - if (ok) updatedCount++; - } + // 一次 batch 写完,等待轮数恒为 1(原来是 `for` 里逐条 await) + const updatedCount = await storage.updateDeviceKeysBatch(userId, updates); return jsonResponse({ success: true, updated: updatedCount }); } @@ -589,11 +600,15 @@ export async function handleUntrustDevices( const body = await readJsonBody(request); const storage = new StorageService(env.DB); const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : []; - const removed = await storage.clearDeviceKeys(userId, devices); - for (const deviceIdentifier of devices) { - if (!deviceIdentifier) continue; - await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier); + + // 同 handleUpdateDeviceKeys:这份列表来自请求体,必须先设上界 + if (devices.length > LIMITS.device.maxBulkIdentifiers) { + return errorResponse(`Too many devices in one request (max ${LIMITS.device.maxBulkIdentifiers})`, 400); } + + const removed = await storage.clearDeviceKeys(userId, devices); + // 一次删完,等待轮数恒为 1(原来是 `for` 里逐台 await) + await storage.deleteTrustedTwoFactorTokensByDevices(userId, devices); await writeAuditEvent(storage, { actorUserId: userId, action: 'device.trust.revoke_batch', @@ -726,4 +741,3 @@ export async function handleClearDeviceToken( return new Response(null, { status: 200 }); } - diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index d89dbb903..d96e8811d 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -185,44 +185,3 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str return new Response(null, { status: 204 }); } - -// POST /api/folders/delete -export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.DB); - - let body: { ids?: string[] }; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } - - const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : []; - if (!ids.length) { - return errorResponse('Folder ids are required', 400); - } - - const folders = ( - await Promise.all(ids.map(async (id) => { - const folder = await storage.getFolderForUser(id, userId); - return folder; - })) - ).filter((folder): folder is Folder => !!folder); - const revisionDate = await storage.bulkDeleteFolders(ids, userId); - if (revisionDate) { - notifyVaultSyncForRequest(request, env, userId, revisionDate); - for (const folder of folders) { - notifyUserFolderDelete(env, { - userId, - folderId: folder.id, - revisionDate, - contextId: readActingDeviceIdentifier(request), - }); - } - await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', { - count: ids.length, - }); - } - - return new Response(null, { status: 204 }); -} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index f97036b0e..faf1e71a7 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -4,7 +4,7 @@ import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits'; -import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; +import { findMatchingTotpCounter, isTotpEnabled, isValidTotpSecret } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; @@ -47,6 +47,12 @@ function identityJsonResponse(data: unknown, status: number = 200): Response { function resolveTotpSecret(userSecret: string | null): string | null { if (userSecret && isTotpEnabled(userSecret)) { + if (!isValidTotpSecret(userSecret)) { + // 存在却**不可用**的密钥(字母表非法)⇒ base32Decode 返回 null ⇒ 任何验证码都不可能匹配。 + // 刻意**不**在这里降级成“未启用”:那会让两步验证被静默跳过(fail-open)。 + // 保持 fail-closed,只留下可诊断的日志;用户的出路是恢复码或重新启用 TOTP。 + console.error('Stored TOTP secret is not a valid base32 string; every TOTP code will be rejected.'); + } return userSecret; } return null; diff --git a/src/handlers/import.ts b/src/handlers/import.ts index 5c3651439..720bbae90 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -135,7 +135,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st // Create folders and build index -> id mapping const folderIdMap = new Map(); const folderRows: Folder[] = []; - + for (let i = 0; i < folders.length; i++) { const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null; const folderId = generateUUID(); @@ -152,19 +152,15 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st folderRows.push(folder); } - if (folderRows.length > 0) { - const folderStatements = folderRows.map(folder => - env.DB - .prepare( - 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' - ) - .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) - ); - await runBatchInChunks(env.DB, folderStatements, batchChunkSize); - } - - // Build cipher index -> folder id mapping from relationships + // 注意执行顺序:**先把所有条目构造并校验完,再落任何库**。 + // + // 原来这里直接先把 folders 批量写库,然后才在循环里逐条校验 ciphers —— + // 于是一条非法条目会让函数返回 400,而**文件夹已经写进去了**。用户看到"导入失败", + // 库里却多出几个空文件夹;重试一次多一批(每次都是新的 UUID,不会冲突,只会累积)。 + // + // 现在把写入统一挪到校验之后:导入要么整体成功,要么什么都不留下。 + // (无法做到真事务:D1 的 batch 只保证单批原子,跨批次需要显式事务, + // 而 5000 条一次性塞进一个事务并不合适。前移校验足以覆盖"入参非法"这一唯一失败原因。) const cipherFolderMap = new Map(); for (const rel of folderRelationships) { if (!rel || typeof rel !== 'object') continue; @@ -173,9 +169,11 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st cipherFolderMap.set(rel.key, folderId); } } + // 取"进入本函数时"已存在的文件夹。本批新建的文件夹用的是全新 UUID, + // 客户端不可能在 `folderId` 里引用到它们,所以先后顺序不影响判定结果。 const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id)); - // Create ciphers + // 构造并校验 ciphers(此阶段不写库) const cipherRows: Cipher[] = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; for (let i = 0; i < ciphers.length; i++) { @@ -281,6 +279,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st cipherMapRows.push({ index: i, sourceId, id: cipher.id }); } + // 到这里所有入参都已校验通过,可以安全落库了 + if (folderRows.length > 0) { + const folderStatements = folderRows.map(folder => + env.DB + .prepare( + 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' + ) + .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) + ); + await runBatchInChunks(env.DB, folderStatements, batchChunkSize); + } + if (cipherRows.length > 0) { const cipherStatements = cipherRows.map(cipher => { const data = JSON.stringify(cipher); diff --git a/src/index.ts b/src/index.ts index e34d0b46e..182aa4ab1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { NotificationsHub } from './durable/notifications-hub'; import { BackupTransferRunner } from './durable/backup-transfer-runner'; import { handleRequest } from './router'; import { StorageService } from './services/storage'; +import { trackAppVersionOnce } from './services/app-version-log'; import { applyCors, jsonResponse } from './utils/response'; import { runScheduledBackupIfDue } from './handlers/backup'; import { @@ -77,7 +78,6 @@ async function ensureDatabaseInitialized(env: Env): Promise { export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - void ctx; const normalizedRequest = normalizeRequestUrl(request); const requestPath = new URL(normalizedRequest.url).pathname; @@ -108,6 +108,11 @@ export default { return applyCors(normalizedRequest, resp, env); } + // 版本启动记录:每个 isolate 只跑一次,放进 waitUntil 不占请求关键路径。 + // 挂在这里是为了「部署后立刻有访客就能立刻在日志中心看到」; + // 零流量的情况由下面的 scheduled(每 5 分钟)兜底。 + ctx.waitUntil(trackAppVersionOnce(env)); + const resp = await handleRequest(normalizedRequest, env); return applyCors(normalizedRequest, resp, env); }, @@ -119,6 +124,9 @@ export default { console.error('Skipping scheduled backup because DB init failed:', dbInitError); return; } + // 与请求路径共用同一个“每 isolate 一次”入口:部署后即使零流量, + // 最迟 5 分钟内也会把新版本写进日志中心。 + ctx.waitUntil(trackAppVersionOnce(env)); ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => { console.error('Scheduled backup failed:', error); })); diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index ff99b0c5e..b85eee742 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -50,7 +50,6 @@ import { handleCreateFolder, handleUpdateFolder, handleDeleteFolder, - handleBulkDeleteFolders, } from './handlers/folders'; import { handleGetSends, @@ -368,10 +367,6 @@ export async function handleAuthenticatedRoute( return null; } - if (path === '/api/folders/delete' && method === 'POST') { - return handleBulkDeleteFolders(request, env, userId); - } - const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i); if (folderMatch) { const folderId = folderMatch[1]; diff --git a/src/services/app-version-log.ts b/src/services/app-version-log.ts new file mode 100644 index 000000000..8ca984181 --- /dev/null +++ b/src/services/app-version-log.ts @@ -0,0 +1,148 @@ +// 应用版本启动记录:把「新版本第一次跑起来」这件事写成一条审计事件, +// 于是它会出现在 Web 端的「日志中心」(webapp/src/components/LogCenterPage.tsx)里。 +// +// 为什么必须"判定"而不是每次冷启动都写一条: +// Workers 里模块顶层代码只在该 isolate 冷启动时执行一次,而 isolate 会随扩缩容 +// 随时销毁重建。若每次冷启动都写,日志中心会被同一版本的重复条目刷满。 +// 所以这里把「上次见到的版本」持久化在 D1 的 config 表里,真的变了才写。 +// +// 两种"变了"都要认: +// ① 版本号变了(shared/app-version.ts 的 APP_VERSION 被改,即发版); +// ② 版本号没变、只是重新构建部署了 —— 靠 Cloudflare 的版本元数据绑定 +// (CF_VERSION_METADATA.id)识别,它每次 build/deploy 都不同。 +// +// 并发安全:判定与写入合并成一条 SQL(claimConfigValue),见该函数的注释。 +// +// 日志中心的键位约定(改动作名或元数据字段时别漏): +// - 动作标签 = `txt_log_action_` + 动作名(非字母数字换成下划线) +// `system.app.version.started` → `txt_log_action_system_app_version_started` +// - 元数据标签 = `txt_log_meta_` + 键名(驼峰转下划线) +// `previousVersion` → `txt_log_meta_previous_version` +// 缺键不会崩(页面会 humanize 回退成英文),但 10 个语言包必须同时补, +// 否则 `npm run i18n:validate` 的键对齐检查会失败。 +// - 元数据是**白名单制**:没登记进 audit-events.ts 的 ALLOWED_METADATA_KEYS 的键 +// 会被静默丢弃,所以新增字段时必须同步补那边。 + +import { APP_VERSION } from '../../shared/app-version'; +import type { Env } from '../types'; +import { safeWriteAuditEvent } from './audit-events'; +import { claimConfigValue, getConfigValue } from './storage-config-repo'; + +/** 存在 config 表里的键。它与日志保留策略无关:清空审计日志/保留期清理都不会动它。 */ +export const APP_VERSION_CONFIG_KEY = 'app.version.last'; +/** 日志中心里显示的动作名 */ +export const APP_VERSION_ACTION = 'system.app.version.started'; + +export interface VersionRecord { + version: string; + deploymentId: string | null; + deploymentTag: string | null; + deployedAt: string | null; +} + +let trackedInThisIsolate = false; +let trackPromise: Promise | null = null; + +function currentVersionRecord(env: Env): VersionRecord { + const meta = env.CF_VERSION_METADATA; + return { + version: APP_VERSION, + deploymentId: meta?.id ?? null, + deploymentTag: meta?.tag ?? null, + deployedAt: meta?.timestamp ?? null, + }; +} + +/** + * 解析 config 里存的值。 + * 兼容早期/异常写法:若存的不是 JSON 对象,就退化成"只知道版本号"。 + */ +export function parseVersionRecord(raw: string | null): VersionRecord | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial | null; + if (parsed && typeof parsed === 'object' && typeof parsed.version === 'string') { + return { + version: parsed.version, + deploymentId: typeof parsed.deploymentId === 'string' ? parsed.deploymentId : null, + deploymentTag: typeof parsed.deploymentTag === 'string' ? parsed.deploymentTag : null, + deployedAt: typeof parsed.deployedAt === 'string' ? parsed.deployedAt : null, + }; + } + } catch { + // 落到下面按裸版本号处理 + } + + return { version: raw, deploymentId: null, deploymentTag: null, deployedAt: null }; +} + +/** 是否算是"新的一次部署"(首次记录也算) */ +export function isNewDeployment(previous: VersionRecord | null, current: VersionRecord): boolean { + if (!previous) return true; + if (previous.version !== current.version) return true; + // 版本号没变,但 Cloudflare 给的 deployment id 变了 ⇒ 仅重新构建部署。 + // 拿不到 id 时(本地 dev / 测试)只能按版本号判断。 + return current.deploymentId !== null && previous.deploymentId !== current.deploymentId; +} + +async function trackAppVersion(env: Env): Promise { + const previous = parseVersionRecord(await getConfigValue(env.DB, APP_VERSION_CONFIG_KEY)); + const current = currentVersionRecord(env); + + if (!isNewDeployment(previous, current)) return; + + // 原子认领:并发(多个 isolate 同时冷启动)时只有赢家拿到 true, + // 于是日志中心里只会出现一条,不会因为竞态重复。 + if (!await claimConfigValue(env.DB, APP_VERSION_CONFIG_KEY, JSON.stringify(current))) return; + + const metadata: Record = { version: current.version }; + if (previous) metadata.previousVersion = previous.version; + if (current.deploymentId) metadata.deploymentId = current.deploymentId; + if (current.deployedAt) metadata.deployedAt = current.deployedAt; + + await safeWriteAuditEvent(env, { + // 系统事件:没有操作者,日志中心里 actor 显示为 "—"(与既有的 + // user.bootstrap.admin_promoted 一致)。 + actorUserId: null, + action: APP_VERSION_ACTION, + category: 'system', + level: 'info', + targetType: 'system', + targetId: null, + metadata, + }); + + // 双写一行到 console:实时排查时 wrangler tail 里也能直接看到。 + console.info('App version started', metadata); +} + +/** + * 每个 isolate 最多跑一次(模块级标志 + 共享 promise,与 ensureDatabaseInitialized 同款做法)。 + * + * 失败只记 console、绝不抛出:版本记录属"锦上添花",不能影响请求或定时任务。 + * 失败后在同个 isolate 内不再重试 —— 兜底交给每 5 分钟的 scheduled, + * 它每次都是新的 isolate,会重新走一遍。 + */ +export function trackAppVersionOnce(env: Env): Promise { + if (trackedInThisIsolate) return Promise.resolve(); + + if (!trackPromise) { + trackPromise = trackAppVersion(env) + .catch((error: unknown) => { + console.error('Failed to record app version startup:', error); + }) + .finally(() => { + trackPromise = null; + trackedInThisIsolate = true; + }); + } + + return trackPromise; +} + +/** 仅供测试:重置"本 isolate 已检查过"的标志(模拟全新 isolate) */ +export function resetAppVersionTrackingForTests(): void { + trackedInThisIsolate = false; + trackPromise = null; +} diff --git a/src/services/audit-events.ts b/src/services/audit-events.ts index 4e2194767..d68682267 100644 --- a/src/services/audit-events.ts +++ b/src/services/audit-events.ts @@ -87,6 +87,12 @@ const ALLOWED_METADATA_KEYS = new Set([ 'error', 'expiresInHours', 'checksumMismatchAccepted', + // 应用版本启动事件(system.app.version.started,见 services/app-version-log.ts)。 + // 不登记的话 sanitizeMetadata 会把它们静默丢弃,日志中心里就只剩动作名。 + 'version', + 'previousVersion', + 'deploymentId', + 'deployedAt', ]); function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null { diff --git a/src/services/auth.ts b/src/services/auth.ts index 28a12e986..69c18c416 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -223,6 +223,13 @@ export class AuthService { const payload = await verifyJWT(parts[1], this.env.JWT_SECRET); if (!payload) return null; + // 用途隔离显式化:访问令牌不得携带附件/下载类专用令牌的字段。 + // 两者共用 JWT_SECRET,故此处直接拒绝,把隐式约定升级为显式契约。 + const rawPayload = payload as unknown as Record; + if (rawPayload.cipherId || rawPayload.attachmentId || rawPayload.sendId || rawPayload.fileId) { + return null; + } + let user = await this.getCachedUser(payload.sub); if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) { user = await this.getFreshUser(payload.sub); diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index c353548d8..03616b5ec 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -2,11 +2,13 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate'; import type { Env } from '../types'; import { APP_VERSION } from '../../shared/app-version'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; +import { reportProgress } from './backup-progress'; import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config'; import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto'; import { getAttachmentObjectKey, getBlobStorageKind, + getBlobObject, } from './blob-store'; // CONTRACT: @@ -36,6 +38,17 @@ const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000; const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128; +/** + * 内联导出(本地下载 zip)时,`db.json` + 全部附件解压后的**总字节**上限。 + * + * 内联导出会把每个附件整块读进内存,`zipSync` 再产出等大的一份, + * 峰值内存约为该总量的 **2 倍**;Worker 每 isolate 内存上限 128 MB, + * 因此取 32 MiB(峰值 ≈ 64 MiB),为同 isolate 内的其他数据留出一半余量。 + * + * 注意:这**收紧了**原先的实际能力(旧代码按附件 ≤ 64 MiB 放行,峰值可达 128 MB)。 + * 要放宽只需调大写这里的值,但必须同时复核峰值内存 ≈ 2 倍总量。 + */ +const MAX_BACKUP_INLINE_TOTAL_BYTES = 32 * 1024 * 1024; export interface BackupManifest { formatVersion: 1; @@ -90,6 +103,27 @@ export interface BackupFileIntegrityCheckResult { export interface BuildBackupArchiveOptions { includeAttachments?: boolean; + /** + * 把附件 blob 内联进归档(zip 内 `attachments//.bin`)。 + * + * 默认为 false,因为**远端备份依赖外部增量上传**:`handlers/backup.ts` 用 + * `manifest.attachmentBlobs` 枚举待上传项、按 size 与远端已有对象比对去重, + * 附件字节**不**进归档。若默认内联,会破坏该设计并把附件重复存一份。 + * + * 本地导出(用户下载 zip)必须置 true —— 否则归档声称 `includes.attachments: true` + * 却没有任何附件字节,且会被本地导入以 `missing required file` 拒绝。 + */ + inlineAttachmentBlobs?: boolean; + /** + * 仅供测试注入更小的「导出侧 db.json 上限」,用于覆盖拒绝分支。 + * 生产调用方必须省略 —— 真实上限来自恢复侧的 `MAX_BACKUP_DB_JSON_BYTES`。 + */ + restorableDbPayloadLimitBytes?: number; + /** + * 进度回调。**必须**自行吞掉异常,且调用方必须走 `services/backup-progress.ts` 的 + * `reportProgress()` 上报(见该模块的 CONTRACT)—— 让「进度上报失败」有机会变成 + * 「备份失败」是一件很容易犯、后果又不小的事。 + */ progress?: BackupArchiveBuildProgressReporter; timeZone?: string; } @@ -289,6 +323,90 @@ function createZipEntries(files: Record): Record { + return new Set( + (manifest.attachmentBlobs || []).map( + (item) => + `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin` + ) + ); +} + export interface ParseBackupArchiveOptions { allowExternalAttachmentBlobs?: boolean; } @@ -335,9 +453,15 @@ export function parseBackupArchive( const decoder = new TextDecoder(); let manifest: BackupManifest; let rawDb: unknown; + // `db.json` 是归档里最大的条目(上限 32 MiB)。它**不需要**以"字节"形态留在返回值里: + // 解码成文本后立刻把引用摘掉,避免"解压产物 + 解码字符串 + 解析出的对象树"三份大块 + // 同时在内存里(Worker isolate 上限 128 MB,恢复大库时这是最紧的一段)。 + // 条目名保留(值为空数组),调用方仍能通过 Object.keys(files) 列举归档内容。 + const dbText = decoder.decode(dbBytes); + zipped['db.json'] = new Uint8Array(0); try { manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest; - rawDb = JSON.parse(decoder.decode(dbBytes)); + rawDb = JSON.parse(dbText); } catch { throw new Error('Backup archive contains invalid JSON metadata'); } @@ -347,14 +471,14 @@ export function parseBackupArchive( } const db = normalizeParsedBackupDb(rawDb); - const externalAttachmentKeys = new Set( - options.allowExternalAttachmentBlobs - ? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) - : [] - ); + const declaredBlobs = declaredAttachmentBlobPaths(manifest); + // 只读集合(下面只用 `.has()`),因此可以直接复用同一个实例,不必再复制一层 + const externalAttachmentKeys = options.allowExternalAttachmentBlobs ? declaredBlobs : new Set(); const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry)); for (const entry of requiredEntries) { if (!zipped[entry]) { + // 声明含附件却没内联 → 远端形态的归档,不是损坏;提示用户改用「从远端恢复」 + if (declaredBlobs.has(entry)) throw new Error(MISSING_ATTACHMENT_FILES_MESSAGE); throw new Error(`Backup archive is missing required file: ${entry}`); } } @@ -382,11 +506,9 @@ export function validateBackupPayloadContents( const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); - const externalAttachmentKeys = new Set( - options.allowExternalAttachmentBlobs - ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) - : [] - ); + const declaredBlobs = declaredAttachmentBlobPaths(payload.manifest); + // 同 parseBackupArchive:只读集合,直接复用 + const externalAttachmentKeys = options.allowExternalAttachmentBlobs ? declaredBlobs : new Set(); const userIds = new Set(); for (const row of userRows) { @@ -451,6 +573,7 @@ export function validateBackupPayloadContents( } const attachmentPath = `attachments/${cipherId}/${id}.bin`; if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) { + if (declaredBlobs.has(attachmentPath)) throw new Error(MISSING_ATTACHMENT_FILES_MESSAGE); throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`); } } @@ -480,7 +603,7 @@ export async function buildBackupArchive( options: BuildBackupArchiveOptions = {} ): Promise { const includeAttachments = options.includeAttachments !== false; - await options.progress?.({ + await reportProgress(options.progress, { step: 'collect_data', fileName: '', stageTitle: 'txt_backup_archive_progress_collect_title', @@ -553,7 +676,7 @@ export async function buildBackupArchive( }, null, BACKUP_JSON_INDENT)), }; - await options.progress?.({ + await reportProgress(options.progress, { step: 'package_archive', fileName: '', stageTitle: 'txt_backup_archive_progress_package_title', @@ -562,11 +685,44 @@ export async function buildBackupArchive( : 'txt_backup_archive_progress_package_detail', includeAttachments, }); + + // 所有导出路径(本地 / 远端 / 定时)都汇到这里,因此这一处预检即可覆盖全部: + // 产出一个恢复侧必然拒收的归档,比直接报错更糟 —— 用户会以为"备份成功了"。 + assertBackupDbPayloadRestorable(files['db.json'].byteLength, options.restorableDbPayloadLimitBytes); + + if (includeAttachments && options.inlineAttachmentBlobs) { + // 本地导出:把附件字节内联进归档,使 zip 自包含、可被本地导入恢复。 + // 远端备份不走这里(它依赖外部增量上传,见 BuildBackupArchiveOptions 注释)。 + // + // 先做体积预检:预算必须同时扣掉 db.json(恢复侧按解压后**总**字节判定)并留出内存余量, + // 否则会产出一个自家人无法恢复的备份。详见 resolveInlineAttachmentBudgetBytes。 + const dbPayloadBytes = files['db.json'].byteLength; + const totalAttachmentBytes = attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0); + const inlineBudgetBytes = resolveInlineAttachmentBudgetBytes(dbPayloadBytes); + if (totalAttachmentBytes > inlineBudgetBytes) { + throw new Error( + `${TOO_LARGE_TO_EXPORT_MESSAGE_PREFIX}: ${dbPayloadBytes} database bytes plus ${totalAttachmentBytes} attachment bytes exceed the ${Math.max(0, inlineBudgetBytes)} byte budget` + ); + } + + for (const item of attachmentBlobs) { + const object = await getBlobObject(env, item.blobName); + if (!object?.body) { + // 措辞与 durable/backup-transfer-runner.ts 保持一致:handlers/backup.ts 依赖 + // 'blob missing' 子串把它映射为 409 而不是 500 + throw new Error(`Attachment blob missing for ${item.blobName}`); + } + files[`attachments/${item.cipherId}/${item.attachmentId}.bin`] = new Uint8Array( + await new Response(object.body).arrayBuffer() + ); + } + } + const bytes = zipSync(createZipEntries(files)); const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); const backupTimeZone = options.timeZone || 'UTC'; const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone); - await options.progress?.({ + await reportProgress(options.progress, { step: 'archive_ready', fileName, stageTitle: 'txt_backup_archive_progress_ready_title', diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 902db37c9..0e6189919 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -158,6 +158,19 @@ function isBlockedIpv6Address(hostname: string): boolean { if (!hextets) return true; const firstHextet = Number.parseInt(hextets[0], 16); if (!Number.isFinite(firstHextet)) return true; + + // NAT64 prefix: 64:ff9b::/96 (RFC 6052 well-known prefix) 与 64:ff9b:1::/48 + // (RFC 8215 local-use prefix) 都会把 IPv6 地址映射回 IPv4。前者格式固定, + // 末两个 hextet 即内嵌 IPv4,解出后按 IPv4 规则判定;后者前缀长度可变、 + // 无法可靠解出,因此整段拒绝。 + if (hextets[0] === '0064' && hextets[1] === 'ff9b') { + if (hextets[2] !== '0000') return true; + const hi = Number.parseInt(hextets[6], 16); + const lo = Number.parseInt(hextets[7], 16); + if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true; + return isBlockedIpv4Address([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff]); + } + // After expansion, loopback (::1) and unspecified (::) have first hextet 0. return ( firstHextet === 0 || diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 79f3b577a..37d44ce75 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -1,6 +1,7 @@ import type { Env, User } from '../types'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; +import { reportProgress } from './backup-progress'; import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config'; import { type BackupManifestAttachmentBlob, @@ -241,6 +242,12 @@ export interface BackupRestoreProgressEvent { error?: string | null; } +/** + * 恢复进度回调。**必须**自行吞掉异常(`handlers/backup.ts` 的实现会先 `touchLease()`, + * 那一步**会抛**);即便如此,内部上报也必须走 `reportProgress()`,见 + * `services/backup-progress.ts` 的 CONTRACT —— 本文件所有调用点都遵守它, + * 那正是 H3「恢复成功却对外报 500」事故的修法所在。 + */ export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise | void; function attachmentRowKey(row: SqlRow): string { @@ -298,28 +305,21 @@ async function prepareImportedConfigRows( } async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise { - const preparedDb: BackupPayload['db'] = { - config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), - users: cloneRows(payload.users || []).map((row) => ({ - ...row, - verify_devices: row.verify_devices ?? 0, - yubikey_nfc: row.yubikey_nfc ?? 0, - })), - domain_settings: cloneRows(payload.domain_settings || []), - user_revisions: cloneRows(payload.user_revisions || []), - webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({ - ...row, - purpose: normalizeAccountPasskeyPurpose(row.purpose), - })), - folders: cloneRows(payload.folders || []), - ciphers: cloneRows(payload.ciphers || []).map((row) => ({ - ...row, - archived_at: row.archived_at ?? null, - })), - attachments: cloneRows(payload.attachments || []), - }; - await importBackupRows(db, preparedDb, true); - return preparedDb; + // 就地补默认值(而不是像过去那样用 cloneRows 整表深拷贝):解析出来的对象树是本次恢复 + // 独占的,复制一份只会让内存峰值翻倍;大库(1.6 万行级)最吃内存的就是这一段。 + payload.config = await prepareImportedConfigRows(env, payload.config || [], payload.users || []); + for (const row of payload.users || []) { + if (row.verify_devices == null) row.verify_devices = 0; + if (row.yubikey_nfc == null) row.yubikey_nfc = 0; + } + for (const row of payload.webauthn_credentials || []) { + row.purpose = normalizeAccountPasskeyPurpose(row.purpose); + } + for (const row of payload.ciphers || []) { + if (row.archived_at == null) row.archived_at = null; + } + await importBackupRows(db, payload, true); + return payload; } function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record): PreparedBackupImportPayload { @@ -425,6 +425,29 @@ async function runInsertBatch(db: D1Database, table: string, statements: D1Prepa } } +/** + * 恢复写入的批大小。 + * + * 过去是**整表一次** `db.batch()`:一个 3 万行的库里同一批会有 3 万条语句, + * 内存峰值随库大小线性增长,也白白撞 D1 对单次 batch 规模的限制。 + * 分批后峰值只与批大小有关,与库大小无关。 + */ +const RESTORE_INSERT_BATCH_SIZE = 200; + +/** 逐批构造并提交插入语句 —— 不先把整表语句都建出来(那正是要避免的峰值)。 */ +async function insertRows( + db: D1Database, + table: string, + columns: string[], + rows: SqlRow[], + upsert = false +): Promise { + for (let start = 0; start < rows.length; start += RESTORE_INSERT_BATCH_SIZE) { + const chunk = rows.slice(start, start + RESTORE_INSERT_BATCH_SIZE); + await runInsertBatch(db, table, buildInsertStatements(db, table, columns, chunk, upsert)); + } +} + async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record): Promise { const restoredAttachments: SqlRow[] = []; const skippedItems: BackupImportSkipSummary['items'] = []; @@ -628,67 +651,35 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set, after async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise { const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table); - await runInsertBatch( - db, - tableName('config'), - buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true) - ); - await runInsertBatch( + await insertRows(db, tableName('config'), ['key', 'value'], payload.config || [], true); + await insertRows( db, tableName('users'), - buildInsertStatements( - db, - tableName('users'), - ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'], - payload.users || [] - ) - ); - await runInsertBatch( - db, - tableName('user_revisions'), - buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true) + ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'], + payload.users || [] ); - await runInsertBatch( + await insertRows(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true); + await insertRows( db, tableName('domain_settings'), - buildInsertStatements( - db, - tableName('domain_settings'), - ['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'], - payload.domain_settings || [], - true - ) + ['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'], + payload.domain_settings || [], + true ); - await runInsertBatch( + await insertRows( db, tableName('webauthn_credentials'), - buildInsertStatements( - db, - tableName('webauthn_credentials'), - ['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'], - payload.webauthn_credentials || [] - ) - ); - await runInsertBatch( - db, - tableName('folders'), - buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []) + ['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'], + payload.webauthn_credentials || [] ); - await runInsertBatch( + await insertRows(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []); + await insertRows( db, tableName('ciphers'), - buildInsertStatements( - db, - tableName('ciphers'), - ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'], - payload.ciphers || [] - ) - ); - await runInsertBatch( - db, - tableName('attachments'), - buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []) + ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'], + payload.ciphers || [] ); + await insertRows(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []); } export async function importBackupArchiveBytes( @@ -714,7 +705,7 @@ export async function importBackupArchiveBytes( await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); try { - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_create_shadow', fileName, @@ -723,7 +714,7 @@ export async function importBackupArchiveBytes( replaceExisting, }); await createShadowTables(env.DB); - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_import_data', fileName, @@ -743,7 +734,7 @@ export async function importBackupArchiveBytes( attachments: (db.attachments || []).length, }); - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_restore_files', fileName, @@ -765,7 +756,7 @@ export async function importBackupArchiveBytes( ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, }); - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_finalize', fileName, @@ -782,7 +773,7 @@ export async function importBackupArchiveBytes( } } - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_complete', fileName, @@ -815,7 +806,7 @@ export async function importBackupArchiveBytes( }, }; } catch (error) { - await progress?.({ + await reportProgress(progress, { source: 'local', step: 'local_failed', fileName, @@ -855,7 +846,7 @@ export async function importRemoteBackupArchiveBytes( await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); try { - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_create_shadow', fileName, @@ -864,7 +855,7 @@ export async function importRemoteBackupArchiveBytes( replaceExisting, }); await createShadowTables(env.DB); - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_import_data', fileName, @@ -884,7 +875,7 @@ export async function importRemoteBackupArchiveBytes( attachments: (db.attachments || []).length, }); - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_restore_files', fileName, @@ -906,7 +897,7 @@ export async function importRemoteBackupArchiveBytes( ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, }); - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_finalize', fileName, @@ -924,7 +915,7 @@ export async function importRemoteBackupArchiveBytes( } } - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_complete', fileName, @@ -962,7 +953,7 @@ export async function importRemoteBackupArchiveBytes( }, }; } catch (error) { - await progress?.({ + await reportProgress(progress, { source: 'remote', step: 'remote_failed', fileName, diff --git a/src/services/backup-progress.ts b/src/services/backup-progress.ts new file mode 100644 index 000000000..14e697b3c --- /dev/null +++ b/src/services/backup-progress.ts @@ -0,0 +1,39 @@ +// 备份 / 恢复的进度上报:**尽力而为**,绝不能决定业务操作的成败。 +// +// CONTRACT: +// 1. 调用方上报进度**必须**走 `reportProgress()`,不要直接 `await reporter(event)`。 +// 2. 进度回调的**实现**应当自行吞掉异常(例如 `notifyUserBackupProgress()` 内部就是 +// try/catch)。第 1 条正是为了在实现违反第 2 条时兜住 —— 两道都要有,缺一不可。 +// +// 为什么需要这个模块(真实事故,见 handlers/backup.ts 的恢复路径):那里的进度回调会先 +// `touchLease()` 去续 Durable Object 的作业租约,而这一步**会抛**(DO 不可用、作业已过期等)。 +// 当时调用点写的是裸 `await progress?.(...)`,于是: +// - `swapShadowTablesIntoPlace()` **之后**的「完成」通知抛错 ⇒ 交换已提交、恢复其实成功了, +// 异常却被外层 catch 捕获并对外报 500(用户以为失败,数据其实已经换掉); +// - catch 分支里的「失败」通知抛错 ⇒ 把原始的失败原因覆盖掉,排障时看不到真因。 +// +// 导出 / 远端备份那 10 处当时没爆,只是因为它们的回调恰好只发通知、而 +// `notifyUserBackupProgress()` 自带 try/catch —— 也就是说那些地方的安全性是**偶然**的, +// 依赖一条没写下来的约定。而一旦有人给备份回调也加上 `touchLease()`(恢复路径就是这么写的), +// 同一条语句会变成:远端「verify 前」的上报抛错被当成**校验失败** ⇒ 进 catch +// `deleteFile()` **删掉刚上传成功的归档**、重试 3 次、最后报「verification failed」 +// 并附上通知的错误。这种后果不该由约定来防。 +// +// 把上报收敛到这里之后,「进度上报不得决定业务成败」就从约定变成了代码里的事实。 + +/** + * 进度回调。实现**应当**自行吞掉异常;即便如此,调用点也必须走 `reportProgress()`。 + */ +export type BackupProgressReporter = (event: Event) => Promise | void; + +/** 尽力而为地上报一次进度:失败只记 console,绝不外抛、绝不改变调用方的控制流。 */ +export async function reportProgress( + reporter: BackupProgressReporter | undefined | null, + event: Event +): Promise { + try { + await reporter?.(event); + } catch (error) { + console.error('Backup progress reporting failed (ignored):', error); + } +} diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index c9c666ac8..62ce92033 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -5,6 +5,7 @@ import { WebDavBackupDestination, normalizeBackupEndpointUrl, } from './backup-config'; +import { isRequestTimeoutError, withRequestTimeout } from '../utils/request-timeout'; export interface BackupUploadResult { provider: BackupDestinationType; @@ -45,6 +46,146 @@ export interface RemoteBackupFilePutOptions { contentType?: string; } +// ---------------------------------------------------------------- 远端请求超时 +// +// 为什么要做:远端目的地不可达时(黑洞 IP、防火墙丢包、容器被暂停、TLS 握手挂住), +// `fetch()` 可能**永不 settle**。异常永远抛不出来 ⇒ 调用方的 catch 永不执行 ⇒ 请求一直挂着, +// 最后由平台兜底返回通用 500(`internal error; reference = …`),管理员拿到的信息量为零。 +// 所以超时不是为了“限速”,而是为了让失败**真的成为一次失败**。 +// +// 为什么要分档:控制类请求(MKCOL / HEAD / DELETE)只传几十字节;而单次传输可能是 +// 100 MiB 的附件(`limits.attachment.maxFileSizeBytes`)或 64 MiB 的归档 +// (`MAX_BACKUP_ARCHIVE_BYTES`),跨境上传远超几秒 ⇒ 传输类按体积估算, +// 避免把“慢但成功”误杀成失败。 +// 超时消息的形状(构造 + 判定)来自 `shared/backup-timeout-message.ts`: +// 那句文本同时被前端 `translateServerError()` 解析,两边必须逐字一致。 +import { + REMOTE_TIMEOUT_MESSAGE_PATTERN, + buildRemoteTimeoutMessage, + type RemoteRequestAction, +} from '../../shared/backup-timeout-message'; + +export type { RemoteRequestAction }; + +export interface RemoteRequestTimeouts { + /** 控制类请求(建目录 / 存在性检查 / 删除)的整段时长上限 */ + controlMs: number; + /** 列目录(WebDAV PROPFIND / S3 ListObjectsV2)的整段时长上限 */ + listingMs: number; + /** 等待首包(响应头)的上限,用于 GET 下载 */ + firstByteMs: number; + /** 传输类(上传 / 下载 body)的下限:小文件也要给足建连与握手时间 */ + transferMinMs: number; + /** 传输类上限:再慢也总得失败一次 */ + transferMaxMs: number; + /** 估算传输耗时用的**保守**带宽假设(字节/秒) */ + transferBytesPerSecond: number; +} + +export const DEFAULT_REMOTE_REQUEST_TIMEOUTS: RemoteRequestTimeouts = { + controlMs: 5_000, + listingMs: 10_000, + firstByteMs: 10_000, + transferMinMs: 30_000, + transferMaxMs: 10 * 60 * 1000, + // 256 KB/s:比任何可用链路都慢,宁可多给时间也不要误杀一次能成功的备份 + transferBytesPerSecond: 256 * 1024, +}; + +/** + * 远端请求超时。与 HTTP 状态码类错误(`WebDAV upload failed: 403`)刻意区分开: + * 调用方据此把它映射成**不可重试**的 4xx(见 `remoteRequestFailureStatus`)。 + */ +export class RemoteRequestTimeoutError extends Error { + constructor( + readonly provider: 'WebDAV' | 'S3', + readonly action: RemoteRequestAction, + readonly timeoutMs: number + ) { + super(buildRemoteTimeoutMessage(provider, action, timeoutMs)); + this.name = 'RemoteRequestTimeoutError'; + } +} + +export function isRemoteRequestTimeoutError(error: unknown): error is RemoteRequestTimeoutError { + return error instanceof RemoteRequestTimeoutError; +} + +/** + * 超时消息的**形状**判定,供跨 JS 上下文使用: + * DO 与 handler 之间传递的是 JSON,Error 对象不会原样过界, + * 所以 handler 侧读回来的只有 message,只能按形状判断。 + * 形状与前端 `translateServerError` 的正则、以及本类的 `super(...)` 三者必须一致。 + */ +export function isRemoteRequestTimeoutMessage(message: unknown): boolean { + return typeof message === 'string' && REMOTE_TIMEOUT_MESSAGE_PATTERN.test(message); +} + +/** + * 超时必须映射成**不可重试**的 4xx。 + * + * 原因:前端 `createAuthedFetch` 的 `retryableRequest` 对 429 与 5xx 会自动重试 3 次 + * (退避 250 / 500 ms)。若超时也回 500,一次超时会被放大成约三倍等待, + * 管理员要等更久才看得到那条“可读的原因”。 + * + * 既接受 Error(同进程)也接受字符串消息(DO → handler 的 JSON 回传)。 + */ +export function remoteRequestFailureStatus(error: unknown, fallbackStatus = 500): number { + if (isRemoteRequestTimeoutError(error)) return 400; + const message = typeof error === 'string' ? error : error instanceof Error ? error.message : ''; + return isRemoteRequestTimeoutMessage(message) ? 400 : fallbackStatus; +} + +/** 把部分覆盖合并成完整配置;非法值(0 / 负数 / NaN)一律回退到默认,避免计时器立即触发或永不触发。 */ +export function resolveRemoteRequestTimeouts(overrides?: Partial): RemoteRequestTimeouts { + if (!overrides) return DEFAULT_REMOTE_REQUEST_TIMEOUTS; + const pick = (value: number | undefined, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; + return { + controlMs: pick(overrides.controlMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.controlMs), + listingMs: pick(overrides.listingMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.listingMs), + firstByteMs: pick(overrides.firstByteMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.firstByteMs), + transferMinMs: pick(overrides.transferMinMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.transferMinMs), + transferMaxMs: pick(overrides.transferMaxMs, DEFAULT_REMOTE_REQUEST_TIMEOUTS.transferMaxMs), + transferBytesPerSecond: pick(overrides.transferBytesPerSecond, DEFAULT_REMOTE_REQUEST_TIMEOUTS.transferBytesPerSecond), + }; +} + +/** 传输类预算:已知字节数时按保守带宽估算,夹在 [transferMinMs, transferMaxMs] 之间。 */ +function resolveTransferTimeoutMs(byteLength: number | undefined, timeouts: RemoteRequestTimeouts): number { + if (!byteLength || byteLength <= 0) return timeouts.transferMinMs; + const estimatedMs = Math.ceil((byteLength / timeouts.transferBytesPerSecond) * 1000); + return Math.min(timeouts.transferMaxMs, Math.max(timeouts.transferMinMs, estimatedMs)); +} + +/** + * 在**整段操作**(发送请求 + 读响应体)外包一层超时。 + * + * 为什么不只包 `fetch()`:`fetch()` 在**收到响应头**时就 resolve 了, + * 下载类请求还要 `await response.arrayBuffer()` 把 body 读进来 —— + * 对端“发了头就不再发数据”时,卡住的正是读 body 这一步。 + * + * 传入 `controller` 可复用同一个 AbortSignal:响应头已到达后再 `abort()` + * 仍能中断 body 读取(下载路径正是这样拆成“首包 + body”两段计时的)。 + */ +async function withRemoteTimeout( + provider: 'WebDAV' | 'S3', + action: RemoteRequestAction, + timeoutMs: number, + run: (signal: AbortSignal) => Promise, + controller: AbortController = new AbortController() +): Promise { + try { + // 计时/中断的实现在 utils/request-timeout.ts(与 Yubico 等其它外发路径共用同一份) + return await withRequestTimeout(timeoutMs, run, controller); + } catch (error) { + // 换成带上「哪家 / 哪一步」的错误:前端按这个消息形状映射本地化文案 + // (见 webapp/src/lib/i18n.ts 里 `timed out after (\d+) ms` 的分支) + if (isRequestTimeoutError(error)) throw new RemoteRequestTimeoutError(provider, action, timeoutMs); + throw error; + } +} + function isBackupArchiveName(name: string): boolean { return /\.zip$/i.test(String(name || '').trim()); } @@ -64,6 +205,19 @@ function trimSlashes(value: string): string { return next; } +/** + * 只去掉结尾的 `/`。 + * + * 与 `baseUrl.replace(/\/+$/, '')` 等价,但用循环实现:尾部量词在 CodeQL 的 + * js/polynomial-redos 规则下会被报"长串同一字符时可能变慢",这里没有任何回溯。 + */ +function trimTrailingSlashes(value: string): string { + const source = String(value || ''); + let end = source.length; + while (end > 0 && source[end - 1] === '/') end -= 1; + return source.slice(0, end); +} + function buildJoinedPath(...segments: string[]): string { return segments.map(trimSlashes).filter(Boolean).join('/'); } @@ -232,7 +386,8 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi } function buildWebDavUrl(baseUrl: string, relativePath: string): string { - const trimmedBase = baseUrl.replace(/\/+$/, ''); + // trimTrailingSlashes 用循环实现,避免 /\/+$/ 这种尾部量词命中 CodeQL js/polynomial-redos + const trimmedBase = trimTrailingSlashes(baseUrl); const normalized = normalizeRelativePath(relativePath); return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase; } @@ -241,18 +396,26 @@ function webDavFullPath(config: WebDavBackupDestination, relativePath: string): return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath)); } -async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise { +async function ensureWebDavDirectory( + baseUrl: string, + directoryPath: string, + authHeader: string, + timeouts: RemoteRequestTimeouts +): Promise { const segments = trimSlashes(directoryPath).split('/').filter(Boolean); let current = ''; for (const segment of segments) { current = buildJoinedPath(current, segment); const url = buildWebDavUrl(baseUrl, current); - const response = await fetch(url, { - method: 'MKCOL', - headers: { - Authorization: authHeader, - }, - }); + const response = await withRemoteTimeout('WebDAV', 'directory creation', timeouts.controlMs, (signal) => + fetch(url, { + method: 'MKCOL', + headers: { + Authorization: authHeader, + }, + signal, + }) + ); if ([200, 201, 204, 405].includes(response.status)) continue; throw new Error(`WebDAV directory creation failed: ${response.status}`); } @@ -262,7 +425,8 @@ async function ensureWebDavDirectoryCached( baseUrl: string, directoryPath: string, authHeader: string, - ensuredDirectories: Set + ensuredDirectories: Set, + timeouts: RemoteRequestTimeouts ): Promise { const segments = trimSlashes(directoryPath).split('/').filter(Boolean); let current = ''; @@ -270,12 +434,15 @@ async function ensureWebDavDirectoryCached( current = buildJoinedPath(current, segment); if (ensuredDirectories.has(current)) continue; const url = buildWebDavUrl(baseUrl, current); - const response = await fetch(url, { - method: 'MKCOL', - headers: { - Authorization: authHeader, - }, - }); + const response = await withRemoteTimeout('WebDAV', 'directory creation', timeouts.controlMs, (signal) => + fetch(url, { + method: 'MKCOL', + headers: { + Authorization: authHeader, + }, + signal, + }) + ); if ([200, 201, 204, 405].includes(response.status)) { ensuredDirectories.add(current); continue; @@ -289,7 +456,8 @@ async function putToWebDav( relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}, - ensuredDirectories?: Set + ensuredDirectories: Set | undefined, + timeouts: RemoteRequestTimeouts ): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remoteFilePath = buildJoinedPath(config.remotePath, relativePath); @@ -297,29 +465,41 @@ async function putToWebDav( if (remoteDir) { if (ensuredDirectories) { - await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories); + await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories, timeouts); } else { - await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader); + await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader, timeouts); } } - const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), { - method: 'PUT', - headers: { - Authorization: authHeader, - 'Content-Type': options.contentType || 'application/octet-stream', - 'Content-Length': String(bytes.byteLength), - }, - body: bytes, - }); + const response = await withRemoteTimeout( + 'WebDAV', + 'upload', + resolveTransferTimeoutMs(bytes.byteLength, timeouts), + (signal) => + fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), { + method: 'PUT', + headers: { + Authorization: authHeader, + 'Content-Type': options.contentType || 'application/octet-stream', + 'Content-Length': String(bytes.byteLength), + }, + body: bytes, + signal, + }) + ); if (!response.ok) { throw new Error(`WebDAV upload failed: ${response.status}`); } } -async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise { - await putToWebDav(config, fileName, archive, { contentType: 'application/zip' }); +async function uploadToWebDav( + config: WebDavBackupDestination, + archive: Uint8Array, + fileName: string, + timeouts: RemoteRequestTimeouts +): Promise { + await putToWebDav(config, fileName, archive, { contentType: 'application/zip' }, undefined, timeouts); return { provider: 'webdav', remotePath: buildJoinedPath(config.remotePath, fileName), @@ -336,20 +516,35 @@ function parseWebDavResponsePath(baseUrl: string, href: string): string { return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath; } -async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise { +async function listWebDavEntries( + config: WebDavBackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const currentPath = normalizeRelativePath(relativePath); const targetFullPath = webDavFullPath(config, currentPath); const authHeader = toBasicAuthHeader(config.username, config.password); - const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), { - method: 'PROPFIND', - headers: { - Authorization: authHeader, - Depth: '1', - 'Content-Type': 'application/xml; charset=utf-8', - }, - body: ``, + // 列目录的响应体很小,把「请求 + 读 body」放在同一段预算里即可 + const listing = await withRemoteTimeout('WebDAV', 'listing', timeouts.listingMs, async (signal) => { + const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), { + method: 'PROPFIND', + headers: { + Authorization: authHeader, + Depth: '1', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body: ``, + signal, + }); + if (response.status === 404) { + return { missing: true as const, xml: '' }; + } + if (!response.ok) { + throw new Error(`WebDAV listing failed: ${response.status}`); + } + return { missing: false as const, xml: await response.text() }; }); - if (response.status === 404) { + if (listing.missing) { return { provider: 'webdav', currentPath, @@ -357,11 +552,8 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath: items: [], }; } - if (!response.ok) { - throw new Error(`WebDAV listing failed: ${response.status}`); - } - const xml = await response.text(); + const xml = listing.xml; const rootFullPath = trimSlashes(config.remotePath); const items: RemoteBackupItem[] = []; for (const block of extractXmlBlocks(xml, 'response')) { @@ -401,58 +593,99 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath: }; } -async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise { +async function downloadFromWebDav( + config: WebDavBackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const normalized = normalizeRelativePath(relativePath); if (!normalized || normalized.endsWith('/')) { throw new Error('Please select a backup file'); } const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, normalized); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { - method: 'GET', - headers: { - Authorization: authHeader, - }, - }); + // 两段计时:先给“首包”,再按 Content-Length 给 body 预算。 + // 复用同一个 controller ⇒ 响应头已到达后 abort 仍能中断后面的 body 读取。 + const controller = new AbortController(); + const response = await withRemoteTimeout( + 'WebDAV', + 'download', + timeouts.firstByteMs, + (signal) => + fetch(buildWebDavUrl(config.baseUrl, remotePath), { + method: 'GET', + headers: { + Authorization: authHeader, + }, + signal, + }), + controller + ); if (!response.ok) { throw new Error(`WebDAV download failed: ${response.status}`); } + const declaredLength = Number(response.headers.get('Content-Length') || ''); + const bytes = await withRemoteTimeout( + 'WebDAV', + 'download', + resolveTransferTimeoutMs(Number.isFinite(declaredLength) ? declaredLength : undefined, timeouts), + () => response.arrayBuffer(), + controller + ); return { provider: 'webdav', remotePath: normalized, fileName: basename(normalized) || 'backup.zip', contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip', - bytes: new Uint8Array(await response.arrayBuffer()), + bytes: new Uint8Array(bytes), }; } -async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise { +async function deleteFromWebDav( + config: WebDavBackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { - method: 'DELETE', - headers: { - Authorization: authHeader, - }, - }); + const response = await withRemoteTimeout('WebDAV', 'delete', timeouts.controlMs, (signal) => + fetch(buildWebDavUrl(config.baseUrl, remotePath), { + method: 'DELETE', + headers: { + Authorization: authHeader, + }, + signal, + }) + ); if (!response.ok && response.status !== 404) { throw new Error(`WebDAV delete failed: ${response.status}`); } } -async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise { - return (await statWebDavFile(config, relativePath)) !== null; +async function existsInWebDav( + config: WebDavBackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { + return (await statWebDavFile(config, relativePath, timeouts)) !== null; } -async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise { +async function statWebDavFile( + config: WebDavBackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { - method: 'HEAD', - headers: { - Authorization: authHeader, - }, - }); + const response = await withRemoteTimeout('WebDAV', 'existence check', timeouts.controlMs, (signal) => + fetch(buildWebDavUrl(config.baseUrl, remotePath), { + method: 'HEAD', + headers: { + Authorization: authHeader, + }, + signal, + }) + ); if (response.status === 404) return null; if (!response.ok) { throw new Error(`WebDAV existence check failed: ${response.status}`); @@ -473,7 +706,7 @@ function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean { } function s3BucketBaseUrl(config: S3BackupDestination): URL { - const endpoint = new URL(config.endpoint.replace(/\/+$/, '')); + const endpoint = new URL(trimTrailingSlashes(config.endpoint)); const bucket = config.bucket.trim(); if (config.addressingStyle === 'virtual-hosted-style') { @@ -482,11 +715,11 @@ function s3BucketBaseUrl(config: S3BackupDestination): URL { return endpoint; } - return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`); + return new URL(`${trimTrailingSlashes(endpoint.toString())}/${encodeURIComponent(bucket)}`); } function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL { - return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`); + return new URL(`${trimTrailingSlashes(s3BucketBaseUrl(config).toString())}/${encodePathSegments(objectKey)}`); } function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string { @@ -497,6 +730,7 @@ async function signedS3Request( config: S3BackupDestination, method: 'GET' | 'PUT' | 'DELETE' | 'HEAD', url: URL, + signal: AbortSignal, body?: Uint8Array, contentType?: string ): Promise { @@ -528,6 +762,7 @@ async function signedS3Request( ...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}), }, body, + signal, }); } @@ -535,26 +770,41 @@ async function putToS3( config: S3BackupDestination, relativePath: string, bytes: Uint8Array, - options: RemoteBackupFilePutOptions = {} + options: RemoteBackupFilePutOptions, + timeouts: RemoteRequestTimeouts ): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = s3ObjectUrl(config, objectKey); - const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType); + const response = await withRemoteTimeout( + 'S3', + 'upload', + resolveTransferTimeoutMs(bytes.byteLength, timeouts), + (signal) => signedS3Request(config, 'PUT', url, signal, bytes, options.contentType) + ); if (!response.ok) { throw new Error(`S3 upload failed: ${response.status}`); } } -async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise { - await putToS3(config, fileName, archive, { contentType: 'application/zip' }); +async function uploadToS3( + config: S3BackupDestination, + archive: Uint8Array, + fileName: string, + timeouts: RemoteRequestTimeouts +): Promise { + await putToS3(config, fileName, archive, { contentType: 'application/zip' }, timeouts); return { provider: 's3', remotePath: normalizeS3ObjectKey(config, fileName), }; } -async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise { +async function listS3Entries( + config: S3BackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const currentPath = normalizeRelativePath(relativePath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; @@ -569,12 +819,13 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string): if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (continuationToken) url.searchParams.set('continuation-token', continuationToken); - const response = await signedS3Request(config, 'GET', url); - if (!response.ok) { - throw new Error(`S3 listing failed: ${response.status}`); - } - - const xml = await response.text(); + const xml = await withRemoteTimeout('S3', 'listing', timeouts.listingMs, async (signal) => { + const response = await signedS3Request(config, 'GET', url, signal); + if (!response.ok) { + throw new Error(`S3 listing failed: ${response.status}`); + } + return response.text(); + }); for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); @@ -588,7 +839,7 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string): : fullPrefix; const normalizedRelative = trimSlashes(relative); if (!normalizedRelative) continue; - const itemPath = normalizedRelative.replace(/\/+$/, ''); + const itemPath = trimTrailingSlashes(normalizedRelative); if ((parentPath(itemPath) || '') !== currentPath) continue; items.push({ path: itemPath, @@ -632,43 +883,79 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string): }; } -async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise { +async function downloadFromS3( + config: S3BackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const normalized = normalizeRelativePath(relativePath); if (!normalized || normalized.endsWith('/')) { throw new Error('Please select a backup file'); } const objectKey = normalizeS3ObjectKey(config, normalized); const url = s3ObjectUrl(config, objectKey); - const response = await signedS3Request(config, 'GET', url); + // 与 WebDAV 下载同构:先给“首包”,再按 Content-Length 给 body 预算,复用同一 controller + const controller = new AbortController(); + const response = await withRemoteTimeout( + 'S3', + 'download', + timeouts.firstByteMs, + (signal) => signedS3Request(config, 'GET', url, signal), + controller + ); if (!response.ok) { throw new Error(`S3 download failed: ${response.status}`); } + const declaredLength = Number(response.headers.get('Content-Length') || ''); + const bytes = await withRemoteTimeout( + 'S3', + 'download', + resolveTransferTimeoutMs(Number.isFinite(declaredLength) ? declaredLength : undefined, timeouts), + () => response.arrayBuffer(), + controller + ); return { provider: 's3', remotePath: normalized, fileName: basename(normalized) || 'backup.zip', contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip', - bytes: new Uint8Array(await response.arrayBuffer()), + bytes: new Uint8Array(bytes), }; } -async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise { +async function deleteFromS3( + config: S3BackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = s3ObjectUrl(config, objectKey); - const response = await signedS3Request(config, 'DELETE', url); + const response = await withRemoteTimeout('S3', 'delete', timeouts.controlMs, (signal) => + signedS3Request(config, 'DELETE', url, signal) + ); if (!response.ok && response.status !== 404) { throw new Error(`S3 delete failed: ${response.status}`); } } -async function existsInS3(config: S3BackupDestination, relativePath: string): Promise { - return (await statS3File(config, relativePath)) !== null; +async function existsInS3( + config: S3BackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { + return (await statS3File(config, relativePath, timeouts)) !== null; } -async function statS3File(config: S3BackupDestination, relativePath: string): Promise { +async function statS3File( + config: S3BackupDestination, + relativePath: string, + timeouts: RemoteRequestTimeouts +): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); const url = s3ObjectUrl(config, objectKey); - const response = await signedS3Request(config, 'HEAD', url); + const response = await withRemoteTimeout('S3', 'existence check', timeouts.controlMs, (signal) => + signedS3Request(config, 'HEAD', url, signal) + ); if (response.status === 404) return null; if (!response.ok) { throw new Error(`S3 existence check failed: ${response.status}`); @@ -706,7 +993,8 @@ export interface RemoteBackupTransferSession { } function resolveConfiguredDestinationAdapter( - destination: BackupDestinationRecord + destination: BackupDestinationRecord, + timeouts: RemoteRequestTimeouts ): ConfiguredDestinationAdapter { ensureDestinationConfigReady(destination); @@ -714,40 +1002,54 @@ function resolveConfiguredDestinationAdapter( return { provider: 'webdav', config: destination.destination as WebDavBackupDestination, - upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName), - putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options), - list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath), - download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), - deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), - exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), - stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath), + upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName, timeouts), + putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options ?? {}, undefined, timeouts), + list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath, timeouts), + download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath, timeouts), + deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath, timeouts), + exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath, timeouts), + stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath, timeouts), }; } if (destination.type === 's3') { return { provider: 's3', config: destination.destination as S3BackupDestination, - upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName), - putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options), - list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath), - download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), - deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), - exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), - stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath), + upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName, timeouts), + putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options ?? {}, timeouts), + list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath, timeouts), + download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath, timeouts), + deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath, timeouts), + exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath, timeouts), + stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath, timeouts), }; } throw new Error('Unsupported backup destination type'); } -export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession { - const adapter = resolveConfiguredDestinationAdapter(destination); +/** + * @param timeouts 仅供测试注入更小的值,避免单测真的等 5–30 秒;生产代码不要传。 + */ +export function createRemoteBackupTransferSession( + destination: BackupDestinationRecord, + timeouts?: Partial +): RemoteBackupTransferSession { + const resolvedTimeouts = resolveRemoteRequestTimeouts(timeouts); + const adapter = resolveConfiguredDestinationAdapter(destination, resolvedTimeouts); const ensuredDirectories = adapter.provider === 'webdav' ? new Set() : null; const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise => { const normalized = normalizeRelativePath(relativePath); if (adapter.provider === 'webdav' && ensuredDirectories) { - await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories); + await putToWebDav( + adapter.config as WebDavBackupDestination, + normalized, + bytes, + options, + ensuredDirectories, + resolvedTimeouts + ); return; } await adapter.putFile(adapter.config, normalized, bytes, options); @@ -776,37 +1078,55 @@ export function createRemoteBackupTransferSession(destination: BackupDestination export async function uploadBackupArchive( destination: BackupDestinationRecord, archive: Uint8Array, - fileName: string + fileName: string, + timeouts?: Partial ): Promise { - return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName); + return createRemoteBackupTransferSession(destination, timeouts).uploadArchive(archive, fileName); } -export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise { - return createRemoteBackupTransferSession(destination).list(relativePath); +export async function listRemoteBackupEntries( + destination: BackupDestinationRecord, + relativePath: string, + timeouts?: Partial +): Promise { + return createRemoteBackupTransferSession(destination, timeouts).list(relativePath); } -export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { - return createRemoteBackupTransferSession(destination).download(relativePath); +export async function downloadRemoteBackupFile( + destination: BackupDestinationRecord, + relativePath: string, + timeouts?: Partial +): Promise { + return createRemoteBackupTransferSession(destination, timeouts).download(relativePath); } -export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { +export async function deleteRemoteBackupFile( + destination: BackupDestinationRecord, + relativePath: string, + timeouts?: Partial +): Promise { const normalized = ensureRemoteRestoreCandidate(relativePath); - await createRemoteBackupTransferSession(destination).deleteFile(normalized); + await createRemoteBackupTransferSession(destination, timeouts).deleteFile(normalized); } -export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise { +export async function remoteBackupFileExists( + destination: BackupDestinationRecord, + relativePath: string, + timeouts?: Partial +): Promise { const normalized = normalizeRelativePath(relativePath); - return createRemoteBackupTransferSession(destination).exists(normalized); + return createRemoteBackupTransferSession(destination, timeouts).exists(normalized); } export async function uploadRemoteBackupFile( destination: BackupDestinationRecord, relativePath: string, bytes: Uint8Array, - options: RemoteBackupFilePutOptions = {} + options: RemoteBackupFilePutOptions = {}, + timeouts?: Partial ): Promise { const normalized = normalizeRelativePath(relativePath); - await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options); + await createRemoteBackupTransferSession(destination, timeouts).putFile(normalized, bytes, options); } function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number { @@ -824,10 +1144,11 @@ function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, p export async function pruneRemoteBackupArchives( destination: BackupDestinationRecord, retentionCount: number | null, - preferredFileName?: string + preferredFileName?: string, + timeouts?: Partial ): Promise { if (retentionCount === null) return 0; - const adapter = resolveConfiguredDestinationAdapter(destination); + const adapter = resolveConfiguredDestinationAdapter(destination, resolveRemoteRequestTimeouts(timeouts)); const listing = await adapter.list(adapter.config, ''); const backupFiles = listing.items .filter((item) => !item.isDirectory && isBackupArchiveName(item.name)) diff --git a/src/services/storage-account-passkey-repo.ts b/src/services/storage-account-passkey-repo.ts index 751d81621..c3147707b 100644 --- a/src/services/storage-account-passkey-repo.ts +++ b/src/services/storage-account-passkey-repo.ts @@ -241,6 +241,25 @@ export async function countAccountPasskeyCredentialsByUserId( return Number(row?.count || 0); } +/** + * 一次查出**所有**配了指定用途 passkey 的用户。 + * + * 存在的理由:管理端的用户列表需要给每个用户标一个"是否启用了双因素 passkey", + * 而 `countAccountPasskeyCredentialsByUserId()` 只能逐个用户问 —— 用户表有多大, + * 查询就有多少条。管理端用户列表只需要"有没有",用一次 `DISTINCT` 拿回集合即可。 + */ +export async function listAccountPasskeyUserIdsByPurpose( + db: D1Database, + purpose: AccountPasskeyCredential['purpose'] = 'login' +): Promise> { + await ensureAccountPasskeySchema(db); + const res = await db + .prepare('SELECT DISTINCT user_id FROM webauthn_credentials WHERE purpose = ?') + .bind(purpose) + .all<{ user_id: string }>(); + return new Set((res.results ?? []).map((row) => row.user_id)); +} + export async function updateAccountPasskeyCounter( db: D1Database, userId: string, @@ -294,7 +313,20 @@ export async function saveAccountPasskeyChallenge( challenge: AccountPasskeyChallenge ): Promise { await ensureAccountPasskeySchema(db); - await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run(); + + // 清理旧挑战。这里原本是一条 `WHERE expires_at < ? OR used_at IS NOT NULL`。 + // + // 实测(EXPLAIN QUERY PLAN):**OR 里只要有一侧没有可用索引,SQLite 就整个退化成 + // 全表扫** —— 连已经建好的 idx_webauthn_challenges_expires 都白费了。 + // 拆成两条之后,每条都能各自走索引(`SEARCH … USING INDEX`)。 + // 代价是多一次往返;换来的是这个"每次保存挑战都会跑"的清理不再全表扫。 + // + // 注意两条的顺序无关紧要(都是删除),但**都必须在 INSERT 之前**, + // 否则刚写入的挑战可能被自己的清理句删掉。 + const now = Date.now(); + await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ?').bind(now).run(); + await db.prepare('DELETE FROM webauthn_challenges WHERE used_at IS NOT NULL').run(); + await db .prepare( 'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' + diff --git a/src/services/storage-admin-repo.ts b/src/services/storage-admin-repo.ts index 2c9caba37..acb704eb4 100644 --- a/src/services/storage-admin-repo.ts +++ b/src/services/storage-admin-repo.ts @@ -20,6 +20,8 @@ function auditLogFromRow(row: any): AuditLog { return { id: row.id, actorUserId: row.actor_user_id ?? null, + // 来自 `COALESCE(l.actor_email, actor.email)`:优先用行内快照(历史事实), + // 快照为空(老数据未回填)时才回退到 JOIN 出来的当前邮箱。 actorEmail: row.actor_email ?? null, action: row.action, category: row.category || 'system', @@ -56,9 +58,11 @@ function buildAuditWhere(options: AuditLogListOptions): { where: string; params: const q = options.q.toLowerCase().slice(0, 48); const like = `%${q}%`; conditions.push( - '(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)' + '(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.actor_email, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)' ); - params.push(like, like, like, like, like, like); + // 注意 `l.actor_email`(行内快照)这一项不能漏:恢复/删用户会把 actor_user_id 置空, + // 之后按操作者邮箱搜索只能靠快照命中,`actor.email` 那个 JOIN 已经查不到东西了。 + params.push(like, like, like, like, like, like, like); } return { @@ -176,9 +180,14 @@ export async function deleteAllInvites(db: D1Database): Promise { export async function createAuditLog(db: D1Database, log: AuditLog): Promise { await db .prepare( - 'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)' + // actor_email 用子查询**就地**抄一份操作者邮箱(原因见 storage-schema.ts 的列注释): + // 同一条语句完成,不额外多一次 D1 往返 —— 审计写入几乎每个变更操作都会触发。 + // + // 新列刻意放在**列尾**:前 9 个绑定参数的位置保持原样, + // `scripts/security-audit-api-key-semantics.mjs` 依赖 `bindings[2] === action`。 + 'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at, actor_email) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT email FROM users WHERE id = ?))' ) - .bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt) + .bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt, log.actorUserId) .run(); } @@ -208,28 +217,66 @@ export async function clearAuditLogs(db: D1Database): Promise { return Number(result.meta.changes ?? 0); } +/** + * 列表查询的 FROM/JOIN 子句。 + * + * `buildAuditWhere` 生成的 WHERE 可能引用 `actor.email` / `target.email` + * (关键词搜索会查这两列),因此**计数查询必须复用同一段 FROM**, + * 否则会出现 "no such column: actor.email" 或口径不一致。 + */ +const AUDIT_LIST_FROM = + 'FROM audit_logs l ' + + // actor JOIN 现在只是**兜底**:正常情况下操作者邮箱取自 `l.actor_email` 行内快照, + // 只有老数据没回填到快照时才需要 JOIN 出当前邮箱。 + 'LEFT JOIN users actor ON actor.id = l.actor_user_id ' + + "LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id "; + export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise { const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50))); const offset = Math.max(0, Math.floor(options.offset || 0)); const { where, params } = buildAuditWhere(options); - const rows = await db - .prepare( - 'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' + - 'FROM audit_logs l ' + - 'LEFT JOIN users actor ON actor.id = l.actor_user_id ' + - "LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " + - `${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?` - ) - .bind(...params, limit + 1, offset) - .all(); + // 计数查询: + // - 无关键词时 WHERE 只引用 l.*,因此**不要 JOIN**(去掉 JOIN 后规划器会走 + // `SEARCH/SCAN ... USING COVERING INDEX idx_audit_logs_category_created`,实测 0.1 ms); + // - 有关键词时 WHERE 会引用 actor.email / target.email,必须复用同一段 FROM, + // 否则列名不存在或口径不一致(这条 `LIKE '%q%'` 本来就无法用索引,详见 query-plan 白名单)。 + const countSql = options.q + ? `SELECT COUNT(*) AS total ${AUDIT_LIST_FROM}${where}` + : `SELECT COUNT(*) AS total FROM audit_logs l ${where}`; + + // 两个查询并行发出,避免给翻页多叠加一次串行往返。 + const [rows, countRow] = await Promise.all([ + db + .prepare( + // actor_email:快照优先、JOIN 兜底。快照是「写入当时」的邮箱,这才是审计日志该有的 + // 含义;JOIN 出来的是「此刻」的邮箱,用户改过邮箱后它会把旧日志显示成新邮箱。 + 'SELECT l.id, l.actor_user_id, COALESCE(l.actor_email, actor.email) AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' + + `${AUDIT_LIST_FROM}${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?` + ) + .bind(...params, limit + 1, offset) + .all(), + db + .prepare(countSql) + .bind(...params) + .first<{ total: number | string | null }>(), + ]); + const results = rows.results || []; const logs = results.slice(0, limit).map(auditLogFromRow); + // 多取一行来判断"还有更多",避免为此再发一次查询。 const hasMore = results.length > limit; + // 分页分母必须是**真实总数**。 + // + // 这里曾经是 `offset + logs.length + (hasMore ? 1 : 0)` —— 那是"已走过的行数 + 本页行数", + // 于是每翻一页分母恰好 +limit,`ceil(total/limit)` 恒等于"页码 + 1", + // 用户永远看不到真实总条数/真实总页数(只有最后一页凑巧是对的)。 + const total = Number(countRow?.total ?? 0); + return { logs, - total: offset + logs.length + (hasMore ? 1 : 0), + total, hasMore, }; } diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts index 21303cde4..2bd04a63b 100644 --- a/src/services/storage-cipher-repo.ts +++ b/src/services/storage-cipher-repo.ts @@ -125,7 +125,7 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip 'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' + 'WHERE user_id=excluded.user_id' ); - await safeBind( + const result = await safeBind( stmt, cipher.id, cipher.userId, @@ -142,6 +142,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip cipher.archivedAt ?? null, cipher.deletedAt ).run(); + + // `ON CONFLICT(id) DO UPDATE ... WHERE user_id=excluded.user_id` 用于阻止跨用户覆盖: + // 当该 id 已被他人占用时,该语句既不更新也不报错,静默 no-op。 + // 这里显式检查受影响行数,避免把「写入被拒绝」当成保存成功。 + // 正常路径不会命中:handler 均先经 getCipherForUser 校验归属; + // updateCipherRevisionDate 传入的是刚从库中读出的对象,user_id 必然一致。 + if (!result?.meta?.changes) { + throw new Error('Cipher could not be saved'); + } } function sanitizeIds(ids: string[]): string[] { diff --git a/src/services/storage-config-repo.ts b/src/services/storage-config-repo.ts index 6163114a3..d5de08fde 100644 --- a/src/services/storage-config-repo.ts +++ b/src/services/storage-config-repo.ts @@ -20,3 +20,23 @@ export async function setRegistered(db: D1Database): Promise { .bind('registered', 'true') .run(); } + +/** + * 「先写后判」的原子认领:只有候选值与库中现值**不同**时才真正写入, + * 返回本次调用是否**赢得了这次变更**。 + * + * 为什么不能写成「先读 → 比较 → 再写」:并发调用(多个 isolate 同时冷启动) + * 会读到同样的旧值、都以为“变了”,于是重复执行后续动作(例如重复写一条审计日志)。 + * 这里把判断挪进同一条 SQL,D1 又是单写者,因此并发的重复调用里只有一个能拿到 + * `meta.changes === 1`,其余拿到 0。 + */ +export async function claimConfigValue(db: D1Database, key: string, value: string): Promise { + const result = await db + .prepare( + 'INSERT INTO config(key, value) VALUES(?, ?) ' + + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value WHERE config.value <> excluded.value' + ) + .bind(key, value) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index b4b6cd4d5..60ae6a867 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -3,6 +3,7 @@ import { generateUUID } from '../utils/uuid'; type GetUserByEmail = (email: string) => Promise; type TrustedTokenKeyFn = (token: string) => Promise; +type SqlChunkSize = (fixedBindCount: number) => number; function mapDeviceRow(row: any): Device { return { @@ -141,6 +142,52 @@ export async function updateDeviceKeys( return Number(result.meta.changes ?? 0) > 0; } +export interface DeviceKeyUpdate { + deviceIdentifier: string; + keys: { + encryptedUserKey?: string | null; + encryptedPublicKey?: string | null; + encryptedPrivateKey?: string | null; + }; +} + +/** + * 批量更新多台设备的密钥,返回实际改动的行数。 + * + * 为什么需要它:调用方原来在 handler 里逐台 `await updateDeviceKeys(...)`, + * 等待轮数 = 设备数。而这个列表来自**客户端请求体**,没有天然上界 —— + * 1000 个条目就是 1000 次串行往返。改成一条 batch 后等待轮数恒为 1。 + * + * 调用方需自行保证 `updates.length` 有上界(见 LIMITS.device.maxBulkIdentifiers)。 + */ +export async function updateDeviceKeysBatch( + db: D1Database, + userId: string, + updates: ReadonlyArray +): Promise { + if (updates.length === 0) return 0; + + const now = new Date().toISOString(); + const statements = updates.map((update) => + db + .prepare( + 'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' + + 'WHERE user_id = ? AND device_identifier = ?' + ) + .bind( + update.keys.encryptedUserKey ?? null, + update.keys.encryptedPublicKey ?? null, + update.keys.encryptedPrivateKey ?? null, + now, + userId, + update.deviceIdentifier + ) + ); + + const results = await db.batch(statements); + return results.reduce((sum, result) => sum + Number(result.meta?.changes ?? 0), 0); +} + export async function clearDeviceKeys( db: D1Database, userId: string, @@ -312,6 +359,43 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI return Number(result.meta.changes ?? 0); } +/** + * 一次删掉多台设备的"记住此设备"令牌。 + * + * 为什么需要它:`handleUntrustDevices` 原来对每台设备 `await deleteTrustedTwoFactorTokensByDevice(...)`, + * 等待轮数 = 请求体里的设备数 —— 而那个列表是客户端可控的。 + * 改用一条 `IN (...)` 后等待轮数恒为 1。 + * + * `sqlChunkSize` 用于遵守 D1 的单语句变量上限(同 `deleteStoredSends` 等函数)。 + */ +export async function deleteTrustedTwoFactorTokensByDevices( + db: D1Database, + userId: string, + deviceIdentifiers: ReadonlyArray, + sqlChunkSize: SqlChunkSize +): Promise { + const uniqueIdentifiers = [...new Set(deviceIdentifiers.filter(Boolean))]; + if (uniqueIdentifiers.length === 0) return 0; + + // user_id 占 1 个固定变量 + const chunkSize = sqlChunkSize(1); + let deleted = 0; + + for (let index = 0; index < uniqueIdentifiers.length; index += chunkSize) { + const chunk = uniqueIdentifiers.slice(index, index + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const result = await db + .prepare( + `DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier IN (${placeholders})` + ) + .bind(userId, ...chunk) + .run(); + deleted += Number(result.meta.changes ?? 0); + } + + return deleted; +} + export async function updateTrustedTwoFactorTokensExpiryByDevice( db: D1Database, userId: string, diff --git a/src/services/storage-folder-repo.ts b/src/services/storage-folder-repo.ts index 254f34300..b34b90478 100644 --- a/src/services/storage-folder-repo.ts +++ b/src/services/storage-folder-repo.ts @@ -64,49 +64,6 @@ export async function clearFolderFromCiphers( .run(); } -export async function bulkDeleteFolders( - db: D1Database, - userId: string, - ids: string[], - sqlChunkSize: (fixedBindCount: number, bindCountPerItem?: number) => number, - updateRevisionDate: (userId: string) => Promise -): Promise { - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - - const now = new Date().toISOString(); - // Each folder ID is bound in all three compatibility predicates below. - const chunkSize = sqlChunkSize(2, 3); - const statements: D1PreparedStatement[] = []; - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - statements.push( - db.prepare( - `UPDATE ciphers - SET folder_id = NULL, updated_at = ?, - data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') - WHERE user_id = ? - AND ( - folder_id IN (${placeholders}) - OR json_extract(data, '$.folderId') IN (${placeholders}) - OR json_extract(data, '$.folder_id') IN (${placeholders}) - )` - ) - .bind(now, userId, ...chunk, ...chunk, ...chunk) - ); - statements.push( - db.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) - .bind(userId, ...chunk) - ); - } - - await db.batch(statements); - - return updateRevisionDate(userId); -} - export async function getAllFolders(db: D1Database, userId: string): Promise { const res = await db .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index e2b143325..009725b8f 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -9,7 +9,11 @@ // - If the new table stores persistent data, update the backup export/import // contract in src/services/backup-archive.ts and backup-import.ts. // - Keep statements idempotent; D1 may execute them again on later requests. -const SCHEMA_STATEMENTS: readonly string[] = [ +import { generateUUID } from '../utils/uuid'; + +// 导出供工具使用:scripts/migration-upgrade.test.ts 需要据此机械推导出「哪些列是后来 +// 加的」,才能构造出「老库」形态并验证 bootstrap 能补齐 —— 这样测试不会随代码演进失效。 +export const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS users (' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + @@ -77,6 +81,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', + // 清理用:`DELETE FROM refresh_tokens WHERE expires_at < ?` 是周期性全表跑的, + // 只按 expires_at 过滤,所以 idx_refresh_tokens_user 帮不上忙(最左列是 user_id)。 + 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT', @@ -98,12 +105,32 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE TABLE IF NOT EXISTS audit_logs (' + - 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + + 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, actor_email TEXT, ' + 'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)', 'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'', 'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'', + // actor_email:操作者邮箱的**行内快照**(写入日志时就把邮箱抄进那一行)。 + // + // 为什么必须抄一份:actor_user_id 上挂着 `ON DELETE SET NULL` 外键,而 + // `DELETE FROM users` 不止恢复时会跑 —— 管理端删除用户 + //(storage-user-repo.deleteUserById)同样会触发。那一刻该用户**所有历史日志**的 + // actor_user_id 都会被置成 NULL,而且**不会自愈**:即便用户随后被重新写回 + //(恢复流程就是这样,id 完全一样),也没有任何代码把这个值算回来。 + // 后果是日志中心的「操作者」永久显示 `—`、按操作者邮箱搜索永久失效。 + // + // 有了行内快照后,被置空的只剩「编号」这一列,邮箱仍留在同一行里; + // 读取端用 COALESCE(actor_email, JOIN) 因此照常显示。 + // 这与 target 侧早就在用的做法一致(metadata.targetEmail 同样是写入时的快照)。 + 'ALTER TABLE audit_logs ADD COLUMN actor_email TEXT', 'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')', 'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')', + // 回填历史行:趁 actor_user_id 还有效,把邮箱抄到快照列里。 + // + // - 幂等:只处理 actor_email IS NULL 的行,重复执行安全(第二次是空转)。 + // - 时序硬约束:**必须在任何一次恢复(或删除用户)之前跑过**。 + // 一旦 actor_user_id 已被置空,就没有回填依据了,那批日志永久丢失。 + // - 量大时可分批(D1 单查询 30s 上限),此处 502 行级别单条语句即可。 + 'UPDATE audit_logs SET actor_email = (SELECT users.email FROM users WHERE users.id = audit_logs.actor_user_id) WHERE actor_email IS NULL AND actor_user_id IS NOT NULL', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)', @@ -138,11 +165,17 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)', 'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)', 'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)', + // 清理用:`DELETE FROM auth_requests WHERE creation_date < ?` 不带 user_id, + // 上面三个索引的最左列都是 user_id,因此都用不上。 + 'CREATE INDEX IF NOT EXISTS idx_auth_requests_creation_date ON auth_requests(creation_date)', 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', + // 清理用:`DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?` + // 只按 expires_at 过滤(同样用不上以 user_id 开头的索引)。 + 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_expires ON trusted_two_factor_device_tokens(expires_at)', 'CREATE TABLE IF NOT EXISTS totp_login_replays (' + 'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' + @@ -164,12 +197,22 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)', + // 清理用的语句原来是 `WHERE expires_at < ? OR used_at IS NOT NULL`(单条)。 + // 实测:只要 OR 里有一侧没有可用索引,SQLite 就整个退化成全表扫 —— + // 连上面那个 expires_at 索引都白建了。已改成两条 DELETE(见 + // storage-account-passkey-repo.ts),因此这里补上第二侧所需的索引。 + 'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_used_at ON webauthn_challenges(used_at)', 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', + // 清理用:`DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)` + // 只按 updated_at 过滤,主键(ip)帮不上忙。 + 'CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_updated_at ON login_attempts_ip(updated_at)', 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', + // 清理用:`DELETE FROM used_attachment_download_tokens WHERE expires_at < ?` + 'CREATE INDEX IF NOT EXISTS idx_used_attachment_download_tokens_expires ON used_attachment_download_tokens(expires_at)', ]; async function executeSchemaStatement(db: D1Database, statement: string): Promise { @@ -184,12 +227,48 @@ async function executeSchemaStatement(db: D1Database, statement: string): Promis } } +// 兜底提权时补写审计事件。 +// 注意:此处运行在 schema 初始化阶段,只有裸 D1Database,无法通过 +// writeAuditEvent 写入(需要 StorageService,且会造成模块循环依赖), +// 因此直接插入 audit_logs。审计写入失败不得中断数据库初始化。 +async function writeBootstrapAdminAuditEvent(db: D1Database, userId: string): Promise { + try { + await db + .prepare( + 'INSERT INTO audit_logs (id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) ' + + 'VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)', + ) + .bind( + generateUUID(), + 'user.bootstrap.admin_promoted', + 'security', + 'security', + 'user', + userId, + JSON.stringify({ reason: 'no_admin_present' }), + new Date().toISOString(), + ) + .run(); + } catch (error) { + console.error('bootstrap admin audit log write failed', error); + } +} + async function ensureAdminUserExists(db: D1Database): Promise { - const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>(); + // 口径必须与 handlers/admin.ts 的 isAdmin() 一致:role='admin' **且** status='active'。 + // 只查 role 会漏掉两种状态,而且后果都是**永久**的(只能手工改库): + // ① 唯一的管理员被 ban 了 ⇒ 这里以为"已经有管理员"而直接返回; + // ② 只查 role 还会把 banned 用户当成提权对象 ⇒ 提权后 isAdmin() 仍为 false, + // 于是下一次运行又走到 ① 分支。 + const admin = await db + .prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active' LIMIT 1") + .first<{ id: string }>(); if (admin?.id) return; + // 同样只在**可登录**的用户里挑候选人:把一个 banned 用户提权成管理员毫无意义 + // (isAdmin() 要求 status='active'),只会制造 ② 那个状态。 const firstUser = await db - .prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1') + .prepare("SELECT id FROM users WHERE status = 'active' ORDER BY created_at ASC LIMIT 1") .first<{ id: string }>(); if (!firstUser?.id) return; @@ -197,6 +276,8 @@ async function ensureAdminUserExists(db: D1Database): Promise { .prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?") .bind(new Date().toISOString(), firstUser.id) .run(); + + await writeBootstrapAdminAuditEvent(db, firstUser.id); } export async function ensureStorageSchema(db: D1Database): Promise { diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts index e91380dd5..bc671e2eb 100644 --- a/src/services/storage-user-repo.ts +++ b/src/services/storage-user-repo.ts @@ -61,6 +61,21 @@ export async function getUserCount(db: D1Database): Promise { return Number(row?.count || 0); } +/** + * 「还能用的管理员」数量。 + * + * 口径必须与 `handlers/admin.ts` 的 `isAdmin()` 一致:`role = 'admin'` **且** `status = 'active'`。 + * 只数 `role` 会把**被 ban 的管理员**也算进去 —— 那种行占着“有管理员”的名额, + * 却登不进管理端,于是“最后一个可用管理员已被移除”成了一个隐形状态 + * (`ensureAdminUserExists` 历史上就踩过:它只看 role,于是直接返回、不再兜底)。 + */ +export async function countActiveAdmins(db: D1Database): Promise { + const row = await db + .prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND status = 'active'") + .first<{ count: number }>(); + return Number(row?.count || 0); +} + export async function getAllUsers(db: D1Database): Promise { const res = await db .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`) diff --git a/src/services/storage.ts b/src/services/storage.ts index fd21b4140..8192220a7 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,6 +1,5 @@ import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types'; import { LIMITS } from '../config/limits'; -import { ensurePushInstallationCredentials } from './push-relay'; import { ensureStorageSchema } from './storage-schema'; import { getConfigValue as getStoredConfigValue, @@ -11,6 +10,7 @@ import { import { createFirstUser as createFirstStoredUser, createUser as createStoredUser, + countActiveAdmins as countStoredActiveAdmins, deleteUserById as deleteStoredUserById, getAllUsers as listStoredUsers, getUser as findStoredUserByEmail, @@ -36,7 +36,6 @@ import { revertInviteUsed as revertStoredInviteUsed, } from './storage-admin-repo'; import { - bulkDeleteFolders as deleteStoredFolders, clearFolderFromCiphers as clearStoredFolderFromCiphers, deleteFolder as deleteStoredFolder, getAllFolders as listStoredFolders, @@ -102,6 +101,7 @@ import { clearDevicePushToken as clearStoredDevicePushToken, clearDeviceKeys as clearStoredDeviceKeys, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, + deleteTrustedTwoFactorTokensByDevices as deleteStoredTrustedTokensByDevices, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, getDevice as findStoredDevice, getDevicePushUuid as findStoredDevicePushUuid, @@ -116,6 +116,8 @@ import { upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, + updateDeviceKeysBatch as updateStoredDeviceKeysBatch, + type DeviceKeyUpdate, updateDevicePushToken as updateStoredDevicePushToken, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, userHasPushDevice as getUserHasPushDevice, @@ -148,6 +150,7 @@ import { import { consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge, countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId, + listAccountPasskeyUserIdsByPurpose as listStoredAccountPasskeyUserIdsByPurpose, deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential, getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId, getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById, @@ -164,7 +167,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse'; +const STORAGE_SCHEMA_VERSION = '2026-09-15-audit-actor-email'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; // D1-backed storage. @@ -264,7 +267,17 @@ export class StorageService { await ensureStorageSchema(this.db); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); } - await ensurePushInstallationCredentials(this.db); + + // 刻意不在这里注册 Bitwarden 的 push installation。 + // + // 此处曾调用 `ensurePushInstallationCredentials(this.db)`,但它会在缺少缓存凭据时 + // 向 `api.bitwarden.com/installations` 发起真实出站 POST —— 而本函数是每个 isolate + // **首次请求**的必经之路,于是冷启动平白依赖一个第三方服务(失败被吞掉、不影响功能, + // 但会增加延迟,实测这一步耗时 1-2 秒)。 + // + // 而真正需要凭据的两处(`getPushAccessToken`、设备注册)都会自己先调 + // `ensurePushInstallationCredentials`,因此移除这里的预热不会影响推送功能。 + // 另:`/config` 硬编码 `pushTechnology: 0` 与 `'web-push': false`,客户端本就不会使用推送。 StorageService.schemaVerified = true; } @@ -321,6 +334,11 @@ export class StorageService { return deleteStoredUserById(this.db, id); } + /** 还能用的管理员数量(role=admin 且 status=active)—— 见 storage-user-repo 的口径说明 */ + async countActiveAdmins(): Promise { + return countStoredActiveAdmins(this.db); + } + async createInvite(invite: Invite): Promise { await createStoredInvite(this.db, invite); } @@ -428,6 +446,14 @@ export class StorageService { return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose); } + /** + * 一次拿回"所有配了指定用途 passkey 的用户"。 + * 用于列表类场景,避免逐用户调 `countAccountPasskeyCredentialsByUserId()` 的 N+1。 + */ + async listAccountPasskeyUserIds(purpose: AccountPasskeyCredential['purpose'] = 'login'): Promise> { + return listStoredAccountPasskeyUserIdsByPurpose(this.db, purpose); + } + async updateAccountPasskeyCounter( userId: string, credentialId: string, @@ -549,16 +575,6 @@ export class StorageService { await deleteStoredFolder(this.db, id, userId); } - async bulkDeleteFolders(ids: string[], userId: string): Promise { - return deleteStoredFolders( - this.db, - userId, - ids, - this.sqlChunkSize.bind(this), - this.updateRevisionDate.bind(this) - ); - } - // Clear folder references from all ciphers owned by the user. // Without this, deleting a folder leaves stale folderId values in cipher JSON. async clearFolderFromCiphers(userId: string, folderId: string): Promise { @@ -797,6 +813,24 @@ export class StorageService { return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); } + /** 批量更新多台设备的密钥,返回改动的行数(等待轮数恒为 1,不随设备数增长) */ + async updateDeviceKeysBatch(userId: string, updates: ReadonlyArray): Promise { + return updateStoredDeviceKeysBatch(this.db, userId, updates); + } + + /** 一次删掉多台设备的"记住此设备"令牌 */ + async deleteTrustedTwoFactorTokensByDevices( + userId: string, + deviceIdentifiers: ReadonlyArray + ): Promise { + return deleteStoredTrustedTokensByDevices( + this.db, + userId, + deviceIdentifiers, + (fixedBindCount) => this.sqlChunkSize(fixedBindCount) + ); + } + async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise { return updateStoredDeviceName(this.db, userId, deviceIdentifier, name); } diff --git a/src/types/index.ts b/src/types/index.ts index 01079b7d8..70b7ac3c0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,14 @@ export interface Env { ATTACHMENTS?: R2Bucket; // Optional fallback for attachment/send file storage (no credit card required). ATTACHMENTS_KV?: KVNamespace; + // Cloudflare 版本元数据绑定(见 wrangler.toml 的 [version_metadata])。 + // `id` 每次构建/部署都不同,因此它是识别“版本号没变但重新部署了”的唯一依据。 + // 本地 dev 与测试环境可能没有这个绑定,所以整块可选、字段也可选。 + CF_VERSION_METADATA?: { + id?: string; + tag?: string; + timestamp?: string; + }; JWT_SECRET: string; WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_NAME?: string; diff --git a/src/utils/account-passkeys.ts b/src/utils/account-passkeys.ts index 3256243ef..f63fd25eb 100644 --- a/src/utils/account-passkeys.ts +++ b/src/utils/account-passkeys.ts @@ -1,6 +1,6 @@ import type { AuthenticationResponseJSON, - AuthenticatorTransportFuture, + AuthenticatorTransport, RegistrationResponseJSON, WebAuthnCredential, } from '@simplewebauthn/server'; @@ -68,7 +68,11 @@ function uuidToDotNetGuidBytes(value: string): Uint8Array | null { } function normalizeWebAuthnBase64(value: unknown): string { - return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + const normalized = String(value || '').replace(/\+/g, '-').replace(/\//g, '_'); + // 去掉结尾的 '=' 填充:用循环而不是 /=+$/(语义一致,且不命中 CodeQL js/polynomial-redos) + let end = normalized.length; + while (end > 0 && normalized[end - 1] === '=') end -= 1; + return normalized.slice(0, end); } async function importHmacKey(secret: string): Promise { @@ -226,7 +230,7 @@ export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential) id: credential.credentialId, publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)), counter: credential.counter, - transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined, + transports: (credential.transports || undefined) as AuthenticatorTransport[] | undefined, }; } @@ -246,7 +250,7 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons attestationObject: normalizeWebAuthnBase64(response.attestationObject), clientDataJSON: normalizeWebAuthnBase64(clientDataJSON), authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined, - transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined, + transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransport[] : undefined, publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined, publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined, }, diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 32f9e17ed..db0ea5dd3 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -42,7 +42,7 @@ function getHmacKey(secret: string): Promise { export async function createJWT(payload: Omit, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise { const header = { alg: 'HS256', typ: 'JWT' }; const now = Math.floor(Date.now() / 1000); - + const fullPayload: JWTPayload = { ...payload, email_verified: true, // required by mobile client @@ -56,14 +56,14 @@ export async function createJWT(payload: Omit { const header = { alg: 'HS256', typ: 'JWT' }; const now = Math.floor(Date.now() / 1000); - + const payload: FileDownloadClaims = { cipherId, attachmentId, @@ -137,14 +137,14 @@ export async function createFileDownloadToken( const encoder = new TextEncoder(); const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); - + const data = `${headerB64}.${payloadB64}`; - + const key = await getHmacKey(secret); - + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signatureB64 = base64UrlEncode(new Uint8Array(signature)); - + return `${data}.${signatureB64}`; } @@ -159,17 +159,23 @@ export async function verifyFileDownloadToken( const [headerB64, payloadB64, signatureB64] = parts; const encoder = new TextEncoder(); - + const key = await getHmacKey(secret); - + const data = `${headerB64}.${payloadB64}`; const signature = base64UrlDecode(signatureB64); - + const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data)); if (!valid) return null; const payload: FileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); - + + // 用途隔离显式化:本令牌与访问令牌共用 JWT_SECRET,这里主动拒绝 + // 访问令牌(其特征为携带 sstamp / sub),避免隔离仅依赖 + // 「调用方随后还会比对 cipherId」这一隐式约定。 + const raw = payload as unknown as Record; + if (raw.sstamp || raw.sub) return null; + // Check expiration const now = Math.floor(Date.now() / 1000); if (payload.exp < now) return null; @@ -226,6 +232,15 @@ export async function verifyAttachmentUploadToken( if (!valid) return null; const payload: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + // 同上:显式拒绝访问令牌,保持用途隔离为显式契约。 + // + // 这里同时看 `sstamp` 与 `sub`:访问令牌两者都有,但 `sstamp` 来自 + // `users.security_stamp`(库定义是 `TEXT NOT NULL`,**并不排除空串**), + // 只查它会在 securityStamp 为空时漏掉。`sub` 恒为用户 id、不可能为空, + // 因此与 `verifyFileDownloadToken` 保持一致地两样都查。 + // 上行令牌的声明里没有 `sub`(见 AttachmentUploadClaims),不会误伤合法令牌。 + const rawUploadClaims = payload as unknown as Record; + if (rawUploadClaims.sstamp || rawUploadClaims.sub) return null; const now = Math.floor(Date.now() / 1000); if (payload.exp < now) return null; if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null; diff --git a/src/utils/request-timeout.ts b/src/utils/request-timeout.ts new file mode 100644 index 000000000..036c1d048 --- /dev/null +++ b/src/utils/request-timeout.ts @@ -0,0 +1,46 @@ +/** + * 外发请求的超时封装(共用实现)。 + * + * 为什么必须有:对端「连上了但不回包」时(黑洞 IP、防火墙丢包、被暂停的容器、挂住的 + * TLS 握手),`fetch` 可能**永不 settle** ⇒ 异常永远抛不出来 ⇒ 调用方的 catch 永不执行 ⇒ + * 请求一直挂着,最后由平台兜底返回通用 500(`internal error; reference = …`), + * 运维只拿到一个引用号,无法自助排查。 + * + * 约定:包的是**整段操作**(发送请求 + 读响应体)。`fetch` 在收到响应头时就 resolve 了, + * 「发了头就不再发数据」卡住的正是 `await response.text()` / `arrayBuffer()` 那一步 —— + * 所以 `run` 里要把读 body 也一起做完(见 `backup-uploader.ts` 的下载路径)。 + */ + +/** 超时错误。调用方据此区分「超时」与「对端主动失败(如 ECONNREFUSED)」。 */ +export class RequestTimeoutError extends Error { + constructor(readonly timeoutMs: number) { + super(`Request timed out after ${timeoutMs} ms`); + this.name = 'RequestTimeoutError'; + } +} + +export function isRequestTimeoutError(error: unknown): error is RequestTimeoutError { + return error instanceof RequestTimeoutError; +} + +/** + * @param timeoutMs 预算(毫秒) + * @param run 实际操作;必须把 `signal` 透传给 `fetch` + * @param controller 需要「两段计时」(先首包、再按体积读 body)时复用同一个 controller + */ +export async function withRequestTimeout( + timeoutMs: number, + run: (signal: AbortSignal) => Promise, + controller: AbortController = new AbortController() +): Promise { + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await run(controller.signal); + } catch (error) { + // 只有我们自己 abort 才会把 signal 置为 aborted;对端主动断开等情况保持原样 + if (controller.signal.aborted) throw new RequestTimeoutError(timeoutMs); + throw error; + } finally { + clearTimeout(timer); + } +} diff --git a/src/utils/response.ts b/src/utils/response.ts index 030ef1889..b8689d316 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -114,7 +114,7 @@ export function applyCors( headers.delete('X-Frame-Options'); headers.set( 'Content-Security-Policy', - "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'" + "default-src 'none'; script-src 'self'; worker-src 'self' blob:; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'" ); } else { headers.set('X-Frame-Options', 'DENY'); diff --git a/src/utils/totp.ts b/src/utils/totp.ts index b8f5a9b9f..a54969f43 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -105,3 +105,24 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs export function isTotpEnabled(secretRaw: string | undefined | null): boolean { return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0); } + +/** + * 密钥是否是**可用的** base32(字母表合法、真能算出验证码)。 + * + * 为什么必须有它:`isTotpEnabled()` 只看“有没有值”,而 `base32Decode()` 碰到 A–Z2–7 之外的 + * 字符(手输/粘贴常带入的 `0`/`1`/`8`/`9`、全角字符、非 ASCII 空格)会返回 null, + * 于是 `findMatchingTotpCounter()` 永远返回 null —— 表现为“两步验证显示已启用、客户端确实要码, + * 但**任何码都不对**”的隐蔽死锁。 + * + * 两个函数的分工(刻意如此,改动前请先读完): + * - **写入 / 启用路径用本函数** ⇒ 非法密钥在启用的那一刻就被明确拒绝, + * 而不是等用户登录时才发现“输什么都不对”。 + * - **读取路径(登录时判断要不要收两步验证)继续用 `isTotpEnabled`** ⇒ 保持 **fail-closed**: + * 库里存了一把坏密钥时仍然要求两步验证,而不是静默降级成“只要密码就能登录”。 + * 坏密钥的出路是恢复码,或在设置页用 `get-authenticator`(它会返回 Enabled:false + 一把新密钥) + * 重新启用一次。 + */ +export function isValidTotpSecret(secretRaw: string | undefined | null): boolean { + if (!secretRaw) return false; + return base32Decode(secretRaw) !== null; +} diff --git a/src/utils/yubico-otp.ts b/src/utils/yubico-otp.ts index 38c4c05eb..05ebcd20d 100644 --- a/src/utils/yubico-otp.ts +++ b/src/utils/yubico-otp.ts @@ -1,4 +1,5 @@ import type { Env, User } from '../types'; +import { isRequestTimeoutError, withRequestTimeout } from './request-timeout'; const YUBIKEY_PUBLIC_ID_LENGTH = 12; const YUBIKEY_MIN_OTP_LENGTH = 32; @@ -7,6 +8,36 @@ const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify'; const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/'; const MODHEX_RE = /^[cbdefghijklnrtuv]+$/; +/** + * 外发请求超时预算。 + * + * Yubico 的两个端点都在交互路径上:一个是登录的二步验证,一个是管理员启用 YubiKey 时 + * 取 API 凭据。两处原先都没有超时 —— 对端「连上但不回包」时请求会一直挂着, + * 最后由平台兜底返回通用 500:用户既登不进去,也看不到原因。 + */ +const YUBICO_API_KEY_REQUEST_TIMEOUT_MS = 5_000; +const YUBICO_VALIDATION_REQUEST_TIMEOUT_MS = 5_000; + +/** 仅供测试注入更小的超时,避免单测真的等 5 秒;生产代码不要传。 */ +export interface YubicoRequestOptions { + requestTimeoutMs?: number; +} + +function resolveRequestTimeoutMs(override: number | undefined, fallback: number): number { + return typeof override === 'number' && Number.isFinite(override) && override > 0 + ? Math.floor(override) + : fallback; +} + +/** 只取主机名:校验地址的查询串里含一次性口令,绝不能进日志。 */ +function safeHostname(value: string): string { + try { + return new URL(value).host; + } catch { + return 'unknown-host'; + } +} + export interface YubicoApiCredentials { clientId: string; secretKey: string; @@ -112,7 +143,11 @@ function validationUrls(env: Env): string[] { return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL]; } -export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise { +export async function requestYubicoApiCredentials( + email: string, + otpInput: string, + options: YubicoRequestOptions = {} +): Promise { const otp = normalizeYubiKeyOtp(otpInput); if (!isYubiKeyOtp(otp)) return null; @@ -121,23 +156,41 @@ export async function requestYubicoApiCredentials(email: string, otpInput: strin body.set('otp', otp); body.set('terms_conditions', 'consented'); - const response = await fetch(YUBICO_GET_API_KEY_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - if (!response.ok) return null; - - const html = await response.text(); - const clientId = /Client ID:<\/th>\s*(\d+)<\/b>/i.exec(html)?.[1] || ''; - const secretKey = /Secret key:<\/th>\s*([^<]+)<\/code>/i.exec(html)?.[1] || ''; - return clientId ? { clientId, secretKey } : null; + const timeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs, YUBICO_API_KEY_REQUEST_TIMEOUT_MS); + try { + const response = await withRequestTimeout(timeoutMs, (signal) => + fetch(YUBICO_GET_API_KEY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal, + }) + ); + if (!response.ok) return null; + + const html = await response.text(); + const clientId = /Client ID:<\/th>\s*(\d+)<\/b>/i.exec(html)?.[1] || ''; + const secretKey = /Secret key:<\/th>\s*([^<]+)<\/code>/i.exec(html)?.[1] || ''; + return clientId ? { clientId, secretKey } : null; + } catch (error) { + // 本函数的失败通道就是 `null`(调用方据此回 400「无法初始化 Yubico 校验凭据」)。 + // 不往外抛:抛出会让管理员看到平台兜底的通用 500,而不是那条可操作的提示。 + // 日志只记主机名与原因 —— 请求体里含一次性口令。 + console.error( + isRequestTimeoutError(error) + ? `Yubico getapikey request timed out after ${timeoutMs} ms` + : 'Yubico getapikey request failed', + { host: safeHostname(YUBICO_GET_API_KEY_URL), reason: error instanceof Error ? error.message : String(error) } + ); + return null; + } } export async function verifyYubicoOtp( env: Env, otpInput: string, - credentials: YubicoApiCredentials | null + credentials: YubicoApiCredentials | null, + options: YubicoRequestOptions = {} ): Promise { const otp = normalizeYubiKeyOtp(otpInput); if (!isYubiKeyOtp(otp)) return false; @@ -158,9 +211,12 @@ export async function verifyYubicoOtp( return false; } + const timeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs, YUBICO_VALIDATION_REQUEST_TIMEOUT_MS); for (const baseUrl of validationUrls(env)) { try { - const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' }); + const response = await withRequestTimeout(timeoutMs, (signal) => + fetch(`${baseUrl}?${params.toString()}`, { method: 'GET', signal }) + ); if (!response.ok) continue; const parsed = parseYubicoResponse(await response.text()); if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; @@ -171,7 +227,13 @@ export async function verifyYubicoOtp( } if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue; return true; - } catch { + } catch (error) { + // 超时与网络错误都落在这里:**保持 fail-closed**(继续试下一个校验地址,最终返回 false), + // 并把原因记进日志 —— 只记主机名,URL 的查询串里含一次性口令。 + console.error('Yubico validation request failed', { + host: safeHostname(baseUrl), + timedOut: isRequestTimeoutError(error), + }); continue; } } diff --git a/tsconfig.json b/tsconfig.json index d231ee31c..1fc872608 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,15 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "noUnusedLocals": false, + // 死声明(声明了 / 导入了却从未读取)交给编译器拦,不再依赖 CodeQL: + // 实测 CodeQL 会报**过时位置**且会漏 —— 它报 vault-page-helpers.tsx 的 useMemo, + // 而那个文件里真正还留着的死声明是 VaultDraftField(`tsc --noUnusedLocals` 才发现)。 + // 打开前已清完存量,整树零报错;本地与 CI(npm run verify 的 typecheck 阶段)同时生效。 + "noUnusedLocals": true, + // 刻意保持关闭:handler 签名 / 接口实现里“用不到的形参”是有意写法, + // 实测打开会多出 7 处报错(account-passkeys 的 request、ciphers 的 options、 + // folders 的 request、router-public 的 env、api/vault 的 authedFetch 等), + // 全部属于“按签名接住参数但不用”的正常代码,不应该被当成错误。 "noUnusedParameters": false }, "include": ["src/**/*", "shared/**/*"], diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 000000000..ae8c2fc46 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,22 @@ +{ + // 类型检查 scripts/** 下的测试脚本。 + // + // 为什么不并进根 tsconfig.json:scripts 会 import '../webapp/src/lib/...', + // 一旦把 scripts 加进根配置,webapp/** 会被一并拉进来,而根配置没有 DOM lib + // → 约 110 个与 scripts 无关的报错。 + // + // 为什么不在这里加 DOM lib:@cloudflare/workers-types 与 DOM 在 + // CacheStorage / BodyInit / BufferSource 等全局声明上不兼容,加 DOM 会让 + // src/** 自身产生 29 个真实报错(实测)。 + // + // exclude 说明: + // · web-crypto-availability.test.ts 会 import webapp 代码,需要 DOM lib + // · scripts/webapp/** 同理(§3.5 的前端纯逻辑测试) + // 两者都无法纳入本配置(无 DOM),改由 tsconfig.webapp-tests.json 检查。 + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "node"] + }, + "include": ["src/**/*", "shared/**/*", "scripts/**/*"], + "exclude": ["node_modules", "scripts/web-crypto-availability.test.ts", "scripts/webapp/**"] +} diff --git a/tsconfig.webapp-tests.json b/tsconfig.webapp-tests.json new file mode 100644 index 000000000..22cf5e039 --- /dev/null +++ b/tsconfig.webapp-tests.json @@ -0,0 +1,23 @@ +{ + // 类型检查「测 webapp 纯逻辑」的测试脚本(§3.5 方案 A)。 + // + // 为什么需要**第四份**配置:项目里已有三份,各自解决一个互斥问题: + // · tsconfig.json —— src/**(Cloudflare Workers),无 DOM + // · webapp/tsconfig.json —— webapp/**,有 DOM,但会被 src/** 拉进来 + // · tsconfig.scripts.json —— scripts/** + src/**,无 DOM + // + // 而本目录的测试同时需要「DOM lib」和「不包含 src/**」: + // · 有 DOM:webapp 代码用到 DOM 类型 + // · 不含 src/**:@cloudflare/workers-types 与 DOM 在 CacheStorage / BodyInit / + // BufferSource 等全局声明上互斥,混在一起会产生 29 个真实报错(实测) + // + // 所以这里 extends webapp 的配置(继承 DOM lib 与 paths),并把 include 收窄成 + // webapp + shared + scripts/webapp。 + "extends": "./webapp/tsconfig.json", + "compilerOptions": { + "noEmit": true, + // webapp 配置里是 vite/client;补上 node 以便使用 node:test / node:assert + "types": ["node", "vite/client"] + }, + "include": ["webapp/src/**/*", "shared/**/*", "scripts/webapp/**/*"] +} diff --git a/webapp/index.html b/webapp/index.html index 14cd169d3..f59e34abf 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -7,6 +7,7 @@ props.onInput((e.currentTarget as HTMLInputElement).value)} @@ -76,7 +77,7 @@ function PasswordField(props: { autoComplete={props.autoComplete} placeholder={props.placeholder} /> - diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 650a5a088..4cddc4d0e 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -22,12 +22,13 @@ import { getRemoteBrowserCacheKey, getVisibleDestinations, invalidateRemoteBrowserCacheForDestination, + isBackupDestinationConfigured, isReplaceRequiredError, loadPersistedRemoteBrowserState, persistRemoteBrowserState, } from '@/lib/backup-center'; import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress'; -import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations'; +import { RECOMMENDED_PROVIDERS } from '@/lib/backup-recommendations'; import { t } from '@/lib/i18n'; import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail'; import { BackupDestinationSidebar } from './backup-center/BackupDestinationSidebar'; @@ -316,6 +317,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { useEffect(() => { if (!savedSelectedDestination) return; + // 「尚未配置」不等于「错误」。 + // 新建 / 迁移时会自动生成一个 baseUrl 为空的目标(见 shared/backup-schema.ts 的 + // createDefaultBackupSettings)。若进入页面就自动列举远端目录,服务端会因 + // 「WebDAV server URL is required」返回 409,前端随即把它当错误弹出, + // 用户每次进页都会看到「请填写 WebDAV 服务地址。」。 + // 因此这里对未配置的目标直接跳过;用户主动点「刷新」或保存后触发的加载不受影响, + // 仍会执行并如实报出真实错误。 + if (!isBackupDestinationConfigured(savedSelectedDestination)) return; const destinationId = savedSelectedDestination.id; const path = remoteBrowserPathByDestination[destinationId] || ''; const cacheKey = getRemoteBrowserCacheKey(destinationId, path); diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 6f5f6e825..ee5949939 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -206,15 +206,12 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { }} > {props.variant === 'warning' ? ( - <> -