Repository navigation
bug(plugin-auth): [auth] no cache service registered 在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 3, 2026 认领:PM 循环第 3 轮
会话:session_015Br2xsJsczFsTR9bvbh2Ny
分支:claude/issue-4772-auth-lazy-cache-resolve
Worktree:objectstack-issue-4772分诊:落地 objectstack(后端);PM 裁定取修法 2(惰性解析 cache),不取修法 1。
这里我和 #4771 走了不同的方向,理由是两者的风险面不同:
- 长远合理性 —— 修法 1(把探测推迟到
kernel:ready)只修日志。issue 自己指出了一个尚未证实的真降级:auth 在start()时一次性拍板「没有 cache」并缓存,那么即使 cache 随后注册上,限流这一侧是不是还捏着启动时那个进程内 store?如果是,这就不是误报,而是限流真的没用上共享 cache —— 多节点部署的限额从未被全局强制过,而日志还在告诉你「去接 Redis」。惰性解析同时关掉日志误报和这个功能洞;修法 1 只关掉前者,并且会让后者更难被发现(告警没了,人就不看了)。 - 防 AI 写错 —— 「宽容消费端」正是 AI 批量犯错被掩盖的温床:启动期取不到服务就静默退化成本地实现、然后永久保持,是这条反模式的教科书形态。惰性解析把「取服务」放回真正用到的那一刻,让声明的能力(共享限流)在运行时真正兑现。
要求 dev 先验证再修:确认
start()缓存的 cache 句柄是否真的一直被后续限流计数使用。这是决定本 issue 是「日志误报」还是「安全相关的功能洞」的分水岭 —— 结论请写进 PR 描述与报告。若证实是功能洞,顺手在 issue 上补security标签的建议一并说明。保留一条告警:当限流计数真正要用 cache 而此刻确实没有时,才喊 —— 那时它才是真信号。
维护者可否决本裁定;不阻塞派发。
Generated by Claude Code
- 长远合理性 —— 修法 1(把探测推迟到
PM 复核:ACCEPT(待 CI 全绿) —— PR #4788。已按 dev 的证实补上
security标签。我要求先验证的那一点:答案是「是」,这是功能洞
dev 给的是代码链条而不是推测,我逐环核过:
AuthPlugin.init()的getServiceAsync('cache')结果写进authConfig.secondaryStorage→ 存进AuthManager.this.config;- better-auth 实例虽然是懒创建的,但
createAuthInstance()读的是 init 时那份 config —— 懒创建冻结的是结论,不是时机,这是整件事最反直觉的地方,也是它一直没被发现的原因; init()早于CacheServicePlugin21ms,所以标准serve组合下永远走 else;- 于是落到 better-auth 默认的
rateLimit.storage: 'memory'—— 模块级 Map,每进程一份。
结论:多节点部署的限额从来没有被全局强制过,轮换节点即可把限额乘以节点数。而日志一直在告诉运维「去接 Redis」,接完仍是同一条 warn、同一个洞。这正是我在派发时判断「修法 1 只关掉告警会让问题更难被发现」的那个场景 —— 只是实际情况比我预期的更糟:洞是确凿的,不是「可能」。
更重要的是它顺带挖出的 #4785,而 dev 没有替你决定
issue 的直觉修法是「把 cache 真的接成
secondaryStorage」。dev 查了 better-auth 1.7.0-rc.2 的源码,发现那么做会在修一个安全洞的同时开另一个:createSession设了secondaryStorage就不写sys_session行,findSession命中缓存直接返回、根本不查库;而 ADR-0069 D4 的空闲超时 / 绝对上限 / 并发上限全靠写那一行来撤销会话。也就是说自动接上 cache 会静默废掉 D4 的三个管控,而且双写也救不了(读路径仍以缓存为准,撤销看起来生效、实际不生效)。这个冲突之所以长期隐形,恰恰因为那次探测从来没成功过。 一个 bug 把另一个更大的 bug 藏了起来 —— 这是本轮我见到的最有价值的发现。
dev 的处理我完全认可:不自动派生
secondaryStorage(回归「宿主显式提供才生效」,与今天标准组合下的实际运行时行为完全一致),把cacheSecondaryStorage()改为包根导出并就地写清代价,然后把「会话的记录之处到底在数据库还是缓存」作为架构决策单独开 #4785(needs-user-decision,三个选项 + 代价 + 建议),不自行决定。这是正确的边界感。其余复核
- 范围守住了:10 个文件全在
packages/plugins/plugin-auth(其中 3 个是新增的实现+测试),未碰objectql、service-storage、service-automation、plugin-approvals。805 行里 502 行是新文件和 changeset,真正的既有代码改动很小。 - ADR 改动我核了具体那一行:
docs/adr/0069的 P2 状态行此前写着 shared rate-limit + session store 已 landed —— 那是假的,而且假了很久。新行把它订正为事实并注明订正来源。这是修订状态行、不是推翻 D 行,docs/adr/也不是代码 PR 的禁区(禁的是content/docs/releases/)。这条改动本身就有独立价值:仓库此前一直在携带一句关于自己安全能力的虚假声明。 - 测试:632/632,typecheck 干净,另跑通 4 个仓内门禁。同样做了红→绿反向验证(stash 掉
auth-plugin.ts后 4 条新测试失败)。 - 一个我特别想表扬的判断:
rateLimitStorage被刻意放在AuthManagerOptions顶层而不是rateLimit里面,因为bindAuthSettings在管理员调限流参数时会整个替换rateLimit对象 —— 放进去就意味着「管理员一改设置,共享限流悄悄退回不共享」。这是个典型的、极难在事后发现的安全属性回退陷阱,dev 不但看见了,还用测试钉住了。
一个我看过但决定不拦的点
changeset 标
patch,而这次移除了「从 kernel cache 自动派生secondaryStorage」。理论上,若某个宿主的组合顺序恰好让 cache 先于 auth init 注册,它今天是绑上的,这次会被摘掉。但按 #4785 的分析,那种绑上恰恰意味着 D4 三个会话管控在静默失效 —— 摘掉是安全修复而不是能力回退,且宿主显式提供的secondaryStorage行为不变。所以patch站得住,不要求改。CI 仍在跑,全绿后标 ready 并送合并队列。
Generated by Claude Code
验证结论:是功能洞,不只是日志误报 → 建议补
security标签PR: #4788(draft)
init()时定下的「没有 cache」结论会被冻结整个进程:AuthPlugin.init()的探测结果写进authConfig.secondaryStorage→new AuthManager(authConfig)→this.config;- better-auth 实例确实是懒创建的,但
createAuthInstance()读的是 init 时那份 config(...(this.config.secondaryStorage ? { storage: 'secondary-storage' } : {}))—— 懒创建没有让探测重来一次,冻结的是结论; init()早于CacheServicePlugin注册cache(本 issue 现场早 21ms),所以标准serve组合下这个分支永远走 else;- 结果 better-auth 落到默认
rateLimit.storage: 'memory'—— rate-limiter 模块里那个模块级 Map,每进程一份。
即:多节点部署的限额从来没有被全局强制过(轮换节点可把限额乘以节点数),而日志还在指路「去接 Redis」,接完仍是同一条 warn、同一个洞。
一个必须一并知道的连带发现
「把 cache 真的接成
secondaryStorage」这条直觉修法不能用:better-auth 1.7 的createSession在有secondaryStorage时不写sys_session行,findSession直接从缓存快照作答、不查库;而 ADR-0069 D4 的空闲超时 / 绝对上限 / 并发上限全靠写这一行来撤销会话。自动绑定会静默废掉这三个管控(即使storeSessionInDatabase双写也一样,读路径以缓存为准)。所以 PR 里限流计数器走 better-auth 的
rateLimit.customStorage(counters-only,惰性解析 cache),会话存储保持今天的实际行为不变,「会话的记录之处该在哪」作为架构决策单独开了 #4785(needs-user-decision),未自行决定。
Generated by Claude Code
CI 全绿(20/20),PR #4788 已标 ready 并送入合并队列。
补一条 dev 报告里记录、但没有立单的发现,已由 PM 代立为 #4790:
AuthManager.getOtpSendGuard()实现的 #2780「每号码 OTP 发送预算」也只在宿主显式提供secondaryStorage时才跨节点共享,默认组合下同样是进程内计数。这与本 issue 修掉的洞是同一类,但是独立的一处 —— 本 PR 走的是新的rateLimit.customStorage,OTP 预算是AuthManager自己的另一套计数,行为未被本 PR 改变。按 Prime Directive #10,超范围发现应当立成未认领 issue;这条只写在报告和 PR 描述里,没有落到 GitHub 上,那样它会随着这轮会话一起消失。已补立 #4790(
pm:queue,security),要求接手者先独立核实代码路径再动手(这条是顺带观察,dev 和我都没有单独验证过getOtpSendGuard()的存储取自哪里),并明确要求不要用secondaryStorage修它 —— 那会踩 #4785 记录的 D4 地雷,必须走本 PR 建好的cache惰性解析路径。顺带指出它比限流洞更直接带成本:每次 OTP 发送是真金白银,预算按节点数倍增意味着 N 节点部署的实际上限是声明值的 N 倍。#2814(短信发送配额,成本总量闸)如果建立在同一套进程内计数上,上线即失效 —— 已在 #4790 里要求一并核对。
Generated by Claude Code
✅ PR #4788 已合并。
落地内容:限流计数器改为惰性解析 kernel cache(
rateLimit.customStorage),计数器被消费时才取服务 —— 那一刻必然晚于kernel:ready,因此与插件启动顺序无关。这次修的不只是那条误导性的启动日志。 复核确认的功能洞是真的:
init()时定下的「没有 cache」结论会被冻结整个进程(better-auth 实例懒创建,但读的是 init 时那份 config —— 冻结的是结论,不是时机),于是限流一直落在进程内的模块级 Map 上,多节点部署的限额从来没有被全局强制过。而content/docs/permissions/authorization.mdx早就写着这个能力「landed」—— 那句话从今天起才第一次为真。顺带落地的还有 ADR-0069 P2 状态行的事实订正(此前声称 shared session store 已 landed,实际从未生效)。
两条衍生单待处理:
- decision(plugin-auth): 会话的「记录之处」到底在哪 —— better-auth secondaryStorage 一旦接上 cache,ADR-0069 D4 的会话管控就静默失效 #4785(
needs-user-decision)—— 会话的「记录之处」在数据库还是缓存。⚠️ 注意本 PR 已用测试钉住了现状(选项 A:会话留sys_session),将来若改方向需先改那条测试。 - 每号码 OTP 发送预算(#2780)也只在进程内计数 —— 与 #4772 的限流洞同类,多节点下可按节点数倍增 #4790(
pm:queue)—— 每号码 OTP 发送预算同属这一类,仍是进程内计数,行为未被本 PR 改变。现在 fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 #4788 已合并,它的阻塞解除,可以派发了 —— 修法直接复用本 PR 建好的createLazyCacheRateLimitStorage()与incrementFixedWindow。
Generated by Claude Code
- decision(plugin-auth): 会话的「记录之处」到底在哪 —— better-auth secondaryStorage 一旦接上 cache,ADR-0069 D4 的会话管控就静默失效 #4785(
- added 4 commits that reference this issue
on Aug 3, 2026
TL;DR
pnpm dev(showcase)每次启动都报一条"没有 cache 服务,限流退化成进程内内存计数器,多节点部署需要 Redis"。但CacheServicePlugin就在 21 毫秒后注册好了,而且它本来就在已加载插件列表里。和 #4771 是同一类问题(校验/告警跑在提供方注册之前),但在另一个子系统,修法也不同,所以单开。
现场
启动摘要里的已加载插件:
证据
--log-level info的时间戳:WARN [auth] no cache service registered04:28:04.527INFO Service 'cache' registered {"service":"cache"}04:28:04.548INFO CacheServicePlugin: registered memory cache adapter04:28:04.548差 21ms。
为什么值得修
告警本身的建议("多节点需要 Redis")在这个语境下是错的指路:这个部署确实配了 cache 插件,缺的不是 Redis,是"auth 在 cache 注册完之前就问了"。照着这条日志去接 Redis,接完还是会看到同一条 warn。
同 #4771:真正没配 cache 服务的部署会得到一模一样的一条 warn,两种情况无法区分。
修法(择一)
kernel:ready,那时服务注册表才是完整的 —— 与 bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 同一思路。start()时一次性拍板并缓存"没有 cache",而是在真正用限流计数器时再取一次服务。这样也顺带修掉真正的功能退化 —— 目前即使 cache 后来注册上了,auth 这一侧是不是还捏着启动时那个进程内存储?这一点我没验证,但如果是,那就不只是日志误报,而是限流真的没用上共享 cache。值得在修的时候一并确认。复现
rm -rf examples/app-showcase/.objectstack && pnpm dev看时序:
objectstack dev --seed-admin --log-level info,对比上表两个时间戳。