Skip to content

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

Closed

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. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 3, 2026
  3. 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

  4. self-assigned this
    on Aug 6, 2026
  5. 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

  6. 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

  7. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 6, 2026
  8. removed their assignment
    on Aug 6, 2026
  9. 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

  10. reopened this on Aug 6, 2026
  11. 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

  12. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedenhancementNew feature or requestsecurity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions