Skip to content

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

Description

@os-zhuang

背景

#2780(PR #2790/#2797)为 OTP 端点落了按号码维度的防滥用:60s 冷却 + 每号码 5 条/小时(hooks.before 准入层,集群经 secondaryStorage 共享),外加 per-IP 限流。但 SMS pumping 的总量维度仍然缺失:攻击者轮换大量不同号码时,每个号码都在自己的预算内,日累计成本没有上限;notify(channels:['sms']) 走 messaging channel 的发送也完全不经过任何配额。

诉求

  1. 全局日配额:sms 设置命名空间新增 daily_quota(0 = 不限),对经过 services.sms.send 的所有发送计数(OTP、邀请、notify channel 统一在 service 层扣减,而不是只挡 auth 端点)。
  2. 每租户日配额(多租户部署):daily_quota_per_tenant,按 organizationId 维度计数。
  3. 超限行为:
    • OTP/邀请路径 → 与按号码冷却一致的 429 TOO_MANY_REQUESTS(不泄露配额剩余细节);
    • messaging channel → SendResult.ok=false + classifyError='rate_limited'(进 outbox 重试/死信,不静默丢)。
  4. 计数存储:单节点内存、集群复用 cache/secondaryStorage(对齐 otp-send-guard 的降级策略:存储故障 fail-open + 告警日志,配额闸不能把登录拖下水)。
  5. 可观测:接近阈值(如 80%)打 WARN,超限打 WARN(含当日计数,绝不含正文)。

相关

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    交叉说明(来自 #4790 的核对,非认领):

    修 #4790(每号码 OTP 预算只在进程内计数)时按要求核对了本单的「日发送配额闸」是否也建立在同一套进程内计数上 —— 不是:packages/services/service-sms/src/ 下检索 quota / daily 均无命中,本单尚未实施,daily_quota / daily_quota_per_tenant 都还不存在,所以没有「上线即失效」的既有闸需要修。

    给将来实施本单的人一个现成件:本单诉求第 4 点(「单节点内存、集群复用 cache,存储故障 fail-open + 告警」)现在有一条已建好的路径,直接接上即可,不要再写第三份计数:

    参考:#4772 → PR #4788,#4790 → PR #4806。


    Generated by Claude Code

  2. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):裁定入队。前提经 origin/main 核实仍成立:service-sms 全无 daily_quota 实现,per-号码闸已建而总量成本闸为零——属已发布能力上的防滥用补墙,非投机扩面;计数通道(incrementFixedWindow)现成可接(#4790 评论同证)。维护者可否决。


    Generated by Claude Code

  3. self-assigned this
    on Aug 6, 2026
  4. hotlong commented on Aug 6, 2026

    @hotlong
    Contributor

    认领: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

  5. hotlong commented on Aug 6, 2026

    @hotlong
    Contributor

    验收:部分 ACCEPT + 升级(services 座位)。

    已验收合入(诉求 1/3/4/5 + 半个 2):PR #6042(CI 24/24 绿,已转 ready 入队)——

    ⚠️ 本 PR 带 Fixes #2814,合并会自动关单;届时本座位立即重开本单以承载下面的待决半边 —— 关闭/重开是刻意操作,不是误触。


    升级维护者(needs-user-decision):诉求 2「每租户日配额 daily_quota_per_tenant」如何处置?

    dev 停手上报的结构性事实(实测,非估计):

    1. SendSmsInput(spec 契约)不携带任何租户标识,服务层也无环境态当前 org(tenancy 服务给姿态不给 org)—— 租户标识要进 services.sms.send 这条缝,干净落点在 spec;
    2. 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

  6. removed their assignment
    on Aug 6, 2026
  7. claude commented on Aug 6, 2026

    @claude
    Contributor

    队列管家通知(新签名拦截,第 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

  8. reopened this on Aug 6, 2026
  9. claude commented on Aug 7, 2026

    @claude
    Contributor

    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, the daily_quota manifest key with min:0 and three translations, the OS_SMS_DAILY_QUOTA env gate), and the derived issue #6039 (quota refusal returning 500) closed via PR #6092.

    Per-tenant quota: ruled C — not declared. SendSmsInput carries no tenant identity at all, and OTP — which is the bulk of SMS spend — happens before authentication, so it has no organizationId to attribute. A daily_quota_per_tenant key 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 at sms-daily-quota.ts:61-66 so 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 SendSmsInput contract, 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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions