fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790) - #4806
Merged
Conversation
…store (#4790) #2780's per-number OTP budget (60s cooldown + 5/hour) was shared across nodes ONLY when a host supplied better-auth's `secondaryStorage`. Nothing in the standard `serve` composition supplies one — and since #4788, AuthPlugin deliberately does not derive it from the kernel cache either — so the budget was counted per process: an N-node deployment granted one phone number N cooldowns and N hourly caps, in paid SMS, with no signal that the declared limit was not the enforced one (ADR-0049). Same defect class as #4772's rate-limit counters, and now the same cure rather than a second implementation of it. The lazy-resolution half of `createLazyCacheRateLimitStorage` is extracted as `createLazyCounterStore()`: resolve the `cache` service when a counter is CONSUMED (strictly after `kernel:ready`, so plugin start order decides nothing), memoise the handle, fall back to the bounded in-process store when there is genuinely no cache — and say which of the two happened, once. The OTP guard reaches it through the new `AuthManagerOptions.sharedCounterStore`, filled by AuthPlugin from the same `resolveCache` closure the rate-limit counters use. Deliberately NOT `secondaryStorage` (#4785): that also relocates the session of record into the cache and silently disables the ADR-0069 D4 session controls. A host-supplied `secondaryStorage` still wins for this budget, unchanged. The cooldown / rolling-hour semantics are untouched — only where the timestamps live changed. A fixed-window counter cannot express "N seconds since the last send", and converting the hourly cap to one would admit a 2× burst across the window boundary: trading one multiplication for another. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
This was referenced Aug 3, 2026
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 3, 2026 08:08
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4790
一、先核实(PM 指令 #1):issue 成立,现象与描述一致
按要求先验证再动手,结论是该洞真实存在,且存储来源确实就是
AuthManagerOptions.secondaryStorage:唯一存储来源 ——
AuthManager.getOtpSendGuard()(auth-manager.ts,改动前)构造OtpSendGuard时,只有这一处会传 store:OtpSendGuard内部load()/save()是二选一分支:有storage走共享 KV,没有就落到实例内的private readonly local = new Map()。没有第三条路径,也没有任何地方从cache服务取过它。默认组合下确实为空 —— 全仓检索
secondaryStorage的赋值点,packages/plugins/plugin-auth/src之外只剩各包的CHANGELOG.md命中,没有任何宿主代码提供它;packages/cli/src/commands/serve.ts的new AuthPlugin({...})不传;而 fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 #4788 之后AuthPlugin.init()明确不再从 kernel cache 派生secondaryStorage(注释写明:那会把会话的记录之处搬进缓存,废掉 ADR-0069 D4)。所以标准serve组合下storage === undefined,预算落在每进程一份的localMap 里。无任何信号 —— 这条路径上一句 warn 都没有,配置里能写每号码预算,多节点下不兑现,而运维看不到任何提示(ADR-0049 声明 ≠ 强制)。
确属 fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 #4788 未覆盖的独立一处 —— fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 #4788 改的是 better-auth 的
rateLimit.customStorage;OTP 预算是 ObjectStack 自己在AuthManager里的另一套计数,行为未被 fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 #4788 改变。结论:issue 不需要关单,按原方向修。
二、修法:复用 #4788 那条路径,不写第二份
rate-limit-storage.ts里把「惰性解析 → 绑定即宣告 → 解析不到就降级到有界进程内存储并响亮告警」这半边抽成createLazyCounterStore();createLazyCacheRateLimitStorage()现在是它的一层薄封装(行为、日志文案对限流侧保持不变,#4788 的断言原样通过)。OTP 预算经新增的AuthManagerOptions.sharedCounterStore接同一条路径,由AuthPlugin用同一个resolveCache闭包填充。secondaryStorage(PM 指令 Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3 / decision(plugin-auth): 会话的「记录之处」到底在哪 —— better-auth secondaryStorage 一旦接上 cache,ADR-0069 D4 的会话管控就静默失效 #4785):走cache惰性解析,会话的记录之处不动。宿主自己显式提供的secondaryStorage对这个预算仍然优先,行为不变。InProcessCounterStore:删掉了 guard 自己那份localMap + 手写剪枝,只剩一个有界 fallback 实现。packages/plugins/plugin-auth,packages/spec/**零改动。一处对 PM 指令 #2 的偏离,请复核
指令说复用
createLazyCacheRateLimitStorage()/incrementFixedWindow。我复用了前者的解析路径(抽出共用),但没有把 OTP 的计数改成incrementFixedWindow的定窗计数,理由:所以时间戳滚动窗口的算法原样保留,只换了它所在的存储——这正是本单的缺陷所在。若维护者更希望统一到定窗计数(接受上述语义变化),这部分可以单独再改。
三、验收对应
otp-send-guard.test.ts"is ONE budget across nodes when the cache is shared":两个 guard(两套 resolver)共用一个 cache,A 发过之后 B 立刻被拒;小时额度也是一份auth-plugin.test.ts"counts the budget in a cache registered AFTER auth init":init 时cache不在注册表,之后再注册,第一次发送就落进共享 store(phone-otp-sends:+86...),并打出 bound 的 infoauth-plugin.test.ts"warns loudly at counting time…":第二次发送仍被 429;warn 只在真正计数时打一次,含PAID SMS与no cache service registered at all四、#2814 短信配额闸的核对(PM 指令 #4)
不是同一套计数,无需另立 issue。
packages/services/service-sms/src/下(sms-service.ts/sms-plugin.ts/transports/)检索quota/daily均无命中,#2814 目前尚未实施——daily_quota/daily_quota_per_tenant都还不存在,因此不存在「建立在进程内计数上、上线即失效」的第二个闸。本 PR 抽出的createLazyCounterStore()正好是 #2814 落地时该用的那条路径(#2814 正文第 4 点要求的就是这个降级策略)。五、验证
已加 changeset:
.changeset/auth-otp-budget-shared-counter-store.md。Generated by Claude Code