Skip to content

feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814) - #6042

Queued
hotlong wants to merge 1 commit into
mainfrom
claude/issue-2814-sms-daily-quota
Queued

feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814)#6042
hotlong wants to merge 1 commit into
mainfrom
claude/issue-2814-sms-daily-quota

Conversation

@hotlong

@hotlong hotlong commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #2814

这道闸补的是哪个洞

#2780 给 OTP 端点落了按号码的防滥用(60s 冷却 + 每号码 5 条/小时)。那挡住的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」:攻击者轮换上万个不同号码时,每个号码都稳稳待在自己的预算里,而日累计账单没有任何上限。更要紧的是,按号码那道闸住在 better-auth 的 hooks.before 里,只看得见 auth 端点 —— notify(channels:['sms']) 与邀请短信从旁边直接走过去,一条都不计数。

本次新增一道总量闸,扣减点放在所有出站短信本来就必经的那一处 —— SmsService.send()。OTP、邀请、messaging sms channel 三条路无论从哪扇门进来,都记在同一本账上。

前提核实(对 origin/main)

改了什么

新增设置项

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_QUOTAcoerceEnvValuedefault 的类型把原始字符串重塑为 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 为键),也不需要 #5933valueDomain specifier。

计数落在哪里

复用仓内唯一那份定窗计数与它的惰性存储解析(incrementFixedWindow / createLazyCounterStore / InProcessCounterStore#4772/#4790),不写第三份

依赖方向经实测判定可行、无需动 plugin-auth:这些件本就是 @objectstack/plugin-auth 的公开导出(export * from './rate-limit-storage.js'),且已有既成先例 —— packages/runtimesecurity/inbound-rate-limit.tsendpoint-policy.ts 都从包根导入 createLazyCounterStorecreateLazyCounterStorelogPrefix 选项就是 #4910 为这些外部调用方加的。本 PR 只是第三个这样的调用方。代价(service-sms 因此传递依赖上整个 better-auth)单独记为 finding #6040,不在本单动手。

窗口是 UTC 自然日,且由两个机制同时保证

计数键带 UTC 日期(sms-daily-sends:2026-08-06),同时窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾:时钟偏差把 TTL 算错了,00:00Z 依然落在新键上;存储忽略 TTL,也依然从新键重新开始。测试钉住了翻窗、以及「第二次发送不会把窗口往后推」。

超限的两条路径

  • OTP / 邀请 —— SendSmsResult.status='failed'errorTOO_MANY_REQUESTS: daily SMS quota exhausted。刻意与按号码闸抛的 TOO_MANY_REQUESTS 用同一个码,且不带任何剩余额度细节(测试断言该串不含任何数字):从外面看两道墙必须长得一样。
  • messaging channel —— SendResult.ok=falseclassifyError 由恒定 'retryable' 改为识别该码返回 'rate_limited'classifyDeliveryAttemptrate_limited 走与 retryable 相同的退避阶梯(既不是 permanent 的立即死信,也不是 invalid_recipient 的抑制),所以投递进 outbox 重试/死信,不会被静默丢弃,同时在投递记录上把「额度用尽」与「网关抖动」区分开。

两条刻意的姿态

⚠️ 一个实测出来的、本单够不着的事实(已单独立案 #6039

诉求要求 OTP 路径回 429。实测:到不了

AuthManager.deliverPhoneOtpstatus==='failed' 抛的是普通 Error(不是 APIError),而 better-call 的路由层对非 APIError 一律回 500、响应体 nullbetter-call@1.3.7 dist/router.mjs:93-98,判定见 dist/utils.mjs:57isAPIError)。直接量测:普通 Errorinstanceof APIErrorfalsenew APIError('TOO_MANY_REQUESTS').statusCode429

于是同一个 OTP 端点上,按号码闸(走 hooks.beforeAPIError)回 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 test70 + 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-onlycheck:skill-docscheck:spec-changescheck:upgrade-guidecheck:authorable-surfacecheck:docscheck:skill-refscheck:react-blockscheck:api-surfacecheck:exported-anycheck:dual-source-exportscheck:skill-examplescheck:doc-formula-expressionsdownstream-contract typecheck
    check:i18n / check:i18n-coverage 第一次是 PREREQUISITE NOT MET:门自己说了「CLI 没 build,什么都没检」—— 构建后重跑,双绿。)
  • 全量构建与门跑完后 git status 无生成物漂移,无需 revert 任何重生成产物。

反向验证(方向定,两处都预测为「红」)

  1. 把插件里被我拆掉的 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 注入短路掉。
  2. classifyError 改回恒定 'retryable' → sms-channel 两条新测试由绿转expected 'retryable' to be 'rate_limited')。

两处均恢复后重跑,三包全绿。

范围

只动 packages/services/** + changeset。⛔ 未触 packages/plugins/plugin-authpackages/speccontent/docs/releases/、objectui、cloud。service-messaging/src/sms-channel.ts 属于 services 车道且不在禁改单上 —— 诉求第 3 点的 classifyError='rate_limited' 只能落在那里,派发令也把落点交由实现方定位。


Generated by Claude Code

按号码闸(#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
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 3:32pm

Request Review

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/service-messaging, @objectstack/service-settings, @objectstack/service-sms.

10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via @objectstack/service-messaging)
  • content/docs/kernel/runtime-services/audit-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services/service-settings)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services/service-settings)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-messaging)
  • content/docs/permissions/authentication.mdx (via @objectstack/service-sms)
  • content/docs/plugins/packages.mdx (via @objectstack/service-messaging, @objectstack/service-settings, @objectstack/service-sms)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-messaging, @objectstack/service-settings)
  • content/docs/releases/v14.mdx (via @objectstack/service-sms)
  • content/docs/releases/v9.mdx (via @objectstack/service-settings)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31119799271 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 37 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 31120013500 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • filter — 失败步骤: Set up job(日志不可读,点进 job 看)

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 40 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 6, 2026
@hotlong
hotlong added this pull request to the merge queue Aug 6, 2026
Any commits made after this event will not be merged.
@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

队列管家拦截(abandoned 聚合假红 —— 该族第 4 例,⛔ 未重投)

本 PR 作为队首于 20:13:47Z 被判红并移出合并队列(git ls-remote --heads origin 'refs/heads/gh-readonly-queue/*' 于 20:16Z 已无 pr-6042-* 分支,且 origin/main 仍是 9e3709a4 ⇒ 未落地,两读数判据齐备,SKILL notes 1)。红因与本 PR 的 diff 无关,run 内零测试失败。

完整签名(取完整 job 归档original_length 80/80,⛔ 未看 tail —— SKILL note 7)

run / job 结束 终报错
CI 31126905027Dogfood 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sms): 短信全局/每租户日发送配额(成本总量闸)

2 participants