Skip to content

fix(store): persist secrets through the unified driver layer - #57

Merged
PIKACHUIM merged 1 commit into
mainfrom
fix/d1-secret-persistence
Sep 16, 2026
Merged

PIKACHUIM merged 1 commit into
mainfrom
fix/d1-secret-persistence

Conversation

@PIKACHUIM

@PIKACHUIM PIKACHUIM commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

fix(store): persist secrets through the unified driver layer

Base: main ← Compare: fix/d1-secret-persistence
Commit: 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 video
playback 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() (supports
D1 / MySQL / DO / KV / Blob), while secrets went through getKvBinding() — a
detector 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 / 用户可感知的行为变化:

  • File downloads and video playback no longer intermittently fail with
    sign verify failed (401) on D1 deployments.
    在 D1 部署下,下载文件与播放视频不再偶发 sign verify failed(401)。
  • The alarming "memory-only mode (data will not persist)" log no longer appears
    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) in store/json.ts, resolving the driver via
    getStorageBackend() — the same path used for business data — so secrets are
    stored wherever the data is stored.
    在 store/json.ts 新增 resolveSecretDriver(env),通过 getStorageBackend()
    解析驱动(与业务数据同一路径),使密钥与数据存储在同一后端。

  • readPersistedSecret() now uses driver.get(key, env);
    writePersistedSecret() now uses driver.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 platform value ("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]].js via
    node 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:

  • OpenList-Frontend:
  • OpenList-Docs:

Related Issues / 关联 Issue

Fixes #51

Root Cause / 根因分析

signDownloadPath()  ─┐
verifyDownloadSign()─┴─► getJwtSecret() ─► env.JWT_SECRET (>=32 chars)?
                                            └─ no ─► readPersistedSecret()
                                                       └─► getKvBinding()   ← no D1 branch!
                                                             └─ fails on D1
                                                                └─► in-process random key per instance
  1. On a D1 deployment (the recommended EdgeOne setup), getKvBinding() returns
    { mode: "none" }, so the secret could neither be read nor written — while
    business 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 —— 这正是"数据已在数据库、
    日志却称内存模式"的原因。

  2. getJwtSecret() falls back to an in-process random key when no secret can
    be persisted, so every cold start / instance generated a different HMAC key.
    getJwtSecret() 在无法持久化密钥时回退到进程内随机密钥,因此每次冷启动 /
    每个实例都会生成不同的 HMAC 密钥。

  3. Download links are signed by one instance (/fs/list, /fs/get in
    server/fs.ts) and verified by another (/d, /p in server/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 中的全部现象:

Observation / 现象 Explanation / 解释
Both users and guests fail / 用户与访客都失败 Signing key is global, not per-user. / 签名密钥是全局的,与用户无关。
Maintainer cannot reproduce / 维护者无法复现 Cloudflare KV persists the secret, masking the bug. / Cloudflare 有 KV 持久化,掩盖了该问题。
Reporter runs EdgeOne + D1 / 报告者为 EdgeOne + D1 EdgeOne lacks a native KV binding → getKvBinding fails. / EdgeOne 无原生 KV 绑定,getKvBinding 失败。
Log shows getKvBinding ... memory-only / 日志出现该提示 Fallback branch of the same broken detector. / 同一探测器的降级分支。
Intermittent / "fixed" by redeploy / 偶发、"重新部署即好" Depends on which instance signs vs. verifies, and whether a random key was regenerated. / 取决于签发与验签的实例,以及随机密钥是否被重新生成。

Testing / 测试

  • go test ./...
  • Manual test / 手动测试:

This repository is TypeScript; the Go checklist item above is not applicable.
本仓库为 TypeScript,上方的 Go 检查项不适用。

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

Command / 命令 Result / 结果
npm run test:store 11/11 pass
npm run test:regress 71/71 pass — ALL PASS
npm run test:server 37/40 pass — the 3 failures are pre-existing on clean main and unrelated (verified by stashing this change) / 3 项失败在干净 main 上同样失败,与本次改动无关(已用 stash 验证)
npx tsc --noEmit 0 errors
node scripts/build-edge.mjs Build OK

New tests / 新增测试:

  • secret persistence: write/read works with explicit DB_DRIVER=d1 — uses an
    in-memory D1 stub (createMockD1Binding) to assert a written secret is
    actually stored and read back under DB_DRIVER=d1, and that a missing key
    returns null.
    使用内存 D1 桩(createMockD1Binding),断言在 DB_DRIVER=d1 下写入的密钥确实
    落盘并能读回,且缺失的键返回 null。
  • secret persistence: degrades gracefully when no backend is available —
    asserts read → null, write → false instead 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 fix
makes 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 / 供审查者手动验证:

  1. Deploy to EdgeOne with DB_DRIVER=d1 and no JWT_SECRET set.
    在 EdgeOne 上以 DB_DRIVER=d1 部署,且不设置 JWT_SECRET。
  2. Confirm openlist_jwt_secret is now present in the D1 kv table and stays
    constant across cold starts.
    确认 D1 的 kv 表中出现 openlist_jwt_secret,且跨冷启动保持恒定。
  3. Repeatedly trigger /fs/list → /d... across instances; expect no 401.
    跨实例反复触发 /fs/list → /d...,应不再出现 401。

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.
    / 我已按适用情况使用 gofmt、go fmt 或 prettier 格式化变更代码。
  • 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 辅助内容。

Operational Note / 运维提示

Even with this change, deployments should set JWT_SECRET to a stable value of
32+ characters via the platform Secret store
. Plain vars in wrangler.jsonc
are re-applied on every deploy and will overwrite console-set values; Secrets are
not. Setting JWT_SECRET as a Secret both keeps the signing key constant and
ensures consistent at-rest field encryption.

即便有了本次修复,仍建议将 JWT_SECRET(32+ 字符)配置为平台 Secret。
wrangler.jsonc 中的普通 vars 每次部署都会重新写入,会覆盖控制台设置的值;
Secret 不会。将 JWT_SECRET 配为 Secret 既能保持签名密钥恒定,也能保证静态字段
加密的一致性。

Scope / Follow-ups / 范围与后续

  • In scope / 本 PR 范围: backend secret persistence + misleading log.
    后端密钥持久化 + 误导性日志。
  • Not in scope / 不在本 PR 范围: OpenList-ReactWeb preview components build
    /d${fsPath} without the user's base_path
    (src/pages/preview/VideoPreview.tsx), unlike the official frontend's
    useLink.ts. This can cause a different 401 (path mismatch, not key
    mismatch) for non-root base_path users; worth a separate PR if confirmed.
    OpenList-ReactWeb 的预览组件构造 /d${fsPath} 时未拼接 base_path,与官方前端
    useLink.ts 不一致;这可能对非根 base_path 用户造成另一类 401(路径不匹配
    而非密钥不匹配),确认后值得单独提 PR。
  • The reported 503 issue is intentionally not addressed here.
    报告中提到的 503 问题本次有意不处理。

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.
@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 63b48df Sep 14 2026, 10:07 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 63b48df Sep 14 2026, 10:07 AM

@PIKACHUIM PIKACHUIM linked an issue Sep 14, 2026 that may be closed by this pull request

@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 — 根因诊断清晰,修复精准完善,单元测试覆盖充分

📖 概要

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(核心修复)

改动意图:统一密钥与业务数据的持久化驱动、修正日志
代码逻辑:

  1. 新增 resolveSecretDriver(env) 复用 getStorageBackend() 解析驱动
  2. readPersistedSecret() 改用 driver.get(key, env) 替代 getKvBinding()
  3. writePersistedSecret() 改用 driver.put(key, secret, env) 替代 getKvBinding()
  4. getKvBinding() 日志修正:从「memory-only mode (data will not persist)」改为「DB_DRIVER 已配置,业务数据由 getStorageBackend() 处理」
  5. 修正 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 — 根因分析透彻、修复设计完善、测试覆盖充分,可直接合并

@PIKACHUIM
PIKACHUIM merged commit 0d09a1e into main Sep 16, 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.

[BUG]使用Cloudflare部署的几个问题

2 participants