Skip to content

fix: 远端备份超时可读化、错误可见性与管理员不变量(15 项修复) - #383

Closed
cakkl wants to merge 59 commits into
shuaiplus:mainfrom
cakkl:main
Closed

cakkl wants to merge 59 commits into
shuaiplus:mainfrom
cakkl:main

Conversation

@cakkl

@cakkl cakkl commented Sep 18, 2026

Copy link
Copy Markdown

概述

这批修复集中在三类静默失败:用户看不到失败原因、系统状态悄悄错掉、以及同一事实在前后端各维护一份。

共 15 个提交,npm run verify 全绿(285 个测试,其中新增 5 个测试文件)。


一、备份可靠性

  1. 远端请求补分档超时。 原先 8 处外发 fetch 都没有超时,对端「连上但不回包」时请求永不 settle,最后由平台兜底返回 internal error; reference = …,管理员无法自助。现在控制类 5 s、列目录 10 s、传输类按体积估算(下限 30 s、上限 10 min,按 256 KB/s 保守带宽),并且包整段操作(含读 body —— fetch 收到响应头就 resolve,卡住的常是 await response.text())。超时映射成不可重试的 4xx:前端对 429/5xx 会自动重试 3 次,回 500 会把一次超时放大成三倍等待。

  2. 失败原因不再被重试循环吃掉。 每次尝试一开始就清空 lastErrorMessage,而失败不更新 lastSuccessAt ⇒ 计划任务在容差窗口内立刻重试 ⇒ 错误在「清空 → 30 s 后写回 → 立刻又清空」的循环里几乎永远看不到。现在清空只发生在成功分支。

  3. 计划任务因租约被跳过时留下痕迹。 DO 租约被占用时返回 409,handler 侧原是直接 return ⇒ 跳过无任何留痕(不报错、日志里全 200),排查时极易误判成「超时没生效」。现在会 console.warn 一条并写一条 backup.scheduled.skipped(system/warn)审计事件。

二、错误可见性

  1. 前端 API 不再吞掉服务端文案(41 处)。 if (!X.ok) throw new Error('<硬编码英文>') 把服务端的 error_description / error 整个丢掉 ⇒ 主密码输错、命中限流(429)、权限(403)时用户只看到一句笼统的失败。全仓 110 个失败分支守卫里 41 处如此(其中 9 处写成 t('txt_…'),文案本地化了但同样丢掉服务端说法)。一律改为 parseErrorMessage(resp, t('txt_…')),无需新增 i18n 键。

  2. 管理端端点同样处理:邀请码的 4 个端点 + 用户删除/封禁。

  3. 后端源码护栏。 scripts/error-message-guard.test.ts 静态扫描响应助手(errorResponse / unsupportedResponse / identityErrorResponse / badRequest)的第一个实参:可静态证明的四种形状免登记,其余必须登记并写理由(34 条 / 42 处),漏登记或登记项失效都会红。

三、管理员不变量

  1. 兜底提权口径与 isAdmin() 对齐。 ensureAdminUserExists() 原先只查 role='admin',而 isAdmin() 要求 role 且 status='active' ⇒ 唯一管理员被 ban 后兜底以为"已有管理员"再也不修;且会把 banned 用户当提权对象(提权后仍不是可用管理员)。

  2. 删除 / 封禁前用库里的最新状态复核操作者。 actorUser 来自 isolate 级缓存(TTL 15 s,只清当前 isolate)⇒ 被 ban 的人最多还能用旧快照管理 15 s。另加 guardLastActiveAdmin() 把「至少保留一个可用管理员」写成显式断言。

四、备份中心界面

  1. 展示「上次失败」。 后端一直在下发 runtime,缺的只是前端没人用。失败原因走 translateServerError()(命中映射就本地化,未命中保留英文原文 —— 刻意不回落到通用文案);只有最后一次尝试是失败的才显示(成功晚于失败不显示),侧栏显示时间、详情页显示时间 + 原因。

五、其它

  1. Yubico 两处外发请求补超时(5 s),并保持 fail-closed(超时即拒绝登录 / 不启用),日志只记主机名(一次性口令不进日志)。
  2. 暗色下图标按钮禁用态与可用态同色(.input-icon-btn:disabled 被 dark.css 的 (0,3,0) 规则反压)。
  3. scripts/ensure-kv.cjs 加固:不再模糊匹配命名空间(列出候选要求显式选择)、写回后校验、段内定位、可测试化 + 10 个测试。
  4. 远端超时消息形状改为单一来源(shared/backup-timeout-message.ts):原先后端判定与前端本地化映射各写一份正则,任一侧改措辞另一侧会静默失配。

验证

  • npm run verify(4 个 tsconfig 类型检查 + i18n 校验 + 全部测试 + 构建):exit 0,285 个测试全绿
  • 关键修复都做了 A/B 反向验证(回退修复 ⇒ 对应用例变红),例如:去掉读 body 的超时包装 ⇒ 用例 3 秒内快速失败;回退兜底口径 / 刷新操作者 ⇒ 各自只有对应用例红。
  • 界面部分用演示站 + 浏览器实测(浅色 / 暗色的计算色、间距量测、无失败目标不渲染空框)。

新增与收敛:

- 新增 `.github/workflows/verify.yml`:在 push 到 main 与所有 PR 上执行
  `npm run verify`(类型检查 + i18n 校验 + 测试 + 构建)。此前仓库的测试脚本
  在 CI 中**没有任何 workflow 执行** —— 测试写了却无人自动运行,回归不会被拦截。
- Node 版本收敛到单一来源 `.nvmrc`(24.18.0):`verify.yml`、
  `sync-global-domains.yml` 与 Cloudflare Workers Builds 的构建镜像三方一致,
  与本地不再分叉,且无需额外下载。
- `scripts/` 纳入类型检查(新增 `tsconfig.scripts.json`)。
- `security-audit-backup-endpoint` 从"脚本"改为带断言的测试文件并纳入 `npm test`:
  它此前在 CI、npm scripts 与文档中均无引用,属死资产。
- 删除 `security-extra.yml` 中已失效的 `pnpm audit` 步骤:项目使用 npm
  (仅有 package-lock.json),该步骤一直被守卫跳过。

依赖漏洞(实质安全修复):

- `overrides` 原本是**精确钉版**(`"nanoid": "3.3.18"`、`"undici": "8.9.0"`、
  `"ws": "8.21.0"`、`"sharp": "0.35.0"`)。精确钉版会让传递依赖永远停在安全通告点,
  只能靠人工发现并升级 —— 而 `sharp 0.35.0` 正是被通报的那个版本。
- 改为 caret 区间(`^3.3.18` / `^8.9.0` / `^8.21.0`),把 `sharp` 顶到 `^0.35.4`,
  并新增 `browserslist: ^4.28.9`;配合 lockfile 刷新,另外两个包
  (`baseline-browser-mapping`、`postcss-selector-parser`)也一并升到修复版本之上。
- 效果(实测对照):在未修复的提交上 `osv` job ❌ —— **4 个包 / 7 个漏洞**
  (5 High + 2 Medium,含 CVSS 8.9 的 `sharp`);本提交 ✅。
  这 4 个包都是 vite / tailwind / autoprefixer 的**构建期**传递依赖
  (OSV 标注 `(dev)`),不会进入 Workers 运行时,因此对已部署实例没有可利用面。
- 同一提交还新增了顶层 `allowScripts` 字段
  (`esbuild@0.28.1`、`workerd@1.20260625.1`)。

workflow 加固:

- 不需要 git push 的 checkout 补 `persist-credentials: false`,与
  `codeql.yml` / `security-extra.yml` 已有的做法保持一致。注意
  `sync-global-domains.yml` **有意不补** —— 它用 `create-pull-request` 推分支。
- 把 `codeql.yml` 与 `sync-global-domains.yml` 的权限从工作流级下移到 job 级:
  工作流级声明会让所有 job 共享过宽权限。zizmor 的 `excessive-permissions`
  在本地报出 3 条达标发现(2 high + 1 medium:工作流级 `contents: write`、
  `pull-requests: write`、`security-events: write`),下移后为
  `No findings to report`。
- **性质说明(已用对照实验更正)**:本仓库此前把 `security-extra.yml` 与
  `codeql.yml` 设为**手动禁用**,因此它们从未运行过 —— "修复前 main 上
  zizmor 是红的"这个说法不成立。启用后专门把**未修复的提交**推到临时分支跑了一次
  对照:`zizmor` job **仍然是绿的**,即 `zizmorcore/zizmor-action` 不会把达标发现
  变成 job 失败。所以这一项属于**权限最小化的加固**,对 CI 结论没有影响,
  不是"修好了一个红 job"。同一次对照运行也顺带证实了上面的 OSV 结论。
- 用途隔离显式化。各类专用令牌与访问令牌共用同一个 `JWT_SECRET`:
  - `AuthService.verifyAccessToken` 拒绝携带
    `cipherId`/`attachmentId`/`sendId`/`fileId` 的令牌;
  - `verifyFileDownloadToken` 拒绝携带 `sstamp`/`sub` 的令牌;
  - `verifyAttachmentUploadToken` 同样拒绝携带 `sstamp` 的令牌。
  此前隔离只依赖"调用方随后还会比对字段"这一隐式约定,现在升级为显式契约。
  顺带修正两处 `verifyAttachmentUploadToken` 与 `verifyFileDownloadToken`
  判定条件不对称的问题(一处只判 `sstamp`,未判 `sub`)。
- CSP 补 `worker-src 'self' blob:`(`src/utils/response.ts` 与 `webapp/index.html`
  两处需一致),否则保险库解密 worker 会被 CSP 拦掉。
`acquireJob` / `touchJob` / `releaseJob` 原本是 `await get()` 之后再
`await put()` / `await delete()`。两个并发请求可能都读到"当前无租约",
于是双双拿到 token,两个备份同时运行 —— 而这个 DO 的唯一职责就是阻止这件事。

改为在 `state.storage.transaction()` 内比对后写入(与 `notifications-hub.ts`
中 ws-token 的单次消费同构)。`releaseJob` 尤其需要事务:
"A 读到 token=A → B 覆盖为 token=B → A 依据旧快照判断通过并 delete() 掉 B 的租约",
会让并发保护静默失效。
- `backup-config.ts`:`64:ff9b::/96`(RFC 6052 知名前缀)与 `64:ff9b:1::/48`
  (RFC 8215 本地前缀)都会把 IPv6 地址映射回 IPv4。此前只判前 16 位,
  使内网地址可以绕过 SSRF 黑名单。现在前者解出内嵌 IPv4 后按 IPv4 规则判定,
  后者因前缀长度可变、无法可靠解出,整段拒绝。
- `storage-cipher-repo.ts`:`ON CONFLICT(id) DO UPDATE ... WHERE user_id=excluded.user_id`
  用于阻止跨用户覆盖,但当该 id 已被他人占用时该语句**既不更新也不报错**(静默 no-op)。
  现在检查 `meta.changes`,避免把"写入被拒绝"当成保存成功。
- `handlers/ciphers.ts`:为"带附件迁移元数据时跳过 stale 检查"这一豁免补注释,
  说明豁免范围仅限于用户覆盖自己的数据,避免后来者无意扩大。
- `migrations/0001_init.sql` 补上**仅存在于运行时建表语句**中的 6 个 YubiKey 列
  (`yubikey_key1..5`、`yubikey_nfc`)与 `rate_limit_buckets` 表及其索引。
  此前全新建库(走 migrations)与运行时 `ensureStorageSchema()` 的定义不一致。
- 兜底提权(实例中无管理员时,把最早创建的账号重新提升为 admin)此前**不留痕**,
  现在写入 `user.bootstrap.admin_promoted` 审计事件。
  该函数运行在 schema 初始化阶段,只有裸 `D1Database`(用 `StorageService`
  会造成模块循环依赖),因此直接插 `audit_logs`;审计写入失败不得中断初始化。
`POST /api/folders/delete` 是 NodeWarden 自己的私有别名,官方 API 并不存在
(官方提供的是 `DELETE /folders` 与 `DELETE /folders/all`)。整条链路
`route → handler → storage → storage-folder-repo` 无其他调用者,因此一并删除,
前端"删除全部文件夹"入口随之移除,侧栏该按钮位置改为"新建文件夹"。

同时清掉 3 个不再使用的 i18n 键(`txt_delete_all_folders` / `_message` /
`_failed`)× 10 个语言包(`txt_folder_deleted` 等单条删除仍在用,未受影响)。

顺带移除 `VaultSidebar.tsx` 中与侧栏其他入口重复的"密码安全"入口。
新建或迁移实例时,`createDefaultBackupSettings` 会自动生成一个"配置为空"的占位目标
(WebDAV 的 `baseUrl`、S3 的 `endpoint`/`bucket` 均为空)。进入备份中心会自动列举
远端目录,服务端因 `WebDAV server URL is required` 返回 409,前端随即把它当错误弹出 ——
用户**每次进页**都会看到"请填写 WebDAV 服务地址。",而那并不是错误。

新增 `isBackupDestinationConfigured()`,对未配置的目标跳过自动列举;
用户主动点"刷新"或保存后触发的加载仍会执行并如实报出真实错误。
- 暗色对比度:`.send-options`(1.93:1)、`.password-toggle`(2.58:1)等硬编码色值
  改为主题变量,暗色下提升到 11.63:1;`.totp-ring-track` 的 `stroke`、
  `.list-sub` 等零散选择器一并纳入 `dark.css` / 打磨层覆盖。
- 无障碍名称:为密码框、网站地址与匹配方式、TOTP 密钥、全局规则复选框补
  `aria-label`;列表复选框与整页 TOTP 复制按钮的可访问名称带上条目名;
  密码显示切换按钮补 `title`/`aria-label`。
- 布局溢出:`.vault-grid` 三栏最小宽度之和(270+400+400+24 = 1094px)在
  1181–1400px 区间会把网格轨道顶出容器,详情面板被截断、"删除"按钮不可达。
  `responsive.css` 新增 1400px 断点收窄侧栏与两栏最小宽度。
- Send 页"移除密码"按钮改用 `.password-toggle.danger`:原先靠组件里写
  `text-red-600 hover:text-red-700` 表达红色意图,但那两个工具类在加载顺序上
  必然被 `.password-toggle` 的同优先级规则覆盖 —— 意图红色、实际渲染为主题色,
  属从未生效的死代码。
说明三件事:首个注册账号自动获得 `admin` 并标记实例已注册;此后注册需要邀请码、
新账号只得到默认 `user` 角色(因此注册无法在既有实例上取得管理员);以及实例中
不再存在管理员时的兜底提权(按**账号创建时间**而非信任度选取,且管理员账号
本身不受删除保护)。

最后一点值得操作者知道:兜底提权只是防止实例永久失去管理能力,
不能替代多用户实例上的人工管理。
文件夹在密文校验**之前**就已落库,导入被 400 拒绝后这些文件夹会留在库里,
重试时不断累积。改为所有校验通过后再执行写操作。

这类"失败后留下半截状态"对密码管理器尤其危险:用户以为导入失败、
实际库里已经多了一批空文件夹。
查询计划(用 `EXPLAIN QUERY PLAN` 对着真实 schema 逐条核对):

- 7 处清理/查询路径此前全表扫,新增索引覆盖
  `refresh_tokens.expires_at`、`auth_requests.creation_date`、
  `trusted_two_factor_device_tokens.expires_at`、`webauthn_challenges.used_at`、
  `login_attempts_ip.updated_at`、`used_attachment_download_tokens.expires_at`、
  `rate_limit_buckets.expires_at`。其中一条清理语句的索引被 `OR` 排掉,
  改用两次独立 DELETE。
- `migrations/0001_init.sql` 与 `storage-schema.ts` 同步修改(仓库约定两处都要改),
  并提升 `STORAGE_SCHEMA_VERSION` —— 那是让**已有部署**拿到变更的唯一机制。

串行 IO / N+1:

- `GET /api/admin/users`:每个用户查一次 passkey → 一次批量查询 + `Set` 判断。
- `handleUpdateDeviceTrust`:每台设备各查一次 → 一次 `getDevicesByUserId` + Map,
  写入改用批量语句;`handleUntrustDevices` 同理。
- `handleListPendingAuthRequests`:删除一整段死代码 ——
  `getDevice(userId, X)` 的查询条件是 `WHERE device_identifier = X`,
  其返回值的 `deviceIdentifier` 必然等于 `X`,因此 `??` 回退分支不可达。
- 设备批量操作补数量上限(`LIMITS.device.maxBulkIdentifiers`),避免超长
  `IN (...)` 把绑定参数打满。

同时移除 `initializeDatabase` 中对 `api.bitwarden.com` 的调用:
它会在**数据库初始化**路径上向第三方发请求(实测拿到 429),
既是出网行为也是可用性风险,且该凭据同步并非本实例所需。
- 本地导出勾选"包含附件"产出的 zip **不含任何附件字节**,而归档的 manifest
  却声明 `includes.attachments: true`,因此会被本地导入以
  `missing required file` 拒绝 —— 一个自称包含附件、实则无法恢复的备份。
  新增 `BuildBackupArchiveOptions.inlineAttachmentBlobs`:本地导出置 true
  把附件字节内联进 zip,远端备份保持 false(它依赖单独的增量上传,
  内联会把附件在远端重复存一份)。
- 体积预检**基数算错**:原先只把"附件字节数"与 64 MiB 比较,既没扣掉 `db.json`
  (恢复侧按**所有条目解压后总字节**判定上限),也没考虑 `zipSync` 会把内存
  占用翻倍(峰值 ≈ 2 × 总量),因此可以产出"导出成功、但本地导入必然失败"的归档。
  改为 `resolveInlineAttachmentBudgetBytes()`:
  `预算 = min(内存总量上限, 恢复上限) − db.json`,总量上限取 32 MiB
  (峰值 ≈ 64 MiB,Worker 每 isolate 内存上限 128 MB)。预算为负表示
  `db.json` 自身已超恢复侧单条目上限,任何附件都不许内联。
- 超限与"远端形态归档走本地导入"两种情况都给出**可操作**提示而非通用报错
  (新增 2 个 i18n 键 × 10 个语言包,均非英文占位)。带具体字节数的文案无法
  精确查表,前端改用正则匹配并把字节换算成 MB。
`hostFromUri` 先用 `uri.trim()` 判空,却拿**未裁剪**的原串去判断 scheme 与解析:

- `'  https://example.com  '` 返回空串 —— 网站图标永远不显示;
- `'\thttps://a.com\n'` 返回垃圾主机名 `'https'`。

现在先裁剪再解析,并把这两种输入固定进测试。
此前 `scripts/` 下的测试只做"发了哪些 SQL"级别的 mock 断言:证明不了
"该备份的数据完整、能原样还原",也盖不到 handler 的参数校验路径。

基础设施(`scripts/lib/`):

- `d1-sqlite.ts`:用内置 `node:sqlite` 实现 D1Database,跑**真实 SQL**
  (真实 schema、真实影子表与 swap),可把两个库逐行比对;纯 mock 做不到这点。
- `r2-memory.ts`:内存 R2。**必须支持 ReadableStream** —— 早期版本对流主动抛错,
  导致附件上传路径完全盖不到(表现为莫名其妙的 500)。
- `sql-recorder.ts`:区分两个口径 —— `queries` 是 prepared 语句数(用于查查询计划),
  `roundTrips` 是数据库往返数(用于查 N+1);`bind()` 返回新对象,故语句也要套 Proxy。
- `test-harness.ts`:共用夹具,含 `resetProcessScopedStatics()` 与惰性 schema 预热
  (否则第二个库拿不到 schema、首个库的计数器会偏)。
- `cloudflare-workers-stub.mjs` + `register-cloudflare-stub.mjs`:`cloudflare:workers`
  在 Node 下无法解析(`ERR_UNSUPPORTED_ESM_URL_SCHEME`),用模块解析钩子补上。

用例(合计 200 项,全部纳入 `npm test`):

- 备份 round-trip 17、迁移升级 6、查询计划 1、查询往返 10
- handler:ciphers 12、sync 9、identity 18、sends 16、folders 12、
  import 11、attachments 11、backup 7
- 前端纯逻辑 32(新增第四份 tsconfig `tsconfig.webapp-tests.json` 提供 DOM 类型;
  `tsconfig.scripts.json` 排除 `scripts/webapp/**`,否则会被无 DOM 的配置捡走)

这些用例同时锁住 4 处**有意的不对称**(不导出 Send、不导出 `users.api_key`、
脱敏内部 config key、恢复后强制标记为已注册)—— 它们不是 bug 而是设计,
但必须显式记录,否则将来会被误改或误报。

注:其中 4 个测试文件必须以本分支此前的修复为前提(导入写序、清理索引、
N+1 消除、内联导出预算),故测试提交排在最后。
- `CONTRIBUTING.md` 新增「Tests」小节:说明 `npm test` 跑的是**真实 SQL**
  (D1/R2 由进程内实现支撑,无需外部服务与网络),并交代 `scripts/lib/` 各夹具的用途,
  以及两条"已经各自产生过一次假结果"的规则 —— 概率门控的清理路径必须钉住
  `Math.random`(或至少连跑两次),以及不要断言两个时间戳不同。
- `Recommended Checks` 改为以 `npm run verify` 为主(与 CI 跑的是同一条命令),
  再按场景给更窄的命令。此前这里只有 `tsc` / `build` / `i18n:validate` 三条,
  **一个字都没提测试**,而 CI 已经在跑它。
- PR 模板:`Cross-File Checklist` 补上 `STORAGE_SCHEMA_VERSION`(CONTRIBUTING 的
  Database Changes 一直这么要求,模板却漏了);`Checks` 改用 `npm run verify`,
  并新增"测试需连跑两次"的勾选项。
`gitleaks` 的 `generic-api-key` 是熵值类规则:测试夹具里的
`test-jwt-secret-at-least-32-characters-long` 因变量名含 "secret" 且熵值 3.602
高于阈值而被判为凭据 —— 定义处(`scripts/lib/test-harness.ts:62`)加 3 个把同一
字面量写死在本地(`import-handler` / `folders-handler` / `backup-handler`)的
测试文件,共 4 处,全部出自提交 8efe2a6。

它不是凭据,但不能靠"给测试目录加白名单"来解决 —— 那会连带放过真的密钥。
因此新增 `.gitleaks.toml`,**只按那一个字面值**加白名单。

为什么不能用行内 `gitleaks:allow` 或改写那几行代码:gitleaks 的扫描单位是
「**本次 push 范围内新增的行**」(实测它执行 `git log -p -U0 --no-merges
--first-parent <range>`)。只要引入这些行的提交仍在范围内 —— 例如合并进 main
的那次 push —— 行内注释无法覆盖更早的提交,只有配置级白名单能稳定生效。

验证(本地用 CI 自动安装的同版本 gitleaks 8.24.3,同一提交范围):

- 加配置前:4 条发现,文件 / 行号 / 规则 / 提交与 CI 输出逐条一致,退出码 2;
- 加配置后:`no leaks found`,退出码 0;
- 精度探针(同一文件内放 4 个取值):白名单值不报;同一文件、同一条规则换个
  高熵取值仍被 `generic-api-key` 报出;`ghp_...` 仍被 `github-pat` 报出
  —— 即默认规则未被关闭,白名单也没有放宽到目录级。
semgrep 的 `p/default` 在本 PR 改动的代码里报出 2 条告警,会让 PR 上的
`Semgrep OSS`(Code Scanning)检查变红。逐条核对后确认都是误报,但**抑制注释行不通**,
最终改用改代码的方式:

1. `scripts/query-plan.test.ts` 的 `detect-non-literal-regexp` —— 原写法是
   `USER_SCOPED_TABLES.find((name) => new RegExp(`FROM ${name}\b`)...)`。现在改为用
   **字面量**正则从 SQL 里取出 `FROM` 后的表名、再与白名单比对:既不再触发该规则,
   也比拼接正则更精确。
2. `scripts/security-audit-backup-endpoint.test.ts` 的 `detect-insecure-websocket` ——
   该处是非加密 WebSocket 地址作为**负向用例**(断言它必须被拒绝,即验证的正是安全性)。
   该规则是**文本级**规则,连注释里的字面量都会命中,且实测 `nosemgrep` 对它无效
   (放在命中行上也不生效),因此改为在运行时拼接出该地址、让字面量不出现;
   注释里写明了原因,并提醒不要"顺手"改回字面量。

验证(本地按 **CI 的完整口径**跑 `semgrep scan --config p/default --sarif .`,整仓):

- 改前:14 条结果,其中测试文件 2 条(与 CI 注解逐条吻合,文件与行号都对上);
- 改后:12 条结果,**测试文件 0 条**;
- `npm run verify` 全绿(200 项测试),其中 `test:query-plan` 与
  `test:security-audit-backup-endpoint` 单跑亦通过。

**过程中踩到的两个坑(值得记住)**:

- 只扫被改的那 2 个文件时得到过"0 条"的**假结论**:同一镜像、同一规则集,
  "定向扫这 2 个文件"与"扫整个仓库"结果不同(0 vs 2),而 CI 用的是整仓。
  → **本地复现必须连同调用口径一起复现,不能只对齐配置。**
- 第一版把 `nosemgrep` 注释放在命中行上一行、并**在注释里写出了那个 WebSocket 字面量**,
  结果从 2 条变成 3 条:文本级规则连注释也扫。
fix: 安全与备份正确性修复、性能优化、测试体系补齐
两处都只是「消掉长期挂着的告警」,不涉及任何运行时代码。

1. `.gitleaks.toml`:把 `webapp/src/lib/demo.ts` 演示数据里的假私钥取值
   `DEMO-PRIVATE-KEY` 加入白名单。
   - 该条目 `notes` 自己就写着 `Fake SSH key material for UI preview.`,所谓私钥
     主体只有这一个字面量;`private-key` 规则只认 `-----BEGIN/END ... PRIVATE
     KEY-----` 装甲头、不校验中间是否为合法 base64,因此必然命中。
   - 该告警自 2026-06-23 起一直开着(实例指向 `70dc9a7` 第 378 行),而 gitleaks
     只扫 push 范围内新增的行,所以它不会自愈:白名单负责「以后不再报」,
     已产生的那条另外手动 dismiss(reason = false positive)。

2. `.github/dependabot.yml`:两个生态各加 `cooldown: default-days: 7`。
   - 一次消掉 zizmor 的 `dependabot-cooldown` ×2 与 semgrep 的
     `dependabot-missing-cooldown` ×2(同一根因)。
   - 实际效果主要在 npm 生态(`open-pull-requests-limit: 5`):新版本发布后等 7 天
     再提 PR;`github-actions` 生态上限是 0,加它只为两个生态配置一致。

验证(均按 CI 口径在本地复跑):

- gitleaks:以引入提交为范围扫 `70dc9a7^..70dc9a7`,无白名单时报出
  `private-key @ webapp/src/lib/demo.ts:378`(Entropy 5.127221),与线上告警
  逐字段吻合;加白名单后 0 条。
- 精度对照:文件模式下另放一个本地临时生成的真 ed25519 私钥,仍被抓到,
  说明白名单按值匹配、没有把别的凭据一起放过。
- semgrep:整仓 `--config p/default` 结果 12 → 10,`dependabot-missing-cooldown`
  归零,测试文件内 0 条。
- `npm run verify`:exit 0,200 项全过。
需求:Cloudflare 构建并部署新版本后,新版本「启动时」在项目自己的日志中心留下一条
记录,写明版本更新。

实现要点:

1. 新增 `src/services/app-version-log.ts`
   - 判定「是否算新的一次部署」认两种信号:① `APP_VERSION` 变了(发版);
     ② 版本号没变、只是重新构建部署了 —— 靠新增的版本元数据绑定
     `CF_VERSION_METADATA`(`[version_metadata]`,见 wrangler.toml)的 id 识别。
   - 「上次见到的版本」持久化在 D1 的 `config` 表(键 `app.version.last`):它不受
     日志中心的保留策略影响,所以即使日志被清理或全清,"下次变化"仍能识别。
   - 写入审计事件 `system.app.version.started`(category=system、level=info、
     actor 为空),于是它自然出现在 Web 端「日志中心」里。
   - 元数据:`version` / `previousVersion` / `deploymentId` / `deployedAt`。

2. 并发安全:`storage-config-repo.ts` 新增 `claimConfigValue()` ——
   `INSERT ... ON CONFLICT DO UPDATE ... WHERE config.value <> excluded.value`
   把"判断是否变化"与"写入"合并成一条 SQL,只有拿到 `meta.changes === 1` 的调用才写日志。
   否则部署瞬间多个 isolate 同时冷启动,会在日志中心留下重复条目。

3. 两个挂点共用同一个「每 isolate 一次」入口(`src/index.ts`):
   - 首次请求路径(放进 `ctx.waitUntil`,不占关键路径):部署后立刻有访客就能立刻看到;
   - `scheduled`(cron 每 5 分钟):零流量也能最迟 5 分钟内被记录。
   失败只 `console.error`、绝不抛出;另有一行 `console.info` 双写,便于 wrangler tail
   实时查看。

4. 配套改动
   - `audit-events.ts`:`ALLOWED_METADATA_KEYS` 补 4 个版本字段(元数据是白名单制,
     不登记会被静默丢弃)。
   - 10 个语言包:新增动作标签 `txt_log_action_system_app_version_started` 与 4 个
     元数据标签(`deployed_at` / `deployment_id` / `previous_version` / `version`)。
   - `demo.ts`:加一条版本事件示例;并把**原有 16 条演示日志的时间戳也改成相对当前
     时间生成**(新增 `demoLogAt()` 辅助函数)。原因是日志中心默认只查「最近 7 天」,
     而这些日期写死在 2026 年 6/7 月 —— 默认视图下整页是空的,示例等于失效;现在按
     原有先后顺序压缩进最近 7 天内,默认打开就有内容。
   - `wrangler.toml` 与 `wrangler.kv.toml`:两份配置都加 `[version_metadata]` 绑定,
     否则 KV 部署变体会缺这个绑定。

验证:

- 新增 `scripts/app-version-log.test.ts`(7 项):首次写入并落 config、同版本不再写、
  版本变化写出旧新对照、仅重新部署也写、**并发只写一条**、同一 isolate 重复调用被短路、
  数据库不可用时只记错误不抛出。已接入 `npm test` 链路。
- `npm run verify`:exit 0,207 项全过(原 200 + 新增 7)。
- 运行时验证(演示站 + 浏览器实测,中文界面):日志中心列表出现「应用版本启动」,
  分类「系统」、级别「信息」、操作者「-」;元数据四项完整显示为中文标签
  (版本 / 上一版本 / 部署 ID / 部署时间)—— 证明白名单与 10 个语言包都生效。
- 本地安全扫描:gitleaks 扫 20 个改动文件 0 命中;semgrep 整仓仍为 10 条,无新增发现。
问题:演示站日志中心里有 15 条标题显示成 "Auth / Login / Failed"、
"Admin / User / Banned" 这种斜杠串,与真实程序不一致。

原因:日志中心对「语言包里没有对应键的动作名」会 humanize 回退
(LogCenterPage.tsx 的 translatedOrHumanized → humanizeIdentifier)。而演示数据里的
动作名是手写的"看起来合理"的名字,真实代码根本不写这些(真实是
`auth.login.failed.bad_password`、`admin.user.status`、`device.delete`、
`admin.backup.remote.scheduled` 等)。17 条里当时只有 2 条能查到译文键。

改法:**逐条照着服务端真实写入的内容重写**(依据 src/handlers/*.ts 里
writeAuditEvent / writeAuditLog 的实际调用),而不是给演示专用名字补词条。

- action:全部换成真实动作名,包括真实 reason 取值
  `auth.refresh.failed.token_not_found_or_expired`(原来是自造的 token_expired)。
- category / level / targetType:按真实调用设置。几处原本与真实不符的一并纠正:
  `device.delete` 由 warn 改 security;`admin.backup.remote.scheduled` 与
  `admin.backup.import` 由 data/warn 改 system/info;`admin.user.status` 按助手规则
  取 security/security(其余 admin.* 是 system/info)、targetType = user。
- 操作者:`auth.refresh.failed.*` 的真实事件**不写操作者**(刷新失败时还没有通过
  认证的用户),故 actor 置空、界面显示 "—";`user.register.invite` 的操作者是注册人。
- metadata:只用 audit-events.ts 白名单里的键,并用真实字段名
  (deviceIdentifier / deviceType / webSession / grantType / updated / deleted /
  requested / removed / users / ciphers / attachments / ...)。
- 请求元数据(method/path/ip/userAgent)**只在真实调用真的传了 request 时才写**,
  逐处核实后:
  ① `admin.backup.remote.scheduled` 由 Durable Object 的闹钟触发、没有 Request,
     写入点也没有 request 可传 ⇒ **不带**这 4 个字段;同一动作的 manual 变体则由调用方
     把 auditRequestMetadata(request) 作为 auditMetadata 传进 DO ⇒ 带。
     (全仓库 `auditRequestMetadata(request)` 只出现在 backup.ts 的手动备份与远端恢复两处,
     所以"哪些事件带请求字段"可以穷举核对。)
  ② `admin.backup.import`(POST /api/admin/backup/remote/restore)、
     `admin.backup.export`(POST /api/admin/backup/export)、
     `admin.backup.settings.update`(PUT /api/admin/backup/settings)三处的真实调用
     **都带了** request ⇒ 演示数据也补上。这三处最初漏了:自动提取调用上下文时被
     760 字符截断,恰好把结尾的 `, request);` 切掉了。
  ③ `user.register.invite` 里真实代码塞的 inviteCode 不在白名单内、会被丢弃,所以
     演示数据也不写它(写了反而比真实程序"多"一个字段)。
- 路径也用真实路由:/identity/connect/token、/api/accounts/password、
  /api/accounts/totp、/api/devices/update-trust、/api/devices/untrust、
  /api/admin/logs/settings、/api/admin/invites。
- 补 10 个语言包的 `txt_log_target_type_system`:版本事件的 targetType 是 system,
  缺键时目标列会回退成英文 "System"(现在中文界面显示「系统」)。

验证:

- 新增一致性核验脚本式检查:17 条全部满足「动作有译文键 + targetType 有标签 +
  元数据键在白名单内」。
- `npm run verify`:exit 0,207 项全过(含 i18n 键对齐检查)。
- 浏览器实测(演示站中文界面):17 条标题**全部中文、斜杠标题 0 条**
  (登录成功 / 密码错误登录失败 / 开启两步验证 / 刷新登录失败:令牌不存在或已过期 /
  修改主密码 / 修改用户状态 / 通过邀请注册 / 永久信任设备 / 删除设备 /
  批量撤销设备信任 / 计划远程备份成功 / 导入备份 / 导出备份 / 更新日志保留设置 /
  创建邀请 / 更新备份设置 / 应用版本启动);
  详情面板的分类、操作者、目标、元数据标签也全部中文。
导出侧预检(A):
- 预检放进 buildBackupArchive 内部 —— 那是所有导出路径的唯一汇聚点,
  因此一处即可覆盖"远端/定时备份"与"本地导出但不勾选附件"这两条过去
  完全没有检查的路径(它们都会产出恢复侧必然拒收的归档)。
- 报错带具体字节数与上限,并按既有约定登记 10 个语言包与 webapp 的
  正则映射(txt_backup_error_archive_db_payload_too_large)。
- Options.restorableDbPayloadLimitBytes 仅供测试注入更小的上限。

恢复侧内存与健壮性(B'):
- 写入按 200 条/批提交,不再整表一个 db.batch(峰值与库大小解耦)。
- importPreparedBackupRows 去掉整表深拷贝,改为就地补默认值。
- parseBackupArchive 解码 db.json 后立即释放其解压字节(条目名保留,
  Object.keys(files) 语义不变)。

实测(wrangler dev + 合成归档,脚本见测试记忆里的 recipe):
- db.json 4/8/16/24/31 MiB 恢复全部 200(31 MiB 用时 1.8 s)
  —— 绑定约束是恢复侧的 32 MiB 单条目上限,不是内存。
- 同一组恢复序列的 workerd 峰值 RSS:983 MiB → 607 MiB。
- 导出 15,545 条(zip 30.26 MiB)→ 立刻导回,闭环 200;
  16,945 条时导出被明确拒绝(改动前会静默产出无法恢复的归档)。

新增 backup-roundtrip 用例 10–14(上限一致性、导出拒绝分支、i18n 登记、
解析后释放字节、分批提交)。
CodeQL js/polynomial-redos ×5(high):把 4 个文件里 8 处"尾部量词"正则
(/^\*+\./、/^\.+/、/\.+$/、/^\/+|\/+$/g、/\/+$/、/=+$/g)改成等价的显式循环。
它们本身是线性扫描,但审计噪音不值得长期挂着;改这类规范化代码最大的风险是
悄悄改变结果,因此新增 scripts/regex-hardening.test.ts:
- 特征化对比:循环实现与被替换的正则链在含 30 万字符恶意长串的语料上逐条一致
- 端到端:normalizeEquivalentDomain 对超长输入仍是线性(毫秒级)且结果不变
- 源码护栏:这 4 个文件不得再出现尾部量词正则(防回退;扫前先剥注释)

CodeQL 卫生类:删除 9 处死代码(未使用的 import、局部变量/函数、恒真的
条件、无用的赋值)。其中 App.tsx 的 pendingTotpMode 只保留 setter ——
写入的值当前无任何读取点,删掉会连带 7 处调用,故选最小改动并加注释。

CodeQL js/clear-text-storage-of-sensitive-data ×5:核实后属"设计如此 +
误报",但把结论落成断言:新增 scripts/webapp/offline-storage-secrets.test.ts
(4 个用例)——用内存 localStorage 桩断言落盘内容里不得出现 access/refresh
token、明文/包裹密钥、master password hash 的标记。顺带把 offline-auth 的
profile 快照从"展开原对象"改成白名单(与 api/auth.ts 的 stripProfileSecrets
一致),未知字段不再被顺手写进 localStorage。

Scorecard Security-Policy:SECURITY.md 补上可直接点开的私密报告链接。

测试接入:新增 test:regex-hardening;test:webapp-lib 补 --tsconfig
tsconfig.webapp-tests.json(否则 @/* 别名在 Node 下解析不了)。
npm run verify:exit 0 / 219 项(新增 7 项)。

线上告警:44 条 → 23 条(关闭 21 条误报/设计如此);其余 23 条已由本提交或
未推送的 fb293fc 修好,推送后随下次扫描自动转为 fixed。
依赖(npm update,均为 minor/patch;声明区间保持不变,实装版本由 lockfile 记录):
wrangler 4.105.0 → 4.131.1、@cloudflare/workers-types 4.20260630.1 → 5.20260911.1、
vite 8.1.3 → 8.3.0、@types/node 26.0.1 → 26.5.1、preact 10.29.8、
@tanstack/react-query 5.102.8、@zip.js/zip.js 2.14.1、lucide-preact 1.45.0、
wouter 3.11.0、tsx 4.23.13,以及 postcss / autoprefixer / opencc-js /
@noble/hashes / @preact/preset-vite 的小版本。

**意外发现**:wrangler 4.131.1 的 peer 依赖要求 @cloudflare/workers-types@^5
⇒ 原本划到「档 2」的 workers-types 大版本被 wrangler 绑定升级,无法只升 wrangler。
实测影响为零:四份 tsconfig 类型检查 0 错误、219 项测试全绿,
且构建产物反而更小(index 544.21 → 533.60 kB,gzip 184.53 → 177.66 kB)。

Cloudflare 侧:
- compatibility_date 2024-01-01 → 2026-09-08(wrangler.toml 与 wrangler.kv.toml 同步改,
  两份配置漏改一份就会出现「本地能跑、KV 形态部署跑不了」)。这是**运行时行为变更**,
  已在本地 wrangler dev 冒烟:引导管理员 / 登录 / 导出→恢复闭环 / scheduled 入口 /
  4 MiB 合成归档恢复(2,184 行)全部通过,服务端无异常日志。
- .nvmrc 24.18.0 → 24.21.0(当前 24.x LTS;CI 的 verify.yml、sync-global-domains.yml
  与 Cloudflare 构建镜像都读这个文件)。

有意未做:tailwindcss 4(dependabot 里本就 ignore major,v4 改配置形态、视觉回归面大)、
typescript 7(另开一轮,免与类型面升级互相掩盖)、@simplewebauthn/server 14(下一轮单独评估)。

注:新版 npm 会拦截未批准的 install scripts(workerd / esbuild 的 postinstall 被拦),
实测不影响构建与 wrangler dev,因此不 approve,保持默认更安全的设置。
备份/恢复加固 + 版本启动日志 + 演示站对齐 + CI/告警清理
依赖与 Cloudflare 版本升到最新(wrangler 4.131 / workers-types 5 / compatibility_date 2026-09-08)
问题(用户实测):底部"已加载/总数"每翻一页两个数都 +50(350/351 → 400/401),
右上角"页码/总页数"的总页数恒等于"页码 + 1"(7/8 → 8/9),用户永远看不到真实条数。

根因:`listAuditLogs` 把 total 算成 `offset + logs.length + (hasMore ? 1 : 0)`,
那是"已走过的行数 + 本页行数",不是真实总数;前端两个数字都由它推导。
只有最后一页(hasMore=false)凑巧是对的。

改法:
- `total` 改为真 `COUNT(*)`,与列表查询**并行**发出(不额外叠加一次串行往返,
  避免给翻页增加往返延迟)。
- 计数复用与列表相同的 FROM/JOIN 段:关键词搜索的 WHERE 引用 actor.email /
  target.email,缺 JOIN 会直接报列不存在。
- **无关键词时不 JOIN**:实测 0.1 ms(走 covering index idx_audit_logs_category_created),
  比带 JOIN 的写法快两个数量级。
- 带关键词时保留裸全表扫描:`LIKE '%q%'`(6 列)天然无法走索引,实测强制
  `INDEXED BY idx_audit_logs_created_at` 反而更慢(20,003 行:10.1 ms → 23.7 ms),
  故按 query-plan 护栏的规则登记白名单并写明理由(护栏按键名匹配,键取别名 `l`)。
- 顺带修演示站一致性问题:`demo.ts` 的 `onLoadAuditLogs` 原先返回
  `offset + sliced.length`,与服务端"回显请求 offset"的语义不同,会让演示站
  "下一页"多跳一页(跳过 50 条)。

上游关系:`src/services/storage-admin-repo.ts` 与 `webapp/src/components/LogCenterPage.tsx`
与上游 `shuaiplus/nodewarden` **逐字一致** ⇒ 这是上游自身的问题,本次为**有意分叉**,
将来同步上游时注意保留本修复(可考虑回馈上游)。

验证:
- 新增 `scripts/audit-log-pagination.test.ts`(4 个用例):翻页 total 恒定且等于真实条数、
  过滤/搜索条件下 total 等于该条件下的条数、恰好取满一整页时 hasMore=false、空库与越界 offset。
  已实测把旧实现塞回去时 4 个用例全失败,确认测试能抓住这个 bug。
- `npm run verify` exit 0 / **223 项**(原 219 + 4)。
- 真实运行时复验(20,005 行):底部恒为 `… / 20005`、右上角恒为 `… / 401`。
修复日志中心分页:显示真实总数,不再每页 +limit
来源:用 Playwright 做的一轮前端运行时审计(Tier 0:工具装在 /tmp,仓库零改动)。
环境与结论记录在本地文档 .vscode/TOFIX.md §九(该目录被 .gitignore 忽略,不进仓库)。

## 1. 键盘焦点不可见(WCAG 2.4.7,最严重)

现象:Tab 到按钮 / 链接 / 树节点时,屏幕上看不出焦点在哪。

证据(三条互相印证):
  ① Tab 聚焦后 el.matches(':focus-visible') 为 true,但 box-shadow 计算值是 none;
  ② 同一裁剪区在「键盘聚焦」与「失焦」两态截图**逐字节一致**(6 类元素里 5 类如此;
     对照组 input 有差异 ⇒ 证明方法本身有效,阴性结果是真的);
  ③ 遍历样式表定位到压制规则。

根因:base.css 的 :focus-visible 用 box-shadow 画焦点环,而组件样式里存在大量
`box-shadow: none` 的**普通规则**(.btn / .side-link / .side-sub-link /
.side-group-trigger / .tree-btn / .nav-layout-* 等)。它们与 :focus-visible 同为
0-1-0 权重、且位于其后 ⇒ 焦点环被整体压掉。

改法:改用 outline(不受那些 box-shadow 规则影响)。少数组件本就有自己的
`outline: none` 加自绘焦点环(.search-clear-btn / .toast-close / .generator-stepper /
.mobile-vault-filter-trigger 等),那些规则更具体且在后,仍然胜出,不受影响。

验证:像素取证 6/6 元素恢复可见焦点环(改前 5/6 完全无指示)。

## 2. 对比度(4 处,全部为运行时实测)

先把检测器做了自检(6/6 通过,含 CSS Color 4 的 color(srgb …) 语法与 alpha 合成)。
过程中修掉检测器自身的两个缺陷:只认 rgb() 会**静默漏报**所有 color(srgb …) 元素;
全交给 canvas 解析有 ±1 量化误差(4.48 / 4.54 这类阈值用例会给出错误结论)。
另加入 WCAG 1.4.3 的 disabled 控件豁免,否则会产生 2 处严重假阳性(2.08:1 / 3.48:1)。

| 位置 | 改前 | 改后 |
|---|---|---|
| .dialog-warning-kicker(暗色) | 2.673 : 1 | 达标(补 color: var(--danger)) |
| .btn-danger(浅色) | 4.474 : 1 | 5.58 : 1 |
| .backup-destination-meta(浅色) | 4.143 : 1 | 6.58 : 1 |

第 1 处的根因:styles.css 对 .dialog-warning-kicker 只重置了 letter-spacing /
text-transform,**漏了 color** ⇒ 回落到 overlays.css 的字面量 #b91c1c(浅色主题的红),
在暗色卡片底 rgb(21,27,36) 上只有 2.673:1。同组的 .dialog-warning-badge 与
.dialog-message.warning 都做了 token 化,唯独它漏了。

新增两个 token(--danger-text / --muted-mid):**暗色下取 var(--原 token)**,
所以暗色表现零变化(已实测计算值完全一致)。

## 3. 弹窗按钮尺寸声明从未生效

现象:桌面按钮恒 38px / 14px,而声明是 50px / text-xl;移动端声明的 46px / 16px 同样无效。

根因:入口层 styles.css 的 `.btn { height: 38px; font-size: 14px }` 与组件层
overlays.css 的 `.dialog-btn` 同为 0-1-0,但**入口层位于所有 @import 之后** ⇒ 尺寸被覆盖。

实现方式(第一版改错、复验时才发现,记录以免后人重蹈):
**不要**在组件层提权到 `.dialog-card .dialog-btn`(0-2-0)—— 那会连带压过
vault.css 的 `.totp-scan-actions .dialog-btn`(同为 0-2-0、但更早导入),
把扫码弹窗按钮的 text-base / mt-0 弄坏。
最终改为**在入口层用 0-1-0 定义**:压得过 .btn,又压不过任何 0-2-0 组件级规则。
同时清理组件层已失效的 h-[50px] / text-xl,避免同一份尺寸在两处重复声明。

实测:桌面 50px / 18.75px;≤1180px 46px / 16px;三档视口(1440 / 390 / 390×664)
横向与纵向溢出均为 0;TOTP 按钮的 15px / margin-top 0 **保持不变**(连带风险已验证)。

## 4. 顺带删除一个从未有过样式的幽灵元素

ConfirmDialog 的 warning 变体渲染的 .dialog-warning-strip:git 全历史检索确认
**任何提交里都不存在它的 background / height**(引入它的提交只加了一条移动端 margin),
实测恒为 height: 0px,完全不可见。负 margin -18px/-16px 等于卡片 padding 的负值,
说明原意是「卡片顶部通栏色带」,但只写了"拉出去"、从未写"长什么样"。
删除该元素与其 margin 规则;实测间距影响:桌面 0px、移动端 2px。

## 验证

- `npm run verify`:**exit 0**(22 组测试全部 fail 0,构建正常)
  —— 已在本分支 `npm ci` 对齐依赖后复跑(拆分支时 node_modules 还停留在 v14)
- 全站对比度复扫(10 路由 × 双主题 × 1440/390):**0 处不达标**(改前 2 项 / 31 条命中)
- 焦点像素取证:6/6 恢复;移动端 7 视口 × 9 路由:横向溢出 **0 / 63**
- 弹窗尺寸与溢出:桌面 1440、手机 390、矮屏 390×664 均为 0 溢出

## 未覆盖(诚实说明)

- warning 变体(备份校验和告警弹窗)在演示模式下不可达,需要一份校验和不匹配的备份归档。
  本次用「合成挂载」验证(在真实样式表已加载的页面里按 ConfirmDialog 的真实 markup 重建节点,
  让浏览器做真实级联计算);忠实性由「合成 default 变体精确复现真实弹窗的 4.474:1」证明。
  建议部署后在真实恢复流程里再看一眼该弹窗的暗色表现。
- 真实注册 / 登录 / 加密 / passkey 等依赖后端的路径不在本次范围。

## 审计工具的处置

Playwright 及全套探针脚本放在 /tmp(本机临时工具体系),**不进仓库、不进 CI**:
这样可以拿到运行时证据,又不给项目增加依赖与 CI 时间。若将来确实需要长期回归防线,
再单独评估引入(成本评估见本地文档)。
fix(a11y): 键盘焦点不可见 + 4 处对比度不足 + 弹窗按钮尺寸声明从未生效
chore(deps): @simplewebauthn/server 13.3.3 → 14.0.2(评估型升级)
背景:本轮三处修复都需要新的用户可见文案,集中放在一个提交里,保证后续提交引用这些键时
它们已经存在(`npm run i18n:validate` 的键对齐检查要求 10 个语言包同时具备新键)。

改动(`webapp/src/lib/i18n.ts` + 10 个语言包):
- 新增 `txt_verify_totp`(「验证 TOTP」):供设置页「验证器」弹窗那个按钮使用,与相邻的
  「停用 TOTP」成对。刻意**不**复用通用的 `txt_verify`(「验证」)—— 它还被
  `AppGlobalOverlays` 与 `ImportPage` 的主密码确认框使用,改它会连带改坏那两处的文案。
- 新增 `txt_totp_secret_unavailable`:两步验证已启用、但读不到可显示的验证器密钥时的提示。
  措辞刻意只描述**现象**、不断言原因 —— 走到这个分支既可能是「库里存的密钥不可用」,
  也可能只是「这一次读取请求失败」;把后者说成前者就是归因错误
  (与 H9 那条 cipher 文案同一类毛病)。同时它把「停用后重新启用」写成**条件性**建议,
  避免在真实原因只是网络抖动时诱导用户去关掉两步验证。
- 新增后端文案映射 `'The cipher key sent by the client is not a valid encrypted string.
  Update the client and try again.'` → `txt_server_error_cipher_key_invalid`。
  前端靠「英文字符串 → i18n 键」查表本地化后端错误,未登记就会原样显示英文。
- 修订 `txt_backup_replace_confirm_message`:明确告知「两步验证会回退到该备份中的状态,
  且所有设备会被强制登出」,并要求用户先确认自己仍能用该备份里的验证器或恢复码登录。

验证:`npm run i18n:validate` exit 0(10 个语言包键集合一致)。
背景:`isTotpEnabled()` 只看「有没有值」,而 `base32Decode()` 碰到 A–Z2–7 之外的字符
(手输/粘贴常带入的 0/1/8/9、全角字符、非 ASCII 空格)会返回 null ⇒
`findMatchingTotpCounter()` 永远返回 null。表现为一个隐蔽死锁:两步验证显示「已启用」、
客户端确实索要验证码,但**任何码都不对**。

改动:
- `src/utils/totp.ts` 新增 `isValidTotpSecret()`:真的能解码才算可用。
- 启用 / 写入路径改用新函数(`accounts.ts` 的 PUT get-authenticator 与
  PUT set-totp-status):非法密钥在「启用」的那一刻就被明确拒绝,
  而不是等用户登录时才发现「输什么都不对」。
- `accounts.ts` 的 GET get-authenticator:`Enabled` 改为反映「密钥可用」,与返回的 key 保持一致。
  过去用 `!!user.totpSecret`,遇到「有值但不可用」会回 `Enabled: true` + 一把**随机**密钥,
  客户端会把随机值当「当前密钥」展示(这正是排查 TOTP 问题时最误导人的一处)。
  现在这种情形回 `Enabled: false` + 新密钥,用户再启用一次即可自愈。
- 读取路径(`identity.ts` 的 `resolveTotpSecret`)**刻意继续**用 `isTotpEnabled`:
  库里存了坏密钥时仍要求两步验证,不能静默降级成「只要有密码就能登录」(fail-open)。
  只补一条可诊断的日志。

验证:`scripts/identity-handler.test.ts` 新增一条守卫 —— 密钥写成 `INVALID0`(字母表非法)时
仍必须返回 400 + `TwoFactorProviders` 含验证器提供方,且**绝不签发 access token**;
随后以合法密钥 `JBSWY3DPEHPK3PXP` 做正向对照,确认同样进入挑战流程。
背景:设置页「验证器」弹窗里的密钥与二维码来自前端 `randomBase32Secret(32)` 现场生成的
随机值,**从未**向服务端取过真实密钥 —— `setSecret()` 的三个调用点(初始随机值 / 重新生成 /
手动输入)没有一个来自服务端。已启用时输入框又是 `disabled`,视觉上很像「只读的真值」。
这是一次真实排查里误导双方数小时的陷阱:会让人以为「恢复备份把密钥换成了一个全新的」。

改动:
- `webapp/src/lib/api/auth.ts` 新增 `getTwoFactorAuthenticatorSecret()`
  → `POST /api/two-factor/get-authenticator`,并显式暴露 `enabled`:服务端在库里没有密钥时
  会**现场生成一把随机值**返回,调用方必须靠它区分「已保存的真值」与「本次待启用的新值」。
- `webapp/src/hooks/useAccountSecurityActions.ts` 新增同名 action(派生登录哈希后调用)。
- 弹窗打开时若已启用,先取服务端真值再展示(二维码与密钥都用真值);未启用则维持原有的
  「现场生成一把新密钥供启用」流程。
- **取不到真值时绝不展示随机值**,改为隐藏密钥与二维码并显示 `txt_totp_secret_unavailable`;
  但**弹窗照旧打开** —— 「停用 TOTP」按钮就在这个弹窗里,它是应用内停用两步验证的**唯一**
  入口。早期版本在这里直接 `return`(连弹窗都不开),于是「库里存着不可用密钥」的用户被
  彻底堵在门外:提示让他「在下方先停用」,可那个按钮他永远看不到,只能去登录页用恢复码自救。
  读取失败(网络 / 服务端错误)时给出具体错误通知,但同样照旧打开弹窗。
- 验证码输入框不再 `disabled`,右侧新增「验证 TOTP」按钮(键 `txt_verify_totp`):在**本地**
  用 `calcTotpNow()` 比对当前时间窗 ±1 步。刻意不打服务端:服务端校验会消费防重放计数,
  失败还计入登录失败次数(连续 10 次会把 IP 锁 2 分钟),而这里只是想帮用户确认
  「手机上的验证器与库里那把是否一致」,不该产生那些副作用。
  成败只通过右上角通知反馈,不在按钮旁重复显示文字。
- **启用成功后不再误报「拿不到密钥」**:过去只置 `setTotpLocked(true)`,没有同时标记
  `totpRealSecret`,于是刚被服务端保存的那把密钥立刻满足 `totpLocked && !totpRealSecret`,
  被当成「拿不到真密钥」—— 密钥框清空、二维码被替换成提示;关闭重开会重新拉取真值,
  所以表现为「重开就正常」。现在启用成功后 `setTotpRealSecret(secret)`,并清空验证码输入
  (与「关闭后重开」的状态完全对齐,也免得把刚被服务端消费掉的那个码留在框里继续点验证)。
- `webapp/src/lib/demo.ts`:演示模式 `createDemoMainRoutesProps` 未覆盖
  `onGetTotpAuthenticatorSecret`,会落到真实实现(演示环境没有后端)而抛错,表现为弹窗
  **根本打不开**。改为就地返回一把固定的公开示例密钥,顺带让新按钮可被真实验证器 App 验证。

验证:`npm run verify` exit 0。演示模式浏览器实测:弹窗显示真实密钥(非随机值);
用真实 HMAC-SHA1 算出当前验证码填入 → 「验证 TOTP」变为可用 → 点击 → 右上角「TOTP 验证通过」;
填错码 → 右上角「TOTP 验证失败」且按钮区无任何行内文字;启用后弹窗保持打开、仍显示真实密钥与
二维码、无密钥不可用提示、验证码框已清空、按钮切换为 停用/验证。另做过 A/B:撤掉
`setTotpRealSecret(secret)` 即复现「状态不一致」,恢复即正常。
背景(真实事故):调用方传入的 `progress` 会去 touch Durable Object 的作业租约并跨 DO 发通知。
过去这里是裸 `await progress?.(...)`,于是:
- `swapShadowTablesIntoPlace()` **之后**的完成通知抛错 ⇒ 交换已提交、恢复其实成功了,
  但异常被外层 catch 捕获,对外报 500(用户以为失败,实际数据已经换掉了);
- catch 分支里的失败通知抛错 ⇒ **把原始失败原因覆盖掉**,排障时看不到真正的原因。

改动:新增 `reportRestoreProgress()`(try/catch + `console.error`),把
`importBackupArchiveBytes` 与 `importRemoteBackupArchiveBytes` 里全部 12 处裸调用替换掉
(本地 / 远端各 6 处)。

验证:`npm run verify` exit 0(backup-handler、backup-roundtrip、security-audit-backup-* 全过)。
两处都是「对外宣告 / 返回的信息与实际能力不符」,同属兼容层,合并为一个提交。

① `src/config-response.ts`:`'email-verification'` 由 `true` 改为 `false`。
本服务器没有邮件发送通道 —— `/accounts/register/send-verification-email`、
`/accounts/verify-email`、`/api/two-factor/send-email-login` 等端点一律返回 501
「Email delivery is not supported by this server.」。报 `true` 会让客户端展示相应的设置项
并调用注定失败的接口。将来接入邮件能力(见 `docs/TODO/MAIL.md`)时再改回。

② `src/handlers/ciphers.ts`:`cipher.key` 不是合法加密串时的 400 文案。
旧文案「Cipher key encryption is not supported by this server. Resync the client and try again.」
有两个毛病:**归因错误**(服务器其实**支持**逐项密钥 —— `config-response.ts` 里该 flag 就是 true,
合法的 EncString 会被原样入库,那两处 400 只拒绝畸形值)、**建议无效**
(重新同步不会把畸形值变成合法值)。改为「The cipher key sent by the client is not a valid
encrypted string. Update the client and try again.」,抽成 `INVALID_CIPHER_KEY_MESSAGE` 常量
供创建 / 更新两处共用,并登记进前端 i18n 映射表。

验证:新增两条测试锁住行为 ——
- `scripts/config-compatibility.test.ts` 断言 `email-verification` 与
  `pm-19051-send-email-verification` 都为 `false`;
- `scripts/ciphers-handler.test.ts` 断言合法 EncString 被接受且**原样入库**、畸形值返回 400 且
  文案不再出现 "not supported"、响应文案与常量一致(防两处副本漂移)、
  且 10 个语言包都具备映射后的键。
- `npm run verify` exit 0。
`allowScripts`(npm 的安装脚本白名单)仍指向 `esbuild@0.28.1` /
`workerd@1.20260625.1`,而实际安装的是 `0.28.2` / `1.20260911.1`。
两者不一致会导致 npm 在部分命令上(实测 `npx` 触发的那类)**静默重写**这个字段,
表现为工作区莫名出现一处与本次改动无关的 diff。

本次只把白名单对齐到实际版本,未做其他依赖变更;不改 `package.json` 的 `version`
(这属于发版动作,另行处理)。

验证:`npm run verify` exit 0(workerd 版本变化由 query-plan / migration-upgrade 等
依赖本地 SQLite 引擎的测试覆盖)。
背景:`audit_logs.actor_user_id` 上的外键是 `ON DELETE SET NULL`,而 `DELETE FROM users`
有**两条**触发路径 —— 从备份恢复(`src/services/backup-import.ts`)与管理端删除用户
(`src/services/storage-user-repo.ts` 的 `deleteUserById`)。那一刻该用户所有历史日志的
`actor_user_id` 被置成 NULL,且**不会自愈**:恢复虽然把用户按同样的 id 写回,
但没有任何代码把值算回来。后果是日志中心的「操作者」永久显示 `—`、
按操作者邮箱搜索永久失效(静默的信息损失,对安全审计是实质损失)。

改动(采纳「行内快照」方案;与 target 侧早就在用的 `metadata.targetEmail` 同一思路):
- `audit_logs` 新增 `actor_email TEXT`:`migrations/0001_init.sql` 与
  `src/services/storage-schema.ts` 两处同步,且都放在**列尾**(保证新库与老库升级后列顺序一致)。
- 写入端 `createAuditLog`:用子查询**就地**抄一份邮箱,同一条 SQL 完成、**不多一次 D1 往返**
  (审计写入几乎每个变更操作都会触发)。新列刻意放列尾,保住前 9 个绑定参数的位置 ——
  `scripts/security-audit-api-key-semantics.mjs` 依赖 `bindings[2] === action`,
  插在前面会让该探针**静默**取到错的值。
- 读取端:`COALESCE(l.actor_email, actor.email)` —— 快照优先、JOIN 兜底(兼容未回填的老行)。
  顺带修正一个**已有的语义错误**:用户改过邮箱后,旧日志原先显示的是「此刻」的新邮箱;
  现在显示「写入当时」的邮箱,这才是审计日志该有的含义。
- 关键词搜索同步加上 `l.actor_email`(否则只修好了「显示」、没修好「搜索」)。
- 回填历史行:`UPDATE ... WHERE actor_email IS NULL AND actor_user_id IS NOT NULL`(幂等)。
- `STORAGE_SCHEMA_VERSION` → `2026-09-15-audit-actor-email`(不改则老库不会重跑 schema)。

⚠️ 两个必须知道的边界:
- **必须先升级、后恢复**。回填只能救「编号还有效」的行;升级之前已经被恢复 / 删用户抹掉的
  行永久丢失(本地库实测:7 条里 6 条可回填、1 条已丢)。
- 同一列前后口径不同:已回填的老行存的是「回填那一刻」的邮箱,新行才是「写入当时」的。

验证:
- `npm run verify` exit 0 / **227 项**全过(新增 1 项)。
- `scripts/audit-log-pagination.test.ts` 新增回归测试:断言外键确实把 `actor_user_id` 置空
  (前置条件,防止测试假绿)→ 行内快照仍能显示 → 按操作者邮箱仍能搜到。
- `scripts/migration-upgrade.test.ts` 会自动把新列纳入「老库 → bootstrap 补齐」的机械推导
  (现为 38 列),无需手改。
- 本地真实库执行 ALTER + 回填:6 条业务日志补上邮箱,唯一的系统事件保持 NULL(本就没有操作者)。
背景:提交 4 只修了恢复路径的调用点(`backup-import.ts`),而同一个写法在导出 / 远端备份
路径上还有 **11 处**裸 `await progress?.(...)`:`handlers/backup.ts` 8 处(其中一处是不带
`?.` 的直接调用 `await progress({`)+ `backup-archive.ts` 3 处。

这 11 处当时**并未真的触发** —— 它们拿到的回调只发通知,而 `notifyUserBackupProgress()`
整个函数体都在 try/catch 里,永远不抛。也就是说这些地方的安全性是**偶然**的:它依赖一条
没写下来的约定「进度回调的实现必须自己吞错」,而恢复路径的回调恰好违反了这条约定
(它会先 `touchLease()` 续 Durable Object 的作业租约,那一步会抛)—— 那正是 H3 的来源。
一旦有人给备份回调也加上 `touchLease()`,同一条语句会立刻变成:远端「verify 前」的上报
抛错被内层 catch 当成**校验失败** ⇒ `deleteFile()` **删掉刚上传成功的归档**、重试 3 次、
最后报「verification failed」并附上通知的错误 —— 烧掉 3 次上传、远端一个备份都不剩。
这种后果不该由约定来防。

改动:
- 新增 `src/services/backup-progress.ts`:`reportProgress()`(`try/catch + console.error`,
  绝不外抛、绝不改变调用方控制流),并把 CONTRACT 写在模块头部。
- 上述 11 处,以及 `handlers/backup.ts` 里那处转发包装(`remote_run_${event.step}`),
  全部改走 `reportProgress()`。
- 顺带把 `backup-import.ts` 的本地 `reportRestoreProgress()` 删掉,其 12 处调用点改用同一个
  实现 —— 至此全仓只有一处上报实现、一份契约(否则「同一件事两个实现」本身就是下一条
  没写下来的约定)。
- 契约同时写进三处回调类型的注释:`BackupRestoreProgressReporter`、
  `BuildBackupArchiveOptions.progress`、`executeConfiguredBackup()` 的 `progress` 参数。
- 新增两条守卫测试(`scripts/backup-roundtrip.test.ts` 附件 15 / 16),用一条**每次都抛**的
  进度回调把 H3 的两个后果钉住:
  ① 导入仍必须成功且数据真的落库(断言含 `local_complete` 阶段 —— 它正是过去对外报 500 的那一处);
  ② 抛出的必须仍是原始失败原因,不能被上报错误覆盖。

刻意**未**改 `handlers/backup.ts` 里两处恢复进度回调的实现:它的 `touchLease()` 抛错正是
「作业租约续不上」的信号,被调用点兜住即可,不该把租约失效一并吞掉。

验证:`npm run verify` exit 0 / **229 项**全过(新增 2 项)。
两条守卫做过 A/B:临时把 `reportProgress` 的 catch 去掉后两条都失败(证明不是空断言),
恢复 catch 即全绿。现有调用路径上的可观测行为不变(那些回调今天确实都不会抛)——
本提交把「进度上报不得决定业务成败」从约定变成了代码里的事实 + 可执行的守卫。
fix: TOTP 弹窗真值与停用入口、审计日志操作者快照、备份进度上报不变式
背景:`tsc --noUnusedLocals` 在当前树上报出 3 处「声明了但从未读取」。它们**不在**
`npm run verify` 的检查范围内(4 个 tsconfig 都没有打开 `noUnusedLocals`),也**不是**
GitHub「安全与质量」里那 10 条告警 —— 那 10 条(4 条 js/polynomial-redos + 6 条质量)
指向的代码早已改好(正则换成显式循环、符号已删除),属于告警簿记未同步。
下面这 3 处才是当前真正存在的死声明:

- `webapp/src/components/BackupCenterPage.tsx`:`type RecommendedProvider`
  (同一条 import 里的值 `RECOMMENDED_PROVIDERS` 仍在用,故只去掉类型)
- `webapp/src/components/vault/vault-page-helpers.tsx`:`VaultDraftField`
- `webapp/src/lib/backup-center.ts`:`BackupRuntimeState`

改动仅是从 import 列表里去掉名字,无运行时行为、无类型可见性变化。

验证:
- 4 个 tsconfig 全部强制 `--noUnusedLocals` 后均为 0 报错(清完即整树干净)
- `npm run verify` exit 0 / 229 项全过
背景:`tsconfig.json` 里这两个开关本来就是显式写着的(`noUnusedLocals: false` /
`noUnusedParameters: false`),也就是说"不查未使用声明"是个刻意的决定。但这次排查证明
它原先的替代方案(CodeQL)不可靠:它报的 10 条告警指向的位置**全都已修好**(陈旧簿记,
已逐条 dismiss),而真正还留在树里的 3 处死声明它**一条都没报**(上一个提交才修掉,
而且是在被它标记的那个文件里发现的)。

改动(只需 2 份,另两份通过 `extends` 自动继承):
- `tsconfig.json`:`noUnusedLocals` false → true
- `webapp/tsconfig.json`(不 extends 根配置):补上 `noUnusedLocals: true`

为什么不一并打开 `noUnusedParameters`(实测数据):会多出 7 处报错,且全部是
"按 handler 签名 / 接口实现接住参数但不用"的正常写法 —— `account-passkeys` 的
`request`、`ciphers` 的 `options`、`folders` 的 `request`、`router-public` 的 `env`、
`api/vault` 的 `authedFetch` 等。把这些当错误是错的策略,故保持 false 并在注释里写明。

代价与替代写法(已写进根配置注释):以后"故意留着不读"要用仓库既有的两种写法 ——
数组解构留空 `const [, setter] = useState(...)`(见 `webapp/src/App.tsx`),
或下划线前缀 `({ api_key: _apiKey, ...rest })`(见 `scripts/backup-roundtrip.test.ts`);
两种实测都能通过本开关。

验证:
- `npx tsc --showConfig` 确认 4 份配置都解析到 `noUnusedLocals: true`(extends 继承生效)
- 该开关确实会生效:打开前它实实在在报出了那 3 处死声明(上一个提交已修)
- `npm run verify` exit 0 / 229 项全过(typecheck 阶段现已自动带上该检查,无需手动传参)
chore: 删除 3 处死声明并打开 noUnusedLocals(让编译器而非 CodeQL 拦这类问题)
远端目的地不可达时(黑洞 IP / 丢包 / 容器被暂停 / TLS 握手挂住),fetch() 可能
**永不 settle**:异常永远抛不出来 ⇒ DO 的 catch 永不执行 ⇒ 请求一直挂着,最后由平台
兜底返回 `{"error":"internal error; reference = …"}`,管理员拿到的信息量为零。
所以这组超时不是为了"限速",而是为了让失败**真的成为一次失败**。

- 8 处外发请求统一走 withRemoteTimeout():包「请求 + 读 body」**整段**,
  因为 fetch() 收到响应头就 resolve,"发了头就不再发数据"卡住的是读 body;
  下载拆成「首包 + body」两段计时并复用同一个 AbortController
- 分档预算:控制类 5s / 列目录 10s / 传输按体积估算(≥30s、≤10min,按 256KB/s 保守带宽),
  避免把 100MiB 附件、64MiB 归档的"慢但成功"上传误杀成失败
- 超时映射成**不可重试**的 4xx:前端 createAuthedFetch 的 retryableRequest 对 429/5xx
  会自动重试 3 次,若回 500 会把一次超时放大成约三倍等待;DO→handler 只传 message,
  故另有 isRemoteRequestTimeoutMessage() 按消息形状判定
- DO 增加兜底 try/catch(原 fetch 改名 route):任何逃逸错误都变成可读且状态正确的响应
- 首个超时即中止本批:附件上传(≤18/批)与附件批量下载(≤40/批)不再逐个等满超时
- 文案:新增 txt_backup_error_remote_request_timeout(10 语言包)
  + translateServerError 的毫秒→秒换算映射
- 测试:scripts/backup-remote-timeout.test.ts(9 项,含"每处 fetch 必须带 signal"源码护栏),
  已 A/B 验证:去掉读 body 的包装 ⇒ 用例 3 秒内快速失败(而不是永远挂住)
api.yubico.com / upgrade.yubico.com「连上但不回包」时,两处 fetch 原先都没有超时:
- 登录的二步验证会挂住,最后由平台兜底 500 —— 用户既登不进去,也看不到原因;
- 管理员启用 YubiKey 时取 API 凭据同样会挂住。
与刚修的备份远端属于同一类缺陷(docs/TODO.md 第 1 项)。

- 新增 src/utils/request-timeout.ts:withRequestTimeout() 包**整段操作**(请求 + 读 body)——
  fetch() 收到响应头就 resolve,"发了头不再发数据"卡住的正是读 body 那一步;
  备份侧的 withRemoteTimeout 改为复用它(只保留「哪家 / 哪一步」的错误语义,行为不变)
- 二步验证:5 s 预算,超时保持 **fail-closed**(继续试下一个校验地址,最终返回 false)
  —— 绝不能出现"超时即通过";配了多个校验地址时逐个尝试,而不是卡死在第一个
- 取 API 凭据:5 s 预算,超时/网络错误统一走该函数既有的 `null` 通道
  ⇒ 管理员看到的是「无法初始化 Yubico 校验凭据」的 400,而不是平台通用 500
- 日志只记主机名与原因(校验 URL 的查询串含一次性口令),并区分 timedOut
- 预算可用 options.requestTimeoutMs 注入(仅供测试,生产不传)
- 新增 scripts/yubico-otp-timeout.test.ts(6 项):fail-closed / 多地址逐个尝试 /
  正常路径不被超时封装破坏(测试内独立实现 HMAC-SHA1 签名)/ 非法 OTP 与缺凭据回归 /
  getapikey 超时返回 null / getapikey 解析回归
背景:scripts/ 里有三个脚本从未审过实现(docs/TODO.md 第 2 条)。审完结论是风险极不均:
i18n-validate.cjs(verify 的 i18n 门禁,确实有 errors.length ⇒ process.exit(1),不是假绿)
与 pages-spa-redirects.cjs(7 行、无分支)**无需修改**;只有 ensure-kv.cjs 有实质风险 ——
它会改写**受版本控制**的 wrangler.kv.toml,而写进去的 id 决定“附件写进哪个 KV 库”。

- 不再猜:原来同名找不到时会退化成 title.endsWith('attachments-kv') 模糊匹配,
  可能把附件静默写进别的项目的库。现在发现“标题相近”就**停下来报错并列出候选**,
  要求显式选择:--id <32 位 hex>(复用其中一个)/ --force-new(确认新建)
- 不再“打印成功但其实没写”:插入后**校验** id 确实进了目标段,否则抛错
  —— 否则写回的是原文、下次构建又会新建,正是本脚本要防的 10014
- 不再可能插错段:正则改为段内定位(原实现要求 binding 行紧跟段头,格式一变就静默失效)
- 可测:纯函数经 module.exports 导出,main() 只在 require.main === module 时执行
- 新增 scripts/ensure-kv.test.ts(10 项)并纳入 npm test;断言重点不是“能跑通”,
  而是“猜错/写错/插错时会不会响”:id 必须留在 [[kv_namespaces]] 段内、
  段缺失 / 已有 id / 非法 id 都抛错、相近标题只报警不自动复用、--help 与未知参数行为
- README.md / README_ZH.md 的 deploy:kv 段补充说明(会写回配置 + 两个显式参数)
TODO 第 3 条「11 处 @apply text-<palette> 不随主题切换」核查后:计数与分布已过期,
绝大多数当年就用 dark.css 覆盖修好了(.mobile-page-title / .folder-add-btn /
.standalone-footer / .topbar / .import-summary-close / .input-icon-btn / .eye-btn /
.input-readonly / .input:disabled / .btn:disabled / .input:focus),只剩这一处漏网。

- forms.css:104 的 `.input-icon-btn:disabled`((0,2,0))被 dark.css:426 的
  `:root[data-theme='dark'] .input-icon-btn`((0,3,0))反压 ⇒ 暗色下禁用态与可用态**同色**,
  「不可点」的线索丢失。触发场景真实存在:VaultEditor 的「扫描 TOTP 二维码」按钮
  就是 disabled={busy};兄弟项 .input:disabled / .btn:disabled 都补了,唯独漏了它。
- 补 `color: color-mix(in srgb, var(--muted) 62%, transparent)`,与既有禁用态取值一致。
- 实测取证(demo + 合成挂载 + A/B 删规则):
  暗色 enabled rgb(203,213,225) vs disabled 62% alpha 的 muted ⇒ 可区分;
  **删掉本条规则后两者完全相同**(复现原缺陷);浅色前后一致(slate-700 / slate-400)⇒ 无回归。
- 全量静态扫描 547 个响应助手调用点:36 处把异常原文当消息实参,逐条审计确认
  无一处含表名 / 路径 / 内网地址(管理端备份链路的原文是刻意保留的排障文案)
- 改成源码护栏而不是运行时白名单:不动任何运行时行为,也不吃掉可操作的配置提示
- 安全形状 = 可静态证明:字面量 / 模板插值只含数学运算 / 三元分支均为字面量 /
  常量在同文件或 import 目标模块里被核实为字符串字面量;其余一律登记(34 条 / 42 处)
- 反向断言:新增未登记来源、登记项失效或数量对不上都会红;覆盖断言 total >= 500
  防助手改名后静默扫到 0 处(已用探针文件实测会红)
- 兜底口径与 isAdmin() 对齐:ensureAdminUserExists 原先只查 role='admin',
  于是「唯一管理员被 ban」会被当成已有管理员而永不兜底(只能手工改库),
  且会把 banned 用户当提权对象(提权后 isAdmin() 仍为 false)⇒ 改为
  role+status 双条件,且只在 status='active' 的用户里挑提权对象
- 删除/封禁前用库里最新状态复核操作者:actorUser 来自 isolate 级缓存(TTL 15 s,
  只清当前 isolate)⇒ 被 ban 的人还能用旧快照管理 15 s,现改为 403
- 新增 guardLastActiveAdmin:把「至少保留一个可用管理员」写成显式断言
  (导出仅为可测试性,handler 路径上当前不可达)
- 前端 admin API 的两条路径不再吞掉服务端文案(输错主密码原先只看到「删除失败」),
  并补 5 条 i18n 映射 × 10 语言包
- 测试 scripts/admin-last-admin-guard.test.ts(8 项);A/B 验证:分别回退兜底口径与
  「刷新操作者」时,各自只有对应用例变红
每次尝试**一开始**就清空 lastErrorMessage,而失败不更新 lastSuccessAt
⇒ 计划任务在容差窗口内立刻重试,错误就在「清空 → 30s 后写回 → 立刻又清空」的循环里
几乎永远看不到(真机验收时 backup.runtime 读到 None,而同一时刻审计日志每 30s 一条失败记录)。
改为:清空只发生在**成功**分支,尝试开始处只更新 lastAttemptAt。测试在下一个提交里。
DO 在租约被占用时返回 409,而 handler 侧过去是**直接 return** ⇒ 「这一轮计划任务被跳过」
没有任何留痕(不报错、日志里全 200),排查时极易误判成「超时没生效 / 计划任务没跑」。
现在 409 会 console.warn 一条,并写一条 backup.scheduled.skipped(system/warn)审计事件,
让「被跳过」能在日志中心里看到。cron 每 5 分钟一次、只在真有重叠时才会走到这里,不会刷屏。
新增 scripts/backup-error-visibility.test.ts(409 留痕 / 200 不留痕 / 500 仍照旧抛错,
外加第 18 条的源码护栏)。元数据键用白名单里已有的 error(ALLOWED_METADATA_KEYS 未登记的键会被静默丢弃)。
createInvite / deleteInvite / deleteInvalidInvites / deleteAllInvites 失败时抛硬编码英文
(Create invite failed 等),把服务端的 error_description / error 整个丢掉
⇒ 输错主密码只看到「创建邀请码失败」,用户无法自救。
改用 parseErrorMessage() 保留服务端说法;并补 1 条 i18n 映射
(Invite not found → txt_server_error_invite_not_found)× 10 语言包。
if (!X.ok) throw new Error('<硬编码英文>') 把服务端的 error_description / error 丢掉
⇒ 主密码输错、命中限流(429)、权限(403)时用户都只看到一句笼统的失败,无法自救。
全仓 110 个失败分支守卫里 41 处如此(其中 9 处写成 throw new Error(t('txt_…')),
文案本地化了但同样丢掉服务端说法);一律改成 parseErrorMessage(resp, t('txt_…')),
41 处的 fallback 语义现有键里全都有,无需新增任何 i18n 键。
新增源码护栏 scripts/webapp/api-error-visibility.test.ts(扫守卫分支 + 扫描器自检 +
覆盖断言 >= 100,防正则失效静默扫到 0);A/B:把一处改回硬编码 ⇒ 护栏点名 vault.ts:76。
…BackupSettingsSecrets 用 ...destination 展开保留了它),共享类型也一直在,缺的只是前端没人用。新增纯函数 getDestinationRuntimeSummary():失败原因走 translateServerError(),命中映射就本地化(超时文案还会毫秒→秒),未命中保留英文原文,刻意不回落到通用文案 —— 具体原因才是排障线索。侧栏多一行「上次失败:<时间>」(只显示时间不显示原因,侧栏窄);详情页顶部一个「上次失败」块(时间 + 原因)。新增 2 个 i18n 键 × 10 语言包;暗色下按第 3 条的教训在 dark.css 补了同特异性规则,否则会被 :root[data-theme="dark"] .backup-destination-meta 反压成 muted。
排查未推送提交时发现的遗漏:第 18/22 条加了「上次失败」展示,但演示站最后一个目标
的 lastErrorMessage 全是 null ⇒ 新功能在演示里永远看不到(本仓库已多次记录
「演示站与真实程序不一致」这类问题)。

- 新增第二个目标 Demo S3:lastSuccessAt 与 lastErrorAt/lastErrorMessage 同时存在
  —— 这正是后端「只在成功时清空错误」的真实形态(一直在重试、一直失败)
- 失败原文用 S3 upload timed out after 30000 ms,走 translateServerError 的
  毫秒→秒映射,演示站能直接看到本地化后的可读文案
- 已实测(127.0.0.1:5174):侧栏出现「上次失败:2026/5/4 17:00:30」且带
  .backup-destination-failed;详情页「最近运行」三行齐全、原因本地化为
  「远端备份目的地未在预期时间内响应(超时 30 秒)…」。
  浅色 rgb(193,31,31)=--danger-text、暗色 rgb(248,113,113)=--danger,
  而普通 meta 行在暗色下仍是 muted rgb(148,163,184) ⇒ dark.css 补的同特异性规则确实生效
按实际使用反馈调整(承接 docs/TODO 第 22 条):
- 去掉「上次尝试」与「上次成功」两行:前者对排障没帮助(本轮尝试已在跑或刚跑完),
  后者在左侧地点列表里已有一份,详情页重复只是占位置。没失败过时整个块不渲染(不留空框)
- 连带清理:删掉不再使用的 i18n 键 txt_backup_runtime_last_attempt(× 10 语言包)、
  未再使用的 .backup-runtime-row 规则;getDestinationRuntimeSummary() 只产出失败相关字段
- 间距:详情面板不是 gap 布局,各块靠自身 margin 分隔 ⇒ 给该块补 mb-2(与 .backup-name-row 一致),
  否则会与下面的「地点名称」贴在一起

实测(演示站 + Playwright):该块到「地点名称」7.5px = 「地点名称」到下方网格 7.5px;
无失败的目标该块 count === 0;浅色 rgb(193,31,31)=--danger-text、暗色 rgb(248,113,113)=--danger
(普通 meta 行暗色下仍是 muted rgb(148,163,184))⇒ dark.css 的同特异性规则仍然生效

verify 全绿(280 个测试)
- 判断改为比较 lastErrorAt 与 lastSuccessAt:成功晚于失败 ⇒ 不显示失败信息。
  后端成功时本会清空 lastError*,但归档恢复 / 手工改配置可能带进这种过时状态,
  不该再占着列表与详情页
- 时间无法解析时**保持显示**(宁可多提示一次,也不把真实失败藏起来)
- 侧栏与详情页共用 getDestinationRuntimeSummary(),不再各自判一遍
- 演示数据:Demo WebDAV 改成「失败过早于成功」以演示「成功后不再显示失败」,
  Demo S3 保持「失败在后」以演示「一直重试、一直失败」
- 测试:+3 用例(成功晚于失败 / 失败晚于成功 / 时间无法解析)
两端原先各写一份正则:后端构造它、按它判定是否超时(决定 400 不可重试 还是 500),
前端按它映射本地化文案 ⇒ 任一侧改措辞,另一侧静默失配(用户看到英文原文,
或超时被当 500 而触发前端自动重试 3 次)。
现在 actions 清单 + 构造器 + 正则都取自 shared/backup-timeout-message.ts,
两端测试样例也由构造器生成,并新增所有 provider × action 组合都被识别/命中的循环用例。
A/B:把前端正则临时换成手写版 ⇒ 循环用例立刻红。
注意:webapp/src/lib/i18n.ts 必须用相对路径 import 该共享文件(它会被后端测试间接加载)。
@cakkl cakkl closed this Sep 18, 2026
@gitguardian

gitguardian Bot commented Sep 18, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 5 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic Password e495ef7 scripts/webapp/password-security.test.ts View secret
- - Generic Password e495ef7 scripts/webapp/password-security.test.ts View secret
- - Generic Password e495ef7 scripts/webapp/password-security.test.ts View secret
- - Generic Password 8efe2a6 scripts/sends-handler.test.ts View secret
- - Generic Password e495ef7 scripts/webapp/password-security.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant