Repository navigation
feat(sms): 短信全局/每租户日发送配额(成本总量闸) #2814
Description
Activity
- addedenhancementNew feature or requestNew feature or request
on Jul 11, 2026 交叉说明(来自 #4790 的核对,非认领):
修 #4790(每号码 OTP 预算只在进程内计数)时按要求核对了本单的「日发送配额闸」是否也建立在同一套进程内计数上 —— 不是:
packages/services/service-sms/src/下检索quota/daily均无命中,本单尚未实施,daily_quota/daily_quota_per_tenant都还不存在,所以没有「上线即失效」的既有闸需要修。给将来实施本单的人一个现成件:本单诉求第 4 点(「单节点内存、集群复用 cache,存储故障 fail-open + 告警」)现在有一条已建好的路径,直接接上即可,不要再写第三份计数:
packages/plugins/plugin-auth/src/rate-limit-storage.ts的createLazyCounterStore({ resolveCache, logger, subject, degradedImpact })—— 计数被消费时才解析 kernelcache服务(因此与插件启动顺序无关,这正是 bug(plugin-auth):[auth] no cache service registered在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 踩过的坑),解析不到就降级为有界的进程内InProcessCounterStore并按subject点名打一次 warn(绑定成功则打 info,两种情况在日志里可区分);- 若是「每日 N 条」这类纯定窗计数,同文件的
incrementFixedWindow()就是共用的计数算法(better-auth 的rateLimit.customStorage与secondaryStorage.increment已经共用它)。
参考:#4772 → PR #4788,#4790 → PR #4806。
Generated by Claude Code
存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队。前提经 origin/main 核实仍成立:service-sms 全无 daily_quota 实现,per-号码闸已建而总量成本闸为零——属已发布能力上的防滥用补墙,非投机扩面;计数通道(
incrementFixedWindow)现成可接(#4790 评论同证)。维护者可否决。
Generated by Claude Code
认领:PM 循环第 4 轮(services 车道)
会话:session_015a5qkLzpGXhLL2F5gvJ7dD
分支:claude/issue-2814-sms-daily-quota
Worktree:objectstack-issue-2814
域:domain:services
文件面:packages/services/service-sms/src/**(统一扣减点 + 配额逻辑 + 测试)、packages/services/service-settings/src/manifests/sms.manifest.ts(新增daily_quota/daily_quota_per_tenant键)+ changeset;⛔ 不触packages/plugins/plugin-auth(identity 车道 —— 复用其计数件的依赖方向若不干净,报 fork 而非动它);⛔ 不触packages/spec
串行约束已清:#5773→PR #5982 已 MERGED(其契约测试断言面 scope 到key === 'provider',新增 number 键结构上不相撞);同包无其它在飞;基于合并后 origin/main派发要点:复用
incrementFixedWindow/createLazyCounterStore路径(#4790 交叉说明,⛔ 不写第三份计数);#5932 已知事实:manifest 声明的 min/max 今天不被 validatePatch 执行,配额值的钳制须在消费侧自行做。派发令随后。
Generated by Claude Code
验收:部分 ACCEPT + 升级(services 座位)。
已验收合入(诉求 1/3/4/5 + 半个 2):PR #6042(CI 24/24 绿,已转 ready 入队)——
- 全局
daily_quota落在SmsService.send()统一扣减点,OTP/邀请/notify 三路同账;UTC 自然日双机制翻窗;fail-open + 点名告警;80%/超限 WARN 去重且 meta 无敏感子串; - 复用
incrementFixedWindow/createLazyCounterStore(零第三份计数;依赖方向经实测 + 既成先例 + Validate Package Dependencies 门确认),⛔ 未动 plugin-auth; - messaging channel
classifyError恒定'retryable'→ 识别配额码返回'rate_limited'(退避阶梯不变,不静默丢); - settings 写入路径只执行 required / options / pattern —— 声明的 min / max / minLength / maxLength 一条都不校验(auth.password_min_length 可被写成 1) #5932 计入:配额值消费侧钳制(负值/NaN → 0=不限,点名 warn),理由与 membershipPolicy 无法作为平台设置配置,且注册路径与回填路径读的是两个来源 #5152 教训写进注释;
- 必答项已核:number 键不涉 spec: SettingsManifest 的 SpecifierSchema 新增
valueDomain闭合枚举 —— 声明存在时标准域为执行边界,options 退化为 UI 便利列表(#5712 裁决的 spec 半边) #5933 / localization 的 timezone / currency 用「策展式 options 表」当穷尽执行边界:合法的 IANA 时区与 ISO 4217 币种被拒 #5712,env 门(OS_SMS_DAILY_QUOTA)自动生效且三测钉住。 - 衍生:fix(plugin-auth): 短信日配额拒发时 OTP 端点回 500 而非 429 —— deliverPhoneOtp 抛的是普通 Error #6039(OTP 配额拒发回 500 非 429 —— better-call 对非 APIError 一律 500,行号级量测;修复在 plugin-auth,identity 车道领地)、[finding] 共用定窗计数件住在 plugin-auth 里,消费方要为 90 行计数背上整个 better-auth #6040(finding:计数件住 plugin-auth 且急切拖入 better-auth 全家,架构负债)。
⚠️ 本 PR 带Fixes #2814,合并会自动关单;届时本座位立即重开本单以承载下面的待决半边 —— 关闭/重开是刻意操作,不是误触。
升级维护者(
needs-user-decision):诉求 2「每租户日配额daily_quota_per_tenant」如何处置?dev 停手上报的结构性事实(实测,非估计):
SendSmsInput(spec 契约)不携带任何租户标识,服务层也无环境态当前 org(tenancy 服务给姿态不给 org)—— 租户标识要进services.sms.send这条缝,干净落点在 spec;- OTP 发送发生在认证之前,彼时不存在 organizationId —— 租户维度天然只能覆盖可归属发送(今天仅 messaging channel 的
Notification.organizationId),而 OTP 恰是短信成本大头,无论选哪条路都进不了租户账。
选项(三轴分析全文见 PR #6042 正文「未实现」节):
- A spec 的
SendSmsInput加可选organizationId(contract-first,转 spec/identity 座位;AI 防错最好;代价是 spec 面全套流程,且只有 notify 一个生产者); - B service 级影子字段 —— ⛔ 反对(PD Add comprehensive test suite for Zod schema validation #12 明令的第二套事实契约);
- C 只保留全局闸(已合入的现状),租户维度不声明 —— 本席与 dev 共同推荐:一个只统计 notify 的
daily_quota_per_tenant会让运营者误以为给租户封了顶,正是 ADR-0049「declared ≠ enforced」形状;宁可不声明,不声明一个兑现不了的上限; - D 闸下沉 messaging channel —— ⛔ 反对(拆掉统一扣减点,违背本单主旨)。
建议 C;若您要 A,请一并把 #6039(OTP 429 语义)排进 identity 车道 —— 两件事同落 auth↔sms 缝。在本单评论回复即生效;不回复则维持 C(全局闸已闭合成本洞,标题诉求已达)。
经办:services 座位,会话
session_015a5qkLzpGXhLL2F5gvJ7dD(第 4 轮)。
Generated by Claude Code
- 全局
队列管家通知(新签名拦截,第 16 轮):本单的 PR #6042 于 20:13:47Z 作为队首被移出合并队列,红因与 diff 无关、run 内零测试失败 —— 是 #6082 那条门禁缺口(队列重建后聚合读数
abandoned落进ci.yml白名单之外)的第 4 例。完整签名与逐 job 归档见 PR #6042 上的拦截评论。
内容侧无待办,验收结论不受影响。该签名未进 #5810 台账(⛔ 只有人工可升级),故本座位 ⛔ 未代为重投;重投时机与决定权在 services 车道 —— 另两条车道已各自重投过同族踢出(#6010 / #6012),本座位对其均已让行。⛔ 重跑无效(复用原合并 ref),解法是重新入队。
Generated by Claude Code
Closing (2026-08-07): the headline ask shipped; the remaining half is ruled C — do not declare it.
The global daily SMS cap landed with PR #6042 (
sms-daily-quota.ts, thedaily_quotamanifest key with min:0 and three translations, theOS_SMS_DAILY_QUOTAenv gate), and the derived issue #6039 (quota refusal returning 500) closed via PR #6092.Per-tenant quota: ruled C — not declared.
SendSmsInputcarries no tenant identity at all, and OTP — which is the bulk of SMS spend — happens before authentication, so it has noorganizationIdto attribute. Adaily_quota_per_tenantkey would therefore cap only the notify path while appearing to cap everything: declared ≠ enforced, the exact shape ADR-0049 exists to prevent. The reasoning is already documented in-code atsms-daily-quota.ts:61-66so the next reader doesn't file it as an oversight.If tenant-level quotas are ever genuinely needed, the route is spec-first: add tenant identity to the
SendSmsInputcontract, then the quota key becomes enforceable. That is a new issue, not this one.Operator: PM session
session_01GcjbQLUQKysMU9uXB34iyv; maintainer ruling 2026-08-07 (decision-inbox round 2). Veto window open — comment or reopen to overturn.
Generated by Claude Code
背景
#2780(PR #2790/#2797)为 OTP 端点落了按号码维度的防滥用:60s 冷却 + 每号码 5 条/小时(
hooks.before准入层,集群经 secondaryStorage 共享),外加 per-IP 限流。但 SMS pumping 的总量维度仍然缺失:攻击者轮换大量不同号码时,每个号码都在自己的预算内,日累计成本没有上限;notify(channels:['sms'])走 messaging channel 的发送也完全不经过任何配额。诉求
sms设置命名空间新增daily_quota(0 = 不限),对经过services.sms.send的所有发送计数(OTP、邀请、notify channel 统一在 service 层扣减,而不是只挡 auth 端点)。daily_quota_per_tenant,按organizationId维度计数。429 TOO_MANY_REQUESTS(不泄露配额剩余细节);SendResult.ok=false+classifyError='rate_limited'(进 outbox 重试/死信,不静默丢)。相关
packages/plugins/plugin-auth/src/otp-send-guard.ts)packages/services/service-sms/src/sms-service.ts(建议的统一扣减点)