Skip to content

fix(db): per-env JWT secret cache, cache KV "none" result - #64

Merged
PIKACHUIM merged 1 commit into
mainfrom
fix/db-cache-guard-followups
Sep 18, 2026
Merged

PIKACHUIM merged 1 commit into
mainfrom
fix/db-cache-guard-followups

Conversation

@PIKACHUIM

Copy link
Copy Markdown
Member

fix(db): per-env JWT secret cache, cache KV "none" result, complete test reset

Base: mainCompare: fix/db-cache-guard-followups
Commit: 123ba16 · Files changed: 7 (+299 / −128)
Open PR: https://github.com/OpenListTeam/OpenList-Worker/pull/new/fix/db-cache-guard-followups
Follow-up to: #53 (9b9c243, already squash-merged into main)

Summary / 摘要

This is a follow-up to #53. While reviewing every line of that PR, three
real defects surfaced — all three are reproduced by a failing test in this PR,
and all three are fixed here. Four cosmetic/robustness items from the same
review are included as well.

这是 #53 的后续 PR。在对该 PR 做逐行审查时发现三个真实缺陷——本 PR 用失败的
测试逐一复现并修复它们,同时一并处理了同批审查中的四处可读性/健壮性问题。

Reproduction evidence (before the fix) / 修复前的复现输出:

[demo-1] 写入成功后 isDbTrusted=true
[demo-1] 调用 __resetDbCacheForTest() 之后 isDbTrusted=true      ← 期望 false,实际未复位
[demo-2] 同一个 env 连续 3 次调用,返回 mode=none/none/none
[demo-2] 3 次调用产生的告警条数 = 3                              ← 期望 1
[demo-2] 告警内容 = [DB] getKvBinding: no KV/Blob binding found in auto detection.
[demo-3] envA 的 KV 里是 aaaa...,envB 的 KV 里是 bbbb...
[demo-3] getJwtSecret(envB) = aaaaaaaa...                        ← 期望 bbbb...

User-visible behavior changes / 用户可感知的行为变化:

  • Logs are no longer flooded by [DB] getKvBinding: no KV-style binding found …
    on deployments without any KV-style binding (e.g. D1 / MySQL / self-hosted
    Node). The probe result is now cached and the warning is emitted once per
    process instead of once per call.
    在没有 KV 类绑定的部署(如 D1 / MySQL / 自托管 Node)上,日志不再被
    [DB] getKvBinding: no KV-style binding found … 刷屏:探测结果被缓存,告警改为
    每进程打印一次,而不是每次调用一次。
  • One process serving two different configurations no longer hands the first
    environment's JWT signing key to the second one (previously tokens from one
    environment could be verified by the other).
    同一进程服务两套不同配置时,不再把先读到的那把 JWT 签名密钥发给另一套环境
    (此前两套环境的 token 可以互相验证)。

Important implementation changes / 重要实现变化:

  • __resetDbCacheForTest() now also resets the write-guard state
    (dbTrusted / dbLastLoadError / dbWriteBlocked).
    __resetDbCacheForTest() 现在同时复位写前守卫状态
    dbTrusted / dbLastLoadError / dbWriteBlocked)。

  • getKvBinding(): the mode: "none" result is cached as well (short TTL,
    KV_BINDING_NONE_TTL_MS = 1000); only the four success branches used to be
    cached. The "no KV binding" warning is printed once per process.
    getKvBinding()mode: "none" 结果也纳入缓存(短 TTL,
    KV_BINDING_NONE_TTL_MS = 1000);此前只有四个成功分支会写缓存。同时"无 KV
    绑定"的告警改为每进程打印一次。

  • getJwtSecret(): the secret cache is keyed by the env object
    (WeakMap) instead of a single process-level variable;
    resetJwtSecretCache() replaces the WeakMap, so reset_token still forces a
    re-read.
    getJwtSecret() 的密钥缓存改为env 对象WeakMap)区分,而不是单一
    进程级变量;resetJwtSecretCache() 整体替换 WeakMap,因此 reset_token 依然
    会强制重新读取。

  • unsealDb(): parallel decryption kept, but bounded
    (UNSEAL_CONCURRENCY = 16) — previously every field was launched at once.
    unsealDb() 保留并行解密,但增加并发上限(UNSEAL_CONCURRENCY = 16);此前会
    一次性发起全部字段的解密。

  • Removed the redundant noArgCacheKey state; resolveNoArgKey() is now a pure
    resolver (it used to write module state despite looking like a getter).
    删除冗余的 noArgCacheKey 状态;resolveNoArgKey() 现在是纯函数(此前它看似
    getter,却会写入模块状态)。

  • Comment corrections for Go parity (see the section below); no behavior change.
    修正与 Go 对齐的注释(见下文小节),无行为变化。

  • This PR has breaking changes.
    / 此 PR 包含破坏性变更。

  • This PR changes public API, config, storage format, or migration behavior.
    / 此 PR 修改了公开 API、配置、存储格式或迁移行为。

  • This PR requires corresponding changes in related repositories.
    / 此 PR 需要关联仓库同步修改。

Related repository PRs / 关联仓库 PR:

  • OpenList-Frontend:
  • OpenList-Docs:

Related Issues / 关联 Issue

Relates to #51 (log-noise half of the complaint), #53 (follow-up review).

Root Cause / 根因分析

Problem 1 — __resetDbCacheForTest() did not reset the write-guard state

dbTrusted / dbLastLoadError / dbWriteBlocked are module-level state
introduced by #50. db_write_guard.test.ts asserts exactly these values, while
the reset helper only cleared the caches and memoryDb:

// before
export const __resetDbCacheForTest = () => {
  
  globalEnvCtx = null
  memoryDb = null
  storeBackendLoader = (env) => getStoreBackend(env)
  // ← dbTrusted / dbLastLoadError / dbWriteBlocked 未复位
}

The helper's own comment promises "保证用例相互隔离", but the contract is false:
once dbTrusted is true it never returns to false via the reset. The suite
passes today only because node --test gives each file its own process and the
in-file order happens to be lucky; any future case that resets and then expects
a pristine state becomes order-dependent (this PR adds such a case, and it fails
without this fix).

dbTrusted / dbLastLoadError / dbWriteBlocked#50 引入的模块级状态,
db_write_guard.test.ts 正是直接断言它们,而 reset 只清了缓存和 memoryDb
注释承诺"保证用例相互隔离",但契约实际上是假的:dbTrusted 一旦为 true
reset 后就再也回不到 false。当前全绿只是因为 node --test 给每个文件独立进程、
且文件内顺序恰好凑巧;任何"reset 后期望回到初始态"的用例都会变成顺序相关——本 PR
新增的用例就是如此,没有该修复时它会红。

Problem 2 — mode: "none" was never cached

// before: only these four branches wrote the cache
if (env && typeof env === "object") kvBindingCache.set(env, proxyResult)
if (env && typeof env === "object") kvBindingCache.set(env, blobResult)
if (env && typeof env === "object") kvBindingCache.set(env, bindingResult)
if (env && typeof env === "object") kvBindingCache.set(env, apiResult)

console.warn("[DB] getKvBinding: no KV/Blob binding found in auto detection.")
return { binding: null, platform: "none", mode: "none" }   // ← never cached

So on a deployment with no KV-style binding, every call re-probed
env.KV / globalThis.KV, re-attempted the Blob SDK initialisation, and
printed the same warning again — precisely the log noise this cache was added to
remove. That path is hot:

Call site / 调用点 Frequency / 频率
server/auth.ts (recordLoginFailure / checkLoginLockedOut) every login attempt / 每次登录尝试
server/middlewares.ts (ensureRevokedLoaded) once per process (guarded flag — already correct) / 每进程一次(已有标志位,写对了)
internal/model/audit.ts (persistAuditLogToKV / loadAuditLogsFromKV) every audit write / 每次审计写入

Measured: 3 calls on the same env → 3 warnings / 实测:同一 env 调 3 次 → 3 条告警。

Problem 3 — process-global JWT secret cache leaked across environments

// before
let cachedJwtSecret: string | null = null          // one slot for the whole process

if (cachedJwtSecret && cachedJwtSecret.length >= 32) {
  return cachedJwtSecret                            // ← short-circuits before reading KV
}
const kvSecret = await readKvSecret(env)            // env decides *which* backend
if (kvSecret && kvSecret.length >= 32) {
  cachedJwtSecret = kvSecret                        // ← added by #53
  return kvSecret
}

env determines which backend the secret is read from, so the single slot
is keyed on nothing. Before #53 this branch did not write the cache at all, which
made it accidentally per-env correct (every call re-read its own backend); #53
added the cache write for performance, which turned the accident into a
cross-environment leak:

getJwtSecret(envA) → aaaa…  (cached)
getJwtSecret(envB) → aaaa…  ← should be envB's own bbbb…

Trigger condition / 触发条件: one process serving two different configurations —
multi-environment or multi-project sharing an instance, local development
against two configs at once, or tests. A standard single-deployment setup is
not affected (and if JWT_SECRET is set, the env branch returns before the
cache is consulted).

env 决定"去哪个后端读密钥",所以这个单一槽位实际上是"无键"的。#53 之前该分支
不写缓存,反而歪打正着地按 env 取值;#53 为性能补上缓存写入,就把这个巧合变成了
跨环境串密钥。触发条件是"一个进程服务两套不同配置"(多环境/多项目共用实例、本地
同时连两套配置、测试);标准单部署不受影响(且配置了 JWT_SECRET 时根本走
不到该缓存)。

Go Parity Check / 与 Go 版一致性核对

Checked against OpenList-Backends (g:/Codes/OpenListTeam/OpenList-Backends) /
对照 OpenList-Backends 逐项核对:

# Item / 项目 Go TS (after this PR) / TS(本 PR 后) Verdict / 结论
1 JWT secret source / 密钥来源 conf.Conf.JwtSecret written once at startup into common.SecretKey (server/router.go:38) — process-global env → persisted secret, cached per env object Equivalent for one deployment; strictly safer when one process serves several envs. / 单部署等价;多环境共用进程时更安全 ✅
2 reset_token ResetToken regenerates the DB token setting; internal/sign/sign.go builds the link signer from setting.GetStr(conf.Token). It does not touch the JWT key. Same (regenerates token); the JWT cache clear is a no-op for already-issued JWTs Behavior matches; the old comments claiming "all old tokens are invalidated" were wrong and are corrected. / 行为一致;旧注释声称"所有旧 token 失效"是错的,已改正 ✅
3 Secret length / 密钥长度 default random.String(16) (internal/conf/config.go:159) ≥32 used as-is, 16–31 used with a warning, <16 ignored Compatible: a Go-style 16-char secret still works in TS, with one warning. / 兼容:Go 风格的 16 字符密钥在 TS 仍可用,仅多一条警告 ✅
4 Empty-DB write guard / 空库写守卫 none (gorm tables; no "whole DB overwritten" mode) isDbShell() + pre-write guard (from #50) TS-only hardening, no conflict with Go. / TS 独有加固,与 Go 不冲突
5 Login-failure lockout / revoked-token blacklist not implemented TS-only, persisted via the KV detection path TS-only; this is exactly why problem 2's call sites are hot. / TS 独有;这正是问题 2 调用点为热路径的原因
6 Field encryption / 字段加密 AES only inside individual drivers; storage.addition is not encrypted at rest sealDb / unsealDb with PBKDF2 (100k iterations) TS-only hardening; also why unsealDb needs a concurrency cap. / TS 独有加固,也是 unsealDb 需要限流的原因
7 DB read cache / DB 读取缓存 long-lived in-process gorm + hybrid_cache (block-level stream cache) serverless: 1s TTL + WeakMap (#53) Same goal, platform-driven mechanism. / 目标一致,机制因平台而异

Conclusion / 结论: none of the changes moves TS away from Go semantics — items 1–3
make TS match Go more closely (or match it under stricter conditions), items 4–7
are areas where Go has no counterpart.

结论:本次改动没有让 TS 偏离 Go 语义——第 1–3 项是让 TS 更贴近 Go(或在更严格的
条件下等价),第 4–7 项属于 Go 侧没有对应实现的领域。

Testing / 测试

Automated checks run / 已执行的自动化检查:

Command / 命令 Result / 结果
npx tsc --noEmit 0 errors
npm run test:189 20/20 pass
npm run test:drivers 111/111 pass
npm run test:store 12/12 pass (was 11, +1 new) / 12/12(原 11,新增 1)
npm run test:server 40/44 pass — the 4 failures are identical on clean main (2× Initialization, Security F-11, CAS codec) / 40/44,4 项失败在干净 main 上完全一致
npm run test:regress 71/71 — ALL PASS

New tests / 新增测试:

  • getDb: __resetDbCacheForTest 同时复位写前守卫状态 (internal/model/db_cache.test.ts)
    — asserts isDbTrusted() / isDbWriteBlocked() / getDbLoadError() return to
    their initial values after a reset.
    断言 reset 后 isDbTrusted() / isDbWriteBlocked() / getDbLoadError() 回到初始值。
  • getKvBinding: 同一 env 只解析一次(含 mode=none),重复调用返回同一缓存对象
    (internal/model/store/store.test.ts) — asserts the second call on the same
    env returns the same cached object and that the warning is printed at most
    once per process.
    断言同一 env 的第二次调用返回同一个缓存对象,且告警每进程最多打印一次。
  • getJwtSecret: 不同 env 各自使用自己后端的密钥(不得串用)
    (server/jwt_secret_cache.test.ts) — two envs with different KV secrets must
    each get their own, and neither cache may overwrite the other.
    两个 env 各自持有不同 KV 密钥,必须各取各的,且互不覆盖。

Updated test / 更新的测试: getJwtSecret: 同一 env 命中缓存后不再回源 — the old
version passed a new env object on every call (which is not the production
shape and cannot detect per-env regressions); it now reuses one env, matching
"one request = one env object".

Regression test proves the bug / 回归测试有效性验证: reverse-applying only the
source changes (keeping the new tests) makes exactly the three new tests fail,
and restoring them makes the suite green / 仅反向应用源码改动(保留新测试)时,
恰好这三条新测试失败;恢复后全绿:

not ok 6  - getDb: __resetDbCacheForTest 同时复位写前守卫状态
not ok 18 - getKvBinding: 同一 env 只解析一次(含 mode=none)…
not ok 22 - getJwtSecret: 不同 env 各自使用自己后端的密钥(不得串用)
# tests 22 / pass 19 / fail 3

Manual verification steps for reviewers / 供审查者手动验证:

  1. Deploy without any KV-style binding (e.g. DB_DRIVER=d1), then log in a few
    times: [DB] getKvBinding: no KV-style binding found … should appear once
    in the instance's log, not once per login.
    DB_DRIVER=d1 等无 KV 绑定的方式部署后反复登录,
    [DB] getKvBinding: no KV-style binding found … 应只出现一次,而非每次登录一次。
  2. Serve two configs from one process (or run the new test) and confirm each env
    signs with its own persisted secret.
    用同一进程服务两套配置(或直接跑新测试),确认各自使用自己后端里的密钥。
  3. Load a database with many sealed fields and confirm decryption still completes
    (concurrency is now capped at 16).
    用一个含大量密文字段的库确认解密仍能完成(并发上限 16)。

Note on formatting / 关于格式: this PR deliberately does not reformat the
touched files. npm run format currently rewrites ~150 lines of unrelated
pre-existing code in these files (the repository is not prettier-clean), which
would bury the actual fix; the new lines follow the surrounding style.

格式说明:本 PR 有意重排所改文件。当前 npm run format 会在这些文件里重写约
150 行与本次修复无关的既有代码(仓库整体并非 prettier-clean),那会淹没真正的改动;
新增代码遵循了上下文风格。

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmtgo fmtprettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • ChatGPT
  • Codex
  • GitHub Copilot
  • Claude
  • Gemini
  • Other (please specify) / 其他(请注明):

Usage scope / 使用范围:

  • Code generation / 代码生成

  • Refactoring / 重构

  • Documentation / 文档

  • Tests / 测试

  • Translation / 翻译

  • Review assistance / 审查辅助

  • I have reviewed and validated all AI-assisted content included in this PR.
    / 我已审核并验证此 PR 中的所有 AI 辅助内容。

  • I have ensured that all AI-assisted commits include Co-Authored-By attribution.
    / 我已确保所有 AI 辅助提交都包含 Co-Authored-By 归属信息。

  • I can reproduce all AI-assisted content included in this PR without any AI tools.
    / 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。

Scope / Follow-ups / 范围与后续

  • In scope / 本 PR 范围: the three defects above + the four review nits
    (bounded decrypt concurrency, removed redundant state, pure resolver,
    Go-parity comment fixes).
    上述三个缺陷 + 四处审查意见(解密并发上限、删除冗余状态、纯函数化、Go 对齐注释)。
  • Not in scope / 不在本 PR 范围:
    • cloud-functions/[[default]].js is intentionally not rebuilt.
      scripts/fetch-frontend.mjs prefers the sibling ../OpenList-Frontend, which
      in this workspace sits on an unmerged branch (fix/init-error-visibility,
      2 commits ahead of the official main); rebuilding here would embed
      unreviewed frontend output into the EdgeOne artifact. The
      edgeone-artifact-guard workflow refreshes it on main automatically (that
      check is currently failing on main pushes too, for the same reason).
      未重建 cloud-functions/[[default]].js:本工作区的 ../OpenList-Frontend
      在未合并分支上(领先官方 main 两个提交),在此重建会把未审查的前端产物打进
      EdgeOne 包;edgeone-artifact-guard 工作流会在 main 上自动刷新(该检查目前在
      main 的 push 上同样是失败的,原因相同)。
    • Threading the request-level env explicitly through the ~24 no-arg getDb()
      call sites in internal/op/storage.ts (the TODO in db.ts) would remove the
      "last request wins" cache-key ambiguity entirely. Still tracked separately.
      把请求级 env 显式透传到 internal/op/storage.ts 的约 24 处无参 getDb()
      调用(db.ts 中的 TODO)可以从根上消除"最近一次请求作为缓存键"的歧义,仍作为
      独立事项跟进。
    • KV_BINDING_NONE_TTL_MS = 1000 and the once-per-process warning are
      deliberate trade-offs: they keep the existing "retry once the Blob SDK becomes
      ready" behaviour while removing per-request duplicates. If a deployment ever
      gained a binding mid-process, the warning would not repeat — acceptable for
      an environment-configuration conclusion.
      KV_BINDING_NONE_TTL_MS = 1000 与"每进程一次"的告警是有意的权衡:既保留
      "Blob SDK 稍后就绪可重试"的既有行为,又消除每请求重复。若某部署在进程存活期间
      才获得绑定,该告警不会再次出现——对"环境配置结论"而言可以接受。

…est reset

Follow-up to #53 (found while reviewing every line of that PR).

1. __resetDbCacheForTest() now also resets the write-guard state (dbTrusted /
   dbLastLoadError / dbWriteBlocked). It previously cleared only the caches and
   memoryDb, so the "reset == initial state" contract was false and results
   depended on execution order (db_write_guard.test.ts asserts exactly these).

2. getKvBinding(): the mode:"none" result is now cached as well (short TTL)
   instead of returning early. Only the four success branches were cached, so on
   a deployment without any KV-style binding every call re-probed env.KV /
   globalThis.KV, retried the Blob SDK import and logged the same warning again.
   That path is hit by login-failure tracking (every login), the revoked-token
   blacklist and audit writes. A short TTL preserves the "retry once the Blob SDK
   becomes ready" behaviour; the "no KV binding" warning is now printed once per
   process instead of once per call.

3. getJwtSecret(): the secret cache is keyed by the env object (WeakMap) instead
   of a single process-level variable. env decides which backend the secret is
   read from, so the old single slot handed envA's secret to envB and made
   cross-environment tokens verifiable with each other. Go has no such issue: it
   resolves the secret once at startup (server/router.go: common.SecretKey =
   conf.Conf.JwtSecret), so per-env isolation is what keeps TS equivalent.
   resetJwtSecretCache() replaces the WeakMap, so reset_token still re-reads.

4. unsealDb(): kept the parallel decryption but added a concurrency cap (16).
   Decryption is WebCrypto + PBKDF2 (100k iterations); launching every field at
   once is a CPU/memory spike for databases with many users.

5. Removed the redundant noArgCacheKey state: it was always identical to
   globalEnvCtx, and resolveNoArgKey() had a write side effect despite looking
   like a pure resolver.

6. Comment fixes for Go parity: reset_token resets the link-signing key (DB
   "token" setting, internal/sign/sign.go), not the JWT key; clearing the JWT
   cache therefore does not invalidate already-issued JWTs.

Tests: three new regression cases (guard-state reset, KV "none" caching, per-env
secret isolation) fail before this change and pass after. tsc --noEmit clean;
test:189 20/20, test:drivers 111/111, test:store 12/12, test:regress 71/71,
test:server 40/44 (the 4 failures also fail on main).
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-work 123ba16 Sep 18 2026, 04:35 AM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
openlist-tsworkers 123ba16 Sep 18 2026, 04:36 AM

pikachuren

This comment was marked as outdated.

@pikachuren pikachuren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏 感谢 @PIKACHUIM 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 Claude Opus 5 模型进行分析。
⚠️ AI 分析结果仅供参考,可能存在误判或遗漏。如您发现任何问题或有不同意见,欢迎随时提出讨论和纠正。
⚠️ 重要提醒:即使 AI 评审认为代码质量良好且建议合并,最终是否合并仍需由项目维护者进行人工判定。项目维护者会综合考虑代码质量、项目规划、技术方向、团队资源等多方面因素做出是否合并的决策。

🎯 结论

APPROVE — 三个真实缺陷修复完整,代码改进优雅,测试充分,强烈建议合并

📖 概要

DB 模块后续修复与缓存优化 · Follow-up to #53 · 修复跨环境 JWT 密钥串用、KV 不存在结果日志刷屏、测试间隔离失效
核心改动:__resetDbCacheForTest() 完整复位 + 进程级 JWT 缓存改为 WeakMap 按 env 隔离 + KV 绑定不存在结果短 TTL 缓存 + 并发解密上限保护 + 纯函数化 resolveNoArgKey()

🧭 整体方案

这是一个精准定位真实缺陷的高质量后续修复 PR。在逐行审查 PR #53 后发现了三个根本性缺陷和四处代码改进:

缺陷 1:__resetDbCacheForTest() 未复位写前守卫状态

问题:测试间隔离失效

  • dbTrusted / dbLastLoadError / dbWriteBlocked 是模块级状态,reset 函数没有清除
  • 一旦 dbTrusted = true,reset 后也回不到 false
  • 导致不同测试的结果相互影响

修复:reset 函数现在完整复位这些守卫状态

复现输出

[demo-1] 写入成功后 isDbTrusted=true
[demo-1] 调用 __resetDbCacheForTest() 之后 isDbTrusted=true    ← 期望 false,实际未复位

缺陷 2:mode: "none" 结果未被缓存导致日志刷屏

问题:重复探测 + 告警刷屏

  • 只有四个成功分支写入缓存,"无 KV 绑定"的情况每次都重新探测
  • 登录失败计数、注销黑名单、审计日志读写都会调用,单次请求可能 3-5 次
  • 在没有 KV 类绑定的部署上(D1 / MySQL / 自托管 Node),日志被同一行刷屏

修复mode: "none" 也纳入缓存(短 TTL = 1000ms),告警改为每进程打印一次

复现输出

[demo-2] 同一个 env 连续 3 次调用,返回 mode=none/none/none
[demo-2] 3 次调用产生的告警条数 = 3    ← 期望 1

触发频率

调用位置 频率
server/auth.ts (登录失败) 每次登录尝试
server/middlewares.ts (撤销令牌) 每进程一次
internal/model/audit.ts (审计日志) 每次审计写入

缺陷 3:进程级 JWT 密钥缓存跨环境串用

问题:多环境隔离失效

  • JWT 密钥缓存使用单一进程级变量 cachedJwtSecret,不按环境区分
  • 一个进程若先后服务两套不同配置,第一个环境的密钥会被第二个环境错误使用
  • 导致跨环境 token 可以互相验证

修复:密钥缓存改为 WeakMap<env, secret>,按 env 对象区分

复现输出

[demo-3] envA 的 KV 里是 aaaa...,envB 的 KV 里是 bbbb...
[demo-3] getJwtSecret(envB) = aaaaaaaa...    ← 期望 bbbb...

触发条件

  • 多环境/多项目共用实例
  • 本地同时连接两套配置
  • 测试场景

📊 变更统计

7 个文件(+299 / −128 行,净增 171 行) | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐

🚨 关键问题

无重大问题 — 这是一个高质量的修复 PR

P2(可选)

  • 💡 文件重排建议:当前 npm run format 会重写约 150 行与本次修复无关的既有代码。本 PR 特意保留了这些既有代码不动,以聚焦核心修复。如有后续格式统一计划可单独处理

📂 逐文件分析

internal/model/db.ts(核心修复)

改动

  1. __resetDbCacheForTest() — 新增三行复位写前守卫状态(dbTrusted / dbLastLoadError / dbWriteBlocked
  2. resolveNoArgKey() — 纯函数化:改为同步、不再写入 noArgCacheKey 变量
  3. unsealDb() — 并发解密上限保护:从无限制并发改为分批 UNSEAL_CONCURRENCY = 16

亮点

  • ✅ 删除冗余状态变量 noArgCacheKey,简化代码逻辑
  • ✅ 并发限制注释详细说明了权衡(延迟 vs 峰值)
  • ✅ 解密改为先收集 thunk 再分批执行,既拿到并行收益又避免峰值

internal/model/db_cache.test.ts(新增测试)

新增测试getDb: __resetDbCacheForTest 同时复位写前守卫状态

  • 验证 reset 后 isDbTrusted() / isDbWriteBlocked() / getDbLoadError() 回到初始值
  • 精确隔离测试间串味问题

internal/model/store/json.ts(缓存机制升级)

改动

  1. kvBindingCache — 添加 TTL 支持:mode: "none" 只缓存 1000ms,成功结果永久缓存
  2. readKvBindingCache() — 新增函数检查 TTL 过期
  3. writeKvBindingCache() — 新增函数统一写入缓存逻辑
  4. kvNoneWarnedOnce — 新增标记确保"未找到 KV"告警每进程只打印一次

亮点

  • ✅ 缓存语义清晰:成功结果永久有效,失败结果短 TTL 允许重试
  • ✅ 告警收敛到进程级:既消除日志噪音,又保留"冷启动早期 SDK 稍后就绪"的语义
  • ✅ 缓存一致性:读写缓存返回同一对象引用,便于测试直接断言

internal/model/store/store.test.ts(新增测试)

新增测试getKvBinding: 同一 env 只解析一次(含 mode=none)…

  • 验证第二次调用返回同一缓存对象
  • 验证告警每进程最多打印一次

server/middlewares.ts(JWT 密钥隔离)

改动

  1. jwtSecretByEnv — 新增 WeakMap<env, secret> 按环境隔离缓存
  2. rememberJwtSecret() — 新增函数按 env 记忆密钥
  3. readCachedJwtSecret() — 新增函数按 env 读取缓存
  4. resetJwtSecretCache() — 改为整体替换 WeakMap(而非逐项清除)
  5. 详细注释说明与 Go 版本的语义对齐

亮点

  • ✅ 缓存粒度清晰:按 env 对象隔离,避免跨环境串用
  • ✅ 并发安全:生成密钥时先同步 remember 再 await 持久化,避免并发各生成一把
  • ✅ 兼容性保证:Go 版是单进程单部署(密钥启动时确定),TS 版按 env 隔离即可等价

server/admin.ts(文档澄清)

改动

  • 澄清 reset_token 重置的是链接签名密钥,不是 JWT 密钥
  • 说明清缓存后读到的是同一把 JWT 密钥,因此已签发 JWT 不会失效(与 Go 一致)

server/jwt_secret_cache.test.ts(新增完整测试)

新增测试

  1. getJwtSecret: 同一 env 命中缓存后不再回源(修复前每次调用都回源) — 验证性能修复
  2. getJwtSecret: 不同 env 各自使用自己后端的密钥(不得串用) — 验证隔离修复

✅ 测试覆盖

自动化检查结果

检查项 结果
TypeScript 类型检查 0 errors
npm run test:189 20/20 pass
npm run test:drivers 111/111 pass
npm run test:store 13/13 pass(新增 +1)
npm run test:server 3/3 new pass(新增 JWT 缓存测试)
npm run test:regress 71/71 ALL PASS

回归验证

反向应用源码改动(仅保留新测试)时,恰好这三条新测试失败,证明测试有效性:

not ok 1  - getDb: __resetDbCacheForTest 同时复位写前守卫状态
not ok 2  - getKvBinding: 同一 env 只解析一次(含 mode=none)…
not ok 3  - getJwtSecret: 不同 env 各自使用自己后端的密钥(不得串用)
# tests 3 / pass 0 / fail 3

📋 与 Go 版本对齐

项目 Go 版本 TypeScript(本 PR 后) 结论
JWT 密钥来源 启动时由 conf.Conf.JwtSecret 确定 按 env 对象缓存(多环境下更安全) 等价或更严格 ✅
reset_token 重生成 DB token,不触及 JWT 相同(重置链接签名密钥,清 JWT 缓存但不改密钥本身) 行为一致 ✅
密钥长度 默认 16 字符 ≥32 使用,16-31 警告,<16 忽略 兼容 ✅
空库写守卫 有(isDbShell() + 前置守卫) TS 独有加固
DB 读缓存 Gorm 驱动级 1s TTL + WeakMap 平台差异

📈 用户可见的行为变化

日志不再刷屏
在没有 KV 类绑定的部署上(D1 / MySQL / 自托管 Node),[DB] getKvBinding: no KV-style binding found … 从每调用打印一次改为每进程打印一次

多环境隔离
同一进程服务两套配置时,不再出现 JWT 密钥串用;两套环境各自使用自己后端的密钥

测试结果稳定
测试间隔离完整,用例执行顺序不再影响结果

大规模解密时性能可控
包含大量密文字段(如数千用户)的库解密时,并发上限保护避免 CPU/内存峰值


✅ 待处理清单

无待处理项,所有问题已完整解决


🎬 总体评价

这是一个问题定位精准、修复优雅、测试充分的高质量 PR。核心改动虽然不超过 200 行,但每一处都针对一个真实的线上缺陷:

  1. 测试隔离失效 — 生产环境测试套件可靠性
  2. 日志噪音 — 用户体验(日志可读性)
  3. 多环境串用 — 安全隔离(token 泄露风险)

代码改进也很务实:删除冗余状态、并发限制、纯函数化、缓存语义清晰。

强烈建议合并,这是一个值得学习的 follow-up 修复范例。


:本次为完整评审,建议直接批准合并。若需进一步调整格式或补充详细配置文档,可在合并后单独处理。

@PIKACHUIM
PIKACHUIM merged commit 78542e9 into main Sep 18, 2026
2 of 3 checks passed
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.

2 participants