fix(store): persist secrets through the unified driver layer - #57
Conversation
readPersistedSecret/writePersistedSecret only used getKvBinding(), which probes KV-flavoured backends (native KV, EdgeOne Blob, CF KV REST, EdgeOne KV proxy) and has no D1/MySQL branch. As a result, when running on D1 the business data persisted fine while secrets could neither be read nor written, and the code logged a misleading "no KV storage found, using memory-only mode (data will not persist)". Because getJwtSecret() falls back to an in-process random key when no secret can be persisted, every cold start / instance generated a different signing key. Multi-instance deployments therefore signed download links with one key and verified them with another, surfacing as intermittent "sign verify failed" (401) on file downloads and video playback for both logged-in users and guests -- and unreproducible on Cloudflare, where KV persistence masks the issue. Issue #51. This routes secret persistence through getStorageBackend() -- the same driver resolution used for business data -- so D1, MySQL, DO, KV and Blob all persist secrets consistently. Driver failures are handled gracefully: reads return null and writes return false instead of throwing. Also corrects the misleading detector log so a working D1/MySQL backend no longer appears to be losing data.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
openlist-work | 63b48df | Sep 14 2026, 10:07 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
openlist-tsworkers | 63b48df | Sep 14 2026, 10:07 AM |
pikachuren
left a comment
There was a problem hiding this comment.
🙏 感谢 @PIKACHUIM 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 Claude Opus 5 模型进行分析。
🎯 结论
✅ Approve — 根因诊断清晰,修复精准完善,单元测试覆盖充分
📖 概要
fix(store): persist secrets through the unified driver layer · 修复 D1 部署时密钥持久化失效导致的「sign verify failed」(401)
核心改动:统一密钥与业务数据的驱动解析路径(从 getKvBinding → getStorageBackend)、新增 2 个单元测试、修正日志措辞
🧭 整体方案
复用 getStorageBackend() 解析密钥存储驱动,确保「密钥与业务数据存储一致」。替换 readPersistedSecret() / writePersistedSecret() 的实现,统一调用 driver.get() / driver.put()。修正误导性日志措辞(「memory-only mode」→「DB_DRIVER specified」)。方案精准,完美解决了多实例签名密钥不一致的根因。
📊 变更统计
3 个文件(+238 / -128 行) | 功能 ⭐⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐⭐⭐
🚨 关键问题
无重大问题
📂 逐文件分析
src/backend/internal/model/store/json.ts(核心修复)
改动意图:统一密钥与业务数据的持久化驱动、修正日志
代码逻辑:
- 新增
resolveSecretDriver(env)复用getStorageBackend()解析驱动 readPersistedSecret()改用driver.get(key, env)替代getKvBinding()writePersistedSecret()改用driver.put(key, secret, env)替代getKvBinding()getKvBinding()日志修正:从「memory-only mode (data will not persist)」改为「DB_DRIVER 已配置,业务数据由 getStorageBackend() 处理」- 修正 platform 取值(「Memory」→「none」),避免正常工作的 D1 后端显得在丢失数据
问题分析:✅ 完全合理,根因诊断清晰:
- 旧实现只走
getKvBinding()(只识别 KV、Blob、CF KV REST),无 D1/MySQL 分支 → D1 部署时密钥既读不到也写不进 - 每次冷启动 fallback 到内存随机密钥 → 多实例间签发/验签 JWT 不一致 → 401
- 新实现走
getStorageBackend()(支持全量驱动:D1、MySQL、DO、KV、Blob)→ 密钥与数据一致存储 → 签名验证稳定
细节优化:
- ✅ 兼容多种驱动的 get/put 接口差异(Response vs 直接值)
- ✅ 优雅降级保留(驱动异常返回 null/false,不新增抛错路径)
- ✅ 日志措辞改为「有可读性的状态指示」而非「误导性的错误警告」
src/backend/internal/model/store/store.test.ts(测试)
改动意图:新增 2 个单元测试覆盖密钥持久化与优雅降级
代码逻辑:
createMockD1Binding():构造最小可用的 D1 模拟对象(支持 kv 表的 prepare/bind/first/run 语义)- 测试 1:验证密钥可在 D1 驱动中正确写入 / 读取(修复前失败)
- 测试 2:验证无后端时读返回 null、写返回 false,不抛错
问题分析:
- ✅ 测试 1 直接验证了修复的核心:D1 驱动下密钥持久化不再失效
- ✅ 测试 2 覆盖了优雅降级路径
- ✅ Mock 对象设计简洁,准确模拟 D1.prepare() 的 bind/first/run 接口
- ✅ 测试用例是此前 issue #51 的经验总结(「冷启动生成新密钥 → 多实例不一致」)
cloud-functions/[[default]].js(产物)
改动意图:重新构建 EdgeOne 产物,反映源码修改
问题分析:✅ 产物已按 node scripts/build-edge.mjs 重新生成,对应源码变更
✅ 待处理清单
无待处理项
🎯 结论:✅ Approve — 根因分析透彻、修复设计完善、测试覆盖充分,可直接合并
fix(store): persist secrets through the unified driver layer
Base:
main← Compare:fix/d1-secret-persistenceCommit:
63b48df· Files changed: 3 (+238 / −128)Open PR: https://github.com/OpenListTeam/OpenList-Worker/pull/new/fix/d1-secret-persistence
Summary / 摘要
Fixes intermittent
sign verify failed(401) on file downloads and videoplayback for D1-backed deployments (issue #51), and removes the misleading
[DB] getKvBinding: no KV storage found, using memory-only mode (data will not persist)log.修复使用 D1 部署时下载文件 / 播放视频偶发
sign verify failed(401)的问题(issue #51),并移除误导性的
[DB] getKvBinding: no KV storage found, using memory-only mode (data will not persist)日志。Root cause / 根因: secrets were persisted through a different code path than
business data. Business data goes through
getStorageBackend()(supportsD1 / MySQL / DO / KV / Blob), while secrets went through
getKvBinding()— adetector that only probes KV-flavoured backends and has no D1/MySQL branch.
密钥与业务数据走了两条不同的持久化路径:业务数据经
getStorageBackend()(支持 D1 / MySQL / DO / KV / Blob),密钥却经
getKvBinding()—— 后者只探测KV 类后端,没有 D1/MySQL 分支。
User-visible behavior changes / 用户可感知的行为变化:
sign verify failed(401) on D1 deployments.在 D1 部署下,下载文件与播放视频不再偶发
sign verify failed(401)。when a non-KV driver (e.g. D1) is correctly in use.
当正确使用非 KV 驱动(如 D1)时,不再出现"memory-only mode (data will not
persist)"这类令人误解的告警日志。
Important implementation changes / 重要实现变化:
Added
resolveSecretDriver(env)instore/json.ts, resolving the driver viagetStorageBackend()— the same path used for business data — so secrets arestored wherever the data is stored.
在
store/json.ts新增resolveSecretDriver(env),通过getStorageBackend()解析驱动(与业务数据同一路径),使密钥与数据存储在同一后端。
readPersistedSecret()now usesdriver.get(key, env);writePersistedSecret()now usesdriver.put(key, secret, env).readPersistedSecret()改用driver.get(key, env);writePersistedSecret()改用driver.put(key, secret, env)。Graceful degradation preserved: driver errors are caught; reads return
null,writes return
false; no new throw paths.保留优雅降级:驱动异常被捕获,读返回
null、写返回false,未新增抛错路径。Corrected the detector log wording and its
platformvalue ("Memory"→"none"), so a working D1/MySQL backend no longer appears to be losing data.修正探测日志措辞及其
platform取值("Memory"→"none"),使正常工作的D1/MySQL 后端不再显得"丢失数据"。
Rebuilt the EdgeOne artifact
cloud-functions/[[default]].jsvianode scripts/build-edge.mjs, per repository convention.按仓库惯例,用
node scripts/build-edge.mjs重新构建 EdgeOne 产物cloud-functions/[[default]].js。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:
Related Issues / 关联 Issue
Fixes #51
Root Cause / 根因分析
On a D1 deployment (the recommended EdgeOne setup),
getKvBinding()returns{ mode: "none" }, so the secret could neither be read nor written — whilebusiness data persisted to D1 normally. That is why data was in the database
yet the log claimed "memory-only mode".
在 D1 部署(EdgeOne 推荐配置)下,
getKvBinding()返回{ mode: "none" },密钥既读不到也写不进,而业务数据却能正常落入 D1 —— 这正是"数据已在数据库、
日志却称内存模式"的原因。
getJwtSecret()falls back to an in-process random key when no secret canbe persisted, so every cold start / instance generated a different HMAC key.
getJwtSecret()在无法持久化密钥时回退到进程内随机密钥,因此每次冷启动 /每个实例都会生成不同的 HMAC 密钥。
Download links are signed by one instance (
/fs/list,/fs/getinserver/fs.ts) and verified by another (/d,/pinserver/raw.ts).Different keys →
sign verify failed(401).下载链接由一台实例签发(
server/fs.ts的/fs/list、/fs/get)、另一台实例验签(
server/raw.ts的/d、/p)。密钥不一致 →sign verify failed(401)。This explains every symptom in issue #51 / 这解释了 issue #51 中的全部现象:
getKvBindingfails. / EdgeOne 无原生 KV 绑定,getKvBinding失败。getKvBinding ... memory-only/ 日志出现该提示Testing / 测试
go test ./...Automated checks run / 已执行的自动化检查:
npm run test:storenpm run test:regressnpm run test:servermainand unrelated (verified by stashing this change) / 3 项失败在干净main上同样失败,与本次改动无关(已用 stash 验证)npx tsc --noEmitnode scripts/build-edge.mjsNew tests / 新增测试:
secret persistence: write/read works with explicit DB_DRIVER=d1— uses anin-memory D1 stub (
createMockD1Binding) to assert a written secret isactually stored and read back under
DB_DRIVER=d1, and that a missing keyreturns
null.使用内存 D1 桩(
createMockD1Binding),断言在DB_DRIVER=d1下写入的密钥确实落盘并能读回,且缺失的键返回
null。secret persistence: degrades gracefully when no backend is available—asserts read →
null, write →falseinstead of throwing.断言无后端时读 →
null、写 →false,而非抛错。Regression test proves the bug / 回归测试有效性验证: stashing only
json.ts(i.e. reverting the fix) makes the new test fail with
not ok 10 - secret persistence: write/read works with explicit DB_DRIVER=d1; restoring the fixmakes it pass. The test therefore genuinely captures the defect rather than
documenting current behavior.
仅还原
json.ts(撤销修复)时新测试报not ok 10 - secret persistence: write/read works with explicit DB_DRIVER=d1;恢复修复后通过。因此该测试确实能捕获该缺陷,而非仅记录现状。
Manual verification steps for reviewers / 供审查者手动验证:
DB_DRIVER=d1and noJWT_SECRETset.在 EdgeOne 上以
DB_DRIVER=d1部署,且不设置JWT_SECRET。openlist_jwt_secretis now present in the D1kvtable and staysconstant across cold starts.
确认 D1 的
kv表中出现openlist_jwt_secret,且跨冷启动保持恒定。/fs/list→/d...across instances; expect no 401.跨实例反复触发
/fs/list→/d...,应不再出现 401。Checklist / 检查清单
/ 我已阅读 CONTRIBUTING。
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
gofmt,go fmt, orprettierwhere applicable./ 我已按适用情况使用
gofmt、go fmt或prettier格式化变更代码。/ 我已在适用情况下请求相关维护者或代码所有者审查。
AI Disclosure / AI 使用声明
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
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-Byattribution./ 我已确保所有 AI 辅助提交都包含
Co-Authored-By归属信息。I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。
Operational Note / 运维提示
Even with this change, deployments should set
JWT_SECRETto a stable value of32+ characters via the platform Secret store. Plain
varsinwrangler.jsoncare re-applied on every deploy and will overwrite console-set values; Secrets are
not. Setting
JWT_SECRETas a Secret both keeps the signing key constant andensures consistent at-rest field encryption.
即便有了本次修复,仍建议将
JWT_SECRET(32+ 字符)配置为平台 Secret。wrangler.jsonc中的普通vars每次部署都会重新写入,会覆盖控制台设置的值;Secret 不会。将
JWT_SECRET配为 Secret 既能保持签名密钥恒定,也能保证静态字段加密的一致性。
Scope / Follow-ups / 范围与后续
后端密钥持久化 + 误导性日志。
OpenList-ReactWebpreview components build/d${fsPath}without the user'sbase_path(
src/pages/preview/VideoPreview.tsx), unlike the official frontend'suseLink.ts. This can cause a different 401 (path mismatch, not keymismatch) for non-root
base_pathusers; worth a separate PR if confirmed.OpenList-ReactWeb的预览组件构造/d${fsPath}时未拼接base_path,与官方前端useLink.ts不一致;这可能对非根base_path用户造成另一类 401(路径不匹配而非密钥不匹配),确认后值得单独提 PR。
报告中提到的 503 问题本次有意不处理。