feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814) - #6042
Conversation
按号码闸(#2780)挡的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」: 轮换上万个号码时每个都待在自己预算里,日账单没有上限;而那道闸住在 better-auth 的 hooks.before 里,只看得见 auth 端点,notify(channels:['sms']) 与邀请短信从旁边 走过去,一条都不计数。 本次把总量闸的扣减点放在所有出站短信本来就必经的 SmsService.send():OTP、邀请、 messaging sms channel 三条路记在同一本账上。 - sms 命名空间新增 daily_quota(number,默认 0 = 不限),env 面沿用既有每键机制 (OS_SMS_DAILY_QUOTA),已实测覆盖生效且按 default 的类型强制为 number。 - 计数复用仓内唯一那份定窗计数(incrementFixedWindow)与惰性存储解析 (createLazyCounterStore,#4772/#4790),不写第三份;解析不到 cache 时降级为 有界进程内计数并点名 warn。 - 窗口是 UTC 自然日,由计数键上的日期与「距下一个 UTC 午夜的秒数」双重保证。 - 超限:OTP/邀请路径回 status='failed' + TOO_MANY_REQUESTS(不含剩余额度); messaging channel 的 classifyError 由恒定 'retryable' 改为识别该码返回 'rate_limited',进 outbox 退避重试/死信而非静默丢。 - fail-open:计数存储读不到时放行并打一次 warn —— 短信成本闸不能把登录拖下水。 - 配额值的钳制在消费侧(#5932:manifest 的 min 今天不被 validatePatch 执行): 负值/NaN/Infinity/非数字一律降级为 0(不限)并点名 warn。 不含每租户维度(daily_quota_per_tenant):SendSmsInput 不携带租户标识,在 service 侧另造一个只此一家的拼法就是 Prime Directive #12 的影子契约 —— 留给维护者裁定。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31119799271 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31120013500 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
队列管家拦截(
|
| run / job | 结束 | 终报错 |
|---|---|---|
CI 31126905027 → Dogfood Regression Gate |
20:13:44Z | dogfood matrix aggregate result: abandoned → ::error::Gate leg dogfood did not pass (result: abandoned) |
| Lint & Type Check 31126905018 | 20:04:13Z | 零 job 级 failure —— ESLint / TypeScript Type Check 两个 job 均为 cancelled,run 级 failure 由生命周期状态产生 |
CI run 共 14 个 job:11 success/skipped + 2 个 dogfood 分片 cancelled(20:09:43Z / 20:12:17Z)+ 1 个聚合门禁 failure。分片是被队列重建取消的,聚合读数落进 ci.yml 白名单(success|skipped|cancelled)之外的 *) 兜底 ⇒ 判红 ⇒ 踢出。零测试失败(「completeness / 聚合绿」≠「测试通过」的对偶:这里是假红,同 note 7 纪律,逐 job 核过结论而非读 tail)。
判定:命中 #6082 记录的门禁缺口族,该族第 4 例
| PR | abandoned 判红 |
后果 |
|---|---|---|
| #6010 | 17:32:13Z | 被踢(第 14 轮拦截;identity 车道 20:05Z 已自行重投) |
| #6012 | 18:54:06Z | 被踢 → devx 车道 43 秒后自行重投(第 15 轮让行) |
| #6013 | 19:06:15Z | 未被踢(仍在链上) |
| #6042(本 PR) | 20:13:44Z | 被踢 |
该签名至今未进 #5810 签名台账 —— 台账 ⛔ 只有人工可升级,本座位第 14/15 轮的两次提请仍待裁定。因此按四分支纪律仍判为新签名 ⇒ ⛔ 本座位不代为重投(试点判据 2:宁可不重投,不可错重投)。
给 services 车道(#2814)的建议动作
- PR 本体无辜:本次红的全部证据都在队列重建的生命周期面,内容侧无待办;
- 重投是车道的授权面,不是本座位的 —— 两条车道已各自按自己的判断重投过同族踢出(identity 20:05Z 于 fix(plugin-auth): /sso/register 门禁改用唯一那把管理员等级尺 (#5942) #6010、devx 18:59Z 于 docs(automation): flows.mdx 的 Scheduled flow 示例补 runAs: 'system' (#5692) #6012),本座位对那两次均已让行。车道若判定重投合理,⛔ 无需等本座位;
- ⛔ 重跑无效(SKILL note 5:
rerun_failed_jobs复用原合并 ref);解法是重新入队拿一个分片真正执行的新 run; ⚠️ 时机提示:origin/main自 15:14:30Z 起 5h07m 零落地,队列处在「重建 →abandoned假红 → 踢出 → 再重建」的自喂循环中,队首重投有较高概率再吃一次同族假红;队尾重投对停滞面新增成本≈0。
本座位本次动作:仅本条审计评论。⛔ 未重投、未重跑、未撤队、未切 ready/draft、未合并、未改代码、未动认领。
Generated by Claude Code
Fixes #2814
这道闸补的是哪个洞
#2780 给 OTP 端点落了按号码的防滥用(60s 冷却 + 每号码 5 条/小时)。那挡住的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」:攻击者轮换上万个不同号码时,每个号码都稳稳待在自己的预算里,而日累计账单没有任何上限。更要紧的是,按号码那道闸住在 better-auth 的
hooks.before里,只看得见 auth 端点 ——notify(channels:['sms'])与邀请短信从旁边直接走过去,一条都不计数。本次新增一道总量闸,扣减点放在所有出站短信本来就必经的那一处 ——
SmsService.send()。OTP、邀请、messagingsmschannel 三条路无论从哪扇门进来,都记在同一本账上。前提核实(对 origin/main)
packages/services/service-sms/src/下检索quota/daily零命中,daily_quota与daily_quota_per_tenant都不存在 —— 前提成立,本单确实没实施过。sms-channel.ts的classifyError此前是恒定return 'retryable',从未产生过rate_limited。plugin-email/src/mail-manifest-providers.contract.test.ts,只读mailSettingsManifest的key === 'provider',与本单新增的 number 键结构上不相撞 —— 已实测确认。改了什么
新增设置项
sms命名空间新增daily_quota(Daily send limit,number,默认0= 不限):本部署每个 UTC 自然日允许发出的短信总条数。0是出厂姿态,所以升级本身不改变任何现有部署的发送行为 —— 闸门要由运营者显式配置才会闭合。zh-CN / ja-JP / es-ES 三个语言包同步补齐(settings-translation-coverage.test.ts会红,问得很直接)。env 面无需额外接线,已实测:env 覆盖是按键自动的,
envKeyOf('sms','daily_quota')即OS_SMS_DAILY_QUOTA,coerceEnvValue按 default 的类型把原始字符串重塑为 number(这正是 manifest 里default必须写0而不是'0'的原因)。sms.manifest.test.ts里三条测试分别钉住:默认值可读、OS_SMS_DAILY_QUOTA=2500生效且typeof === 'number'且locked/source='env'、非数字 env 值原样透传给消费方(由消费侧钳制)。number 键没有
options表,因此完全不碰 #5204 的 env 拒绝面(那条路径以optionTables为键),也不需要 #5933 的valueDomainspecifier。计数落在哪里
复用仓内唯一那份定窗计数与它的惰性存储解析(
incrementFixedWindow/createLazyCounterStore/InProcessCounterStore,#4772/#4790),不写第三份:cache服务时计在共享 cache;解析不到时降级为有界的进程内计数,并由解析器点名打一条 warn,说明降级的代价(N 节点最多花 N× 配额);[auth] no cache service registered在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 的坑);resolveCache用getServiceAsync,因为cache是异步注册的、getService对它会抛 —— 形状照抄AuthPlugin.init()。依赖方向经实测判定可行、无需动 plugin-auth:这些件本就是
@objectstack/plugin-auth的公开导出(export * from './rate-limit-storage.js'),且已有既成先例 ——packages/runtime的security/inbound-rate-limit.ts与endpoint-policy.ts都从包根导入createLazyCounterStore,createLazyCounterStore的logPrefix选项就是 #4910 为这些外部调用方加的。本 PR 只是第三个这样的调用方。代价(service-sms 因此传递依赖上整个 better-auth)单独记为 finding #6040,不在本单动手。窗口是 UTC 自然日,且由两个机制同时保证
计数键带 UTC 日期(
sms-daily-sends:2026-08-06),同时窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾:时钟偏差把 TTL 算错了,00:00Z 依然落在新键上;存储忽略 TTL,也依然从新键重新开始。测试钉住了翻窗、以及「第二次发送不会把窗口往后推」。超限的两条路径
SendSmsResult.status='failed',error为TOO_MANY_REQUESTS: daily SMS quota exhausted。刻意与按号码闸抛的TOO_MANY_REQUESTS用同一个码,且不带任何剩余额度细节(测试断言该串不含任何数字):从外面看两道墙必须长得一样。SendResult.ok=false,classifyError由恒定'retryable'改为识别该码返回'rate_limited'。classifyDeliveryAttempt对rate_limited走与retryable相同的退避阶梯(既不是permanent的立即死信,也不是invalid_recipient的抑制),所以投递进 outbox 重试/死信,不会被静默丢弃,同时在投递记录上把「额度用尽」与「网关抖动」区分开。两条刻意的姿态
min: 0今天并不被validatePatch执行):负值 /NaN/Infinity/ 非数字一律降级为0(不限)并点名打 warn。理由写在normalizeDailyQuota的文档注释里:拒发会把设置表单里的一个手误变成手机登录的全站故障(正是诉求 4 排除的失败模式),而替运营者编一个别的默认值只会把手误藏进看似合理的行为里(membershipPolicy 无法作为平台设置配置,且注册路径与回填路径读的是两个来源 #5152 的教训)。忽略并说出来,部署就停在坏编辑之前的状态,日志里点名要改哪个值。100.5变成100,成本闸的严格方向),不打诊断。诉求要求 OTP 路径回 429。实测:到不了。
AuthManager.deliverPhoneOtp对status==='failed'抛的是普通Error(不是APIError),而 better-call 的路由层对非APIError一律回500、响应体null(better-call@1.3.7 dist/router.mjs:93-98,判定见dist/utils.mjs:57的isAPIError)。直接量测:普通Error的instanceof APIError为false;new APIError('TOO_MANY_REQUESTS').statusCode为429。于是同一个 OTP 端点上,按号码闸(走
hooks.before抛APIError)回 429,总量闸回 500 —— 恰是「两道墙从外面看应当一样」的反面。补齐需要改packages/plugins/plugin-auth,是 identity 车道领地、本单 ⛔,所以如实记录并立案 #6039,代码里也在返回点写下了这条注释。未实现:每租户日配额(
daily_quota_per_tenant)这一项没有做,需要维护者裁定,理由不是工作量:
SendSmsInput(@objectstack/spec/contracts/sms-service.ts)不携带任何租户标识,仓内也没有服务层可读的环境态当前组织(tenancy服务给的是姿态,不是当前 org)。要按organizationId计数,标识必须从某处进到services.sms.send这个缝:干净的家在 spec 契约上(本单 ⛔ 不触packages/spec),而在 service 侧另造一个只此一家的可选字段,就是 Prime Directive #12 明令禁止的影子契约 —— 同一个概念两处拼写、无人保持同步。还有一条与之纠缠的事实值得维护者先看到:OTP 发送发生在认证之前,此时根本没有 organizationId。所以租户维度天然只能覆盖可归属租户的发送(messaging channel 有
Notification.organizationId),而 OTP —— 恰恰是短信成本的大头 —— 无论如何都进不了租户账。一个只统计 notify 的daily_quota_per_tenant会让运营者以为自己给租户封了顶,这正是 ADR-0049「declared ≠ enforced」的形状。选项与取舍写在了给 PM 的报告里(
open_questions)。验证
pnpm --filter @objectstack/service-sms --filter @objectstack/service-messaging --filter @objectstack/service-settings test:70 + 162 + 247 全绿(其中新增 21 + 5 + 6 + 2 条)。pnpm --filter @objectstack/service-sms --filter @objectstack/service-messaging typecheck绿;全仓turbo run typecheck(120 tasks)绿;pnpm --filter './examples/*' typecheck绿。.github/workflows/lint.yml的门逐条跑过,全绿:lint/check:slot-lookup/check:query-options-erasure/check:nul-bytes/check:doc-authoring/check:docs-audit-scope/check:role-word/check:adr-anchors/check:org-identifier/check:authz-resolver/check:service-providers/check:route-envelope/check:error-code-casing/check:wildcard-fallthrough/check:init-service-contract/check:durability-log-level/check:startup-registry-verdict/check:objectui-changeset/check:release-notes/check:release-body/check:node-version/check:workflow-status-functions/check:published-files/check:engine-double-contract/check:resume-authority-declared/check:type-check-coverage/check:driver-conformance/check:stall-guard/check:i18n/check:i18n-coverage/check:skill-frame-sync/ spec 侧check:generated --reconcile-only、check:skill-docs、check:spec-changes、check:upgrade-guide、check:authorable-surface、check:docs、check:skill-refs、check:react-blocks、check:api-surface、check:exported-any、check:dual-source-exports、check:skill-examples、check:doc-formula-expressions、downstream-contract typecheck。(
check:i18n/check:i18n-coverage第一次是 PREREQUISITE NOT MET:门自己说了「CLI 没 build,什么都没检」—— 构建后重跑,双绿。)git status无生成物漂移,无需 revert 任何重生成产物。反向验证(方向先定,两处都预测为「红」)
if (this.options.transport) return;早退装回去(即恢复「宿主注入 transport 就跳过全部 settings」)→binds the quota even when the host injected its own transport由绿转红(expected 'sent' to be 'failed')。这正是要钉的点:「今天允许花多少钱」是运营者对部署的策略,不是某个 transport 的属性,不该被 transport 注入短路掉。classifyError改回恒定'retryable'→ sms-channel 两条新测试由绿转红(expected 'retryable' to be 'rate_limited')。两处均恢复后重跑,三包全绿。
范围
只动
packages/services/**+ changeset。⛔ 未触packages/plugins/plugin-auth、packages/spec、content/docs/releases/、objectui、cloud。service-messaging/src/sms-channel.ts属于 services 车道且不在禁改单上 —— 诉求第 3 点的classifyError='rate_limited'只能落在那里,派发令也把落点交由实现方定位。Generated by Claude Code