导入成功却只存本地,我在 Cloudflare Workers + R2 上踩的 3 个坑
- Vault Folio 的生产环境把静态资源、Cloudflare Access、Worker API 和 R2 分开,页面能打开不等于 API 已通过身份验证或已写入对象存储。
- 导入历史备份时不能把 expectedRevision 固定为 0。Worker 若校验 envelope.revision === expectedRevision + 1,Revision 35 的备份应发送 34。
- 浏览器扩展返回 net::ERR_BLOCKED_BY_CLIENT 时,前端若静默回退到 IndexedDB,会制造同步成功的错觉。生产模式应清晰展示失败原因和数据来源。
大家好,我是若风。
这次做一个本地优先的 Vault,架构听上去很顺手。浏览器负责加解密,Cloudflare Access 管入口,Worker 只收密文,R2 放版本历史。导入本地备份,输入密码,页面解锁成功,后面再改一条数据,看起来也一切正常。
只有一个问题,R2 桶是空的。
更让人崩的是,页面左下角显示「本地加密存储」。一开始我还以为是 R2 权限、Access 配错、或者对象列表延迟。最后才发现,这不是一个坑,是三个很小的坑按顺序叠起来了。
坦白讲,我盯着那个空桶愣了半分钟,还连续重试了 3 次导入。表面上看是 R2 没写进去,真正关键不是对象存储,而是请求在到达 R2 之前已经走歪了。
这篇把整个过程记下来。你要是也在做 Cloudflare Workers + Access + R2 的私有工具,希望能少绕几圈。
先交代一下 Vault Folio 在干什么
Vault Folio 不是一个把资产明细丢到服务器上算总额的记账 SaaS。它是给单个用户用的本地优先 Vault,记录账户、余额快照和净资产变化。主密码只在浏览器里参与解密,服务端拿不到明文,也不保存 Recovery Key。
数据在这里分成了两份。
浏览器侧的 IndexedDB 保存加密 envelope,离线时还能打开已有 Vault。生产环境的 Worker API 把同一份 envelope 写进 R2,按 Revision 留历史版本,再用 ETag 做乐观锁。Cloudflare Access 放在最外层,只允许指定账号访问自定义域名,Worker 再校验 JWT,避免有人绕过边缘规则直接打 API。
所以整条链路是这样。
主密码 → 浏览器解锁密文 → 修改数据后重新加密
↓
Worker 校验 Access JWT
↓
R2 保存 manifest 和 revisions/<n>.bin
这套设计有一个好处,云端只看见密文。也有一个很现实的代价,任何一环失败,页面仍然可能因为本地缓存而看上去正常。下面的坑,就是在这个「看上去正常」里冒出来的。
页面能打开,不等于 Worker 已经拿到 Access 身份
第一层误导来自浏览器。
我直接打开自定义域名,没有看到 Google 登录页,以为 Access 没生效。用一个不带 Cookie 的请求去看,根路径和 /api/v1/health 都是 302,跳到 Access 登录页。边缘保护其实一直在。
那为什么浏览器不问我登录?因为它已经有 CF_AppSession。Access 会话还在,浏览器自然直接放行。无痕窗口或者清掉站点 Cookie,登录页就出来了。
这里还有一个很容易漏掉的细节。Worker 不应该只相信边缘已经拦过,它仍然要校验 Access 注入的 Cf-Access-Jwt-Assertion。我一开始把 JWT 的 issuer 拼成了带末尾 / 的地址,结果是用户已经通过 Access,Worker 仍然报「未通过验证」。
const teamDomain = env.ACCESS_TEAM_DOMAIN.replace(/\/$/, "");
await jwtVerify(token, jwks, {
issuer: teamDomain,
audience: env.ACCESS_AUDIENCE,
});
这个地方别随手补 /。JWT 的 iss 是精确字符串匹配,团队域名应该和 token 里的值一字不差。
还有个成本问题。静态资源默认不需要经过 Worker,只有 /api/* 路径进 Worker 就够了。为了让静态页面也走 Worker 而把 run_worker_first 开成 true,会让每一个 CSS、JS、图片请求都产生 Worker 调用。真正应该保护整个站点的是 Access 应用策略,不是让 Worker 接管全部静态资源。
页面能打开,只能证明静态资源可用。API 能拿到合法 JWT,才算身份链路真的通了。
这不是「页面有锁」就够了,而是「每一层都知道自己在保护什么」。
导入备份不是创建新 Vault,它带着自己的 Revision
第二层坑更隐蔽。
正常创建一个 Vault,第一份 envelope 的 Revision 是 1,客户端发送 expectedRevision: 0,Worker 检查下面这个关系。
envelope.revision === expectedRevision + 1
这套乐观锁没问题。问题出在「导入」。
我导入的备份已经改过很多次,当前 Revision 是 35。前端为了做「云端为空时首次上传」,把 expectedRevision 固定成了 0。Worker 很老实地算了一遍,35 !== 0 + 1,返回了这个错误。
{"error":"密文 envelope 或 revision 无效"}
那一刻才明白,导入不是创建。备份不是一份没有历史的新数据,它已经带着版本号来了。
修复很小,但语义很重要。
export async function saveImportedVault(envelope: EncryptedVault) {
return saveEncryptedVault(
envelope,
envelope.revision - 1,
"imported-vault-must-be-new",
);
}
空云端允许它以 Revision 35 作为第一份远端版本。后面用户修改一次,Revision 才变成 36。
我还保留了一个保护。If-Match 传一个刻意不匹配的值,Worker 只会在云端没有 manifest 时接受这次导入。云端已经有 Vault,就返回冲突,不会因为一次本地文件导入把正在使用的云端数据静默覆盖掉。
这里的原则很简单,版本号描述的是数据历史,不是某台设备的上传次数。迁移、恢复、导入这些场景,都不能假设版本从 1 开始。
R2 一直为空,真正拦住请求的是浏览器扩展
Revision 修好后,页面仍然显示「本地加密存储」。这次我没有再猜 R2,而是直接看网络请求。
结果很戏剧性。
net::ERR_BLOCKED_BY_CLIENT
API 请求甚至没离开浏览器。广告拦截、隐私防护或者安全扩展把 /api/v1/vault 拦下来了,Worker 没收到请求,R2 当然也不会有对象。
前端当时的逻辑又做了一件看似贴心、实际很危险的事。除 Access 认证失败和 Revision 冲突外,其他错误都会回退到 IndexedDB。
try {
const response = await fetch("/api/v1/vault", options);
// 处理远端保存
} catch {
await cacheEncryptedVault(envelope);
return { source: "local" };
}
本地优先没有错。离线时还能继续工作,是好事。但生产环境里「浏览器把请求拦掉」和「网络暂时断了」不是同一种事情。前者需要清楚告诉用户,请求根本没到服务端;后者可以回退本地,但也应该让同步状态明显可见。
所以最终我把页面上的数据来源当成一个真正的状态,而不是一句装饰文案。
| 状态 | 含义 | 下一步 |
|---|---|---|
| 云端密文已同步 | Worker 已确认写入 R2 | 可以继续使用,仍建议导出离线备份 |
| 本地加密存储 | 数据只在当前浏览器的 IndexedDB | 检查网络、Access、扩展拦截,再重新同步 |
| 云端已有 Vault | 导入被保护策略拒绝 | 先确认是否应该覆盖,不能静默替换 |
扩展给站点放行后,再做一次「导入备份 → 输入密码解锁」,R2 里出现了两个对象。
vaults/<owner-key>/manifest.json
vaults/<owner-key>/revisions/35.bin
之后修改一条记录,新的 Revision 也能正常写入。到这里,导入、解锁、首次上云和后续更新才算真的闭环。
一份我现在会照着跑的检查清单
以后再部署这种私有 Web 应用,我会按这个顺序排查。
- 用无 Cookie 请求访问根路径和 API,确认它们都会被 Access 重定向。
- 登录后确认 Worker 收到
Cf-Access-Jwt-Assertion,并严格校验iss和aud。 - 看浏览器 Network,不要只看页面是否「解锁成功」。
ERR_BLOCKED_BY_CLIENT、401、400、409和412是完全不同的问题。 - 导入历史数据时保留原 Revision,首次上传的期望版本应为
revision - 1。 - 把「本地缓存」「云端已同步」「冲突未上传」变成明确的 UI 状态,别把失败悄悄吞掉。
- 最后去 R2 验证 manifest 和 revision 对象都出现,再相信页面上的「同步成功」。
写在最后
这次最有意思的地方,不是 Cloudflare 哪里配错了,而是每一层都给了一个很像成功的信号。
Access 会话让页面直接打开,导入和密码让 Vault 成功解锁,IndexedDB 让数据继续存在。每一步单独看都没错,连起来却让人误以为云端已经同步。
真正可靠的标准只有一个,服务端确认写入了密文,R2 里也能看到对应版本对象。
我倒是觉得,这比多写几个重试更重要。同步系统最怕的不是失败,而是失败以后还让人以为成功了。
希望这篇文章能帮你省掉几小时。尤其是当你看到「本地加密存储」时,先别急着怪 R2,打开 Network 看一眼,浏览器扩展也许正在替你做一个你并不想要的决定。

评论互动