Skip to content

Commit 69768db

Browse files
committed
feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814)
按号码闸(#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
1 parent bbdbf28 commit 69768db

17 files changed

Lines changed: 1009 additions & 7 deletions

File tree

.changeset/sms-daily-send-quota.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/service-sms": minor
3+
"@objectstack/service-settings": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814)
8+
9+
#2780 给 OTP 端点落了**按号码**的防滥用(60s 冷却 + 每号码 5 条/小时)。那挡住的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」:攻击者轮换上万个不同号码时,每个号码都稳稳待在自己的预算里,而日累计账单没有任何上限——这正是 SMS pumping / toll fraud 的典型打法。更要紧的是,按号码那道闸住在 better-auth 的 `hooks.before` 里,只看得见 auth 端点:`notify(channels:['sms'])` 与邀请短信从旁边直接走过去,一条都不计数。
10+
11+
本次新增一道**总量**闸,扣减点放在所有出站短信本来就必经的那一处 —— `SmsService.send()`。OTP、邀请、messaging `sms` channel 三条路无论从哪扇门进来,都记在同一本账上。
12+
13+
## 新增设置项
14+
15+
`sms` 命名空间新增 `daily_quota`(Daily send limit,number,默认 `0` = 不限):这套部署每个 **UTC 自然日**允许发出的短信总条数。超出后拒发,直到 00:00 UTC。env 覆盖沿用既有的每键机制,无需额外接线:`OS_SMS_DAILY_QUOTA=2500`
16+
17+
`0` 是出厂姿态,所以升级本身不改变任何现有部署的发送行为——闸门要由运营者显式配置才会闭合。
18+
19+
## Observable behaviour change
20+
21+
**配置配额后,发送可能被拒**,两条路径的表现分别是:
22+
23+
- OTP / 邀请路径 —— `SendSmsResult.status='failed'``error``TOO_MANY_REQUESTS: daily SMS quota exhausted`。刻意与按号码闸抛出的 `TOO_MANY_REQUESTS` 用同一个码,且**不带任何剩余额度细节**:从外面看,两道墙必须长得一样,攻击者不该能试探出自己撞的是哪一道。
24+
⚠️ 但这个码**目前到不了 HTTP 调用方**`AuthManager.deliverPhoneOtp` 把它重抛成普通 `Error`,而 better-call 对非 `APIError` 一律回 500(实测,见 #6039)。也就是说 OTP 端点上,按号码闸回 429、总量闸回 500。补齐要动 plugin-auth,已单独立案。
25+
- messaging `sms` channel —— `SendResult.ok=false`,且 `classifyError` 返回 `'rate_limited'`(此前一律 `'retryable'`)。投递落进 outbox 走退避重试 / 死信,不会被静默丢弃;`rate_limited``retryable` 走同一条重试阶梯,但把「额度用尽」与「网关抖动」在投递记录上区分开。
26+
27+
## 计数落在哪里
28+
29+
复用仓内唯一那份定窗计数(`incrementFixedWindow`)与它的惰性存储解析(`createLazyCounterStore`#4772/#4790),不写第三份:
30+
31+
- 有 kernel `cache` 服务时计在共享 cache(集群共享与否取决于 cache 本身);
32+
- 解析不到时降级为有界的进程内计数,并由解析器**点名**打一条 warn,说明降级的代价(N 节点部署最多可花 N× 配额);
33+
- 解析在**计数被消费时**发生,而非插件 init —— 后注册的 cache 也能在下一次发送时被接上(#4772 的坑)。
34+
35+
窗口是 UTC 自然日,且由两个机制同时保证:计数键带 UTC 日期(`sms-daily-sends:2026-08-06`),窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾。
36+
37+
## 两条刻意的姿态
38+
39+
- **fail-open**:计数存储读不到时,闸门**放行**并打一次 warn。短信成本闸不能把登录拖下水(#2814 诉求 4)。
40+
- **配额值的钳制在消费侧**:manifest 上的 `min: 0` 今天并不被 `validatePatch` 执行(#5932),所以负值 / `NaN` / `Infinity` / 非数字都会原样抵达读取方。这些一律降级为 `0`(不限)并**点名**打 warn,而不是拒发、也不是替运营者编一个别的默认值——一个设置表单里的手误不该变成手机登录的全站故障,而编一个没人声明过的上限只会把手误藏进看似合理的行为里。
41+
42+
## 不在本次范围
43+
44+
诉求中的**每租户日配额**`daily_quota_per_tenant`)未实现:`SendSmsInput``@objectstack/spec/contracts`)不携带任何租户标识,而在 service 侧另造一个只此一家的拼法就是 Prime Directive #12 明令禁止的影子契约。租户维度要么落在 spec 契约上,要么不落——详见 #2814 上的讨论。

packages/services/service-messaging/src/sms-channel.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,37 @@ describe('sms channel', () => {
153153
expect(r.error).toContain('gateway timeout');
154154
expect(ch.classifyError?.(new Error('x'))).toBe('retryable');
155155
});
156+
157+
describe('daily SMS quota exhaustion is rate_limited, not retryable (#2814)', () => {
158+
/** Exactly what `SmsService.send` returns once the day's budget is spent. */
159+
const QUOTA_REFUSAL = { id: 'sms-1', status: 'failed', error: 'TOO_MANY_REQUESTS: daily SMS quota exhausted' };
160+
161+
it('reports ok:false so the delivery lands in the outbox rather than being dropped', async () => {
162+
const data = fakeData();
163+
const ch = createSmsChannel({
164+
getSms: () => ({ async send() { return QUOTA_REFUSAL; } }),
165+
getData: () => data,
166+
store: new NotificationTemplateStore({ getData: () => data }),
167+
});
168+
const r = await ch.send(silentCtx(), delivery());
169+
expect(r.ok).toBe(false);
170+
expect(r.error).toContain('TOO_MANY_REQUESTS');
171+
// The dispatcher hands `SendResult.error` — a string — to classifyError.
172+
expect(ch.classifyError?.(r.error)).toBe('rate_limited');
173+
});
174+
175+
it('classifies a thrown quota error the same way', () => {
176+
const data = fakeData();
177+
const ch = createSmsChannel({
178+
getSms: () => ({ async send() { return QUOTA_REFUSAL; } }),
179+
getData: () => data,
180+
store: new NotificationTemplateStore({ getData: () => data }),
181+
});
182+
expect(ch.classifyError?.(new Error('sms send failed: TOO_MANY_REQUESTS: daily SMS quota exhausted')))
183+
.toBe('rate_limited');
184+
// Everything else stays retryable — a transport hiccup is not a wall.
185+
expect(ch.classifyError?.('sms send failed: gateway timeout')).toBe('retryable');
186+
expect(ch.classifyError?.(undefined)).toBe('retryable');
187+
});
188+
});
156189
});

packages/services/service-messaging/src/sms-channel.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ const PHONE_SHAPE = (s: string): string | undefined => {
5252
return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined;
5353
};
5454

55+
/**
56+
* The code `@objectstack/service-sms` prefixes onto `SendSmsResult.error` when
57+
* the deployment's daily send quota is exhausted (`SMS_QUOTA_EXCEEDED_CODE`,
58+
* #2814). Spelled locally for the SAME reason as `PHONE_SHAPE` above — this
59+
* package deliberately takes no dependency on service-sms and resolves whatever
60+
* is registered under the `sms` service — and pinned from both ends: the
61+
* producer exports the constant, and `sms-channel.test.ts` asserts this literal
62+
* still classifies as `rate_limited`.
63+
*
64+
* It matters that this is not classified `retryable`: an exhausted daily budget
65+
* is not a transient transport hiccup, and the outbox's retry ladder should
66+
* back off rather than burn attempts against a wall that only opens at 00:00
67+
* UTC.
68+
*/
69+
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';
70+
5571
/**
5672
* The `sms` channel (#2780) — delivers a notification by SMS.
5773
*
@@ -139,7 +155,12 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel {
139155
}
140156
},
141157

142-
classifyError(_err: unknown): ErrorClass {
158+
classifyError(err: unknown): ErrorClass {
159+
// The dispatcher hands this `SendResult.error` — the string built
160+
// above — not a thrown Error, so the quota refusal arrives as
161+
// `sms send failed: TOO_MANY_REQUESTS: …`.
162+
const text = err instanceof Error ? err.message : String(err ?? '');
163+
if (text.includes(SMS_QUOTA_EXCEEDED_CODE)) return 'rate_limited';
143164
return 'retryable';
144165
},
145166
};

packages/services/service-settings/src/manifests/sms.manifest.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { SettingsManifestSchema } from '@objectstack/spec/system';
5+
import { SettingsService } from '../settings-service.js';
56
import { smsSettingsManifest, smsTestActionHandler } from './sms.manifest.js';
67

78
describe('sms settings manifest', () => {
@@ -16,6 +17,20 @@ describe('sms settings manifest', () => {
1617
expect(smsSettingsManifest.writePermission).toBe('manage_platform_settings');
1718
});
1819

20+
it('declares the #2814 daily send quota as an unlimited-by-default number', () => {
21+
const spec = (smsSettingsManifest.specifiers as any[]).find((s) => s.key === 'daily_quota');
22+
expect(spec, 'sms manifest must declare a `daily_quota` specifier').toBeDefined();
23+
expect(spec.type).toBe('number');
24+
// `0 = no limit` is the shipped posture: adding a cost ceiling must not
25+
// change what an existing deployment sends the moment it upgrades.
26+
expect(spec.default).toBe(0);
27+
expect(spec.required).toBe(false);
28+
// A NUMBER key carries no `options` table, so it never touches the #5204
29+
// env-rejection surface (that path is keyed on `optionTables`) and needs
30+
// nothing from a `valueDomain` specifier (#5933).
31+
expect(spec.options).toBeUndefined();
32+
});
33+
1934
it('marks provider secrets as encrypted password specifiers', () => {
2035
const byKey = new Map(
2136
(smsSettingsManifest.specifiers as any[]).filter((s) => s.key).map((s) => [s.key, s]),
@@ -48,3 +63,39 @@ describe('smsTestActionHandler (fallback)', () => {
4863
expect(r.message).toMatch(/From number|Messaging Service/);
4964
});
5065
});
66+
67+
describe('sms.daily_quota through the settings resolver', () => {
68+
it('is readable, defaults to 0 and reports its cascade source', async () => {
69+
const svc = new SettingsService({ env: {} });
70+
svc.registerManifest(smsSettingsManifest);
71+
const resolved = await svc.get('sms', 'daily_quota');
72+
expect(resolved.value).toBe(0);
73+
expect(resolved.locked).toBeFalsy();
74+
});
75+
76+
it('honours the OS_SMS_DAILY_QUOTA env override, coerced to a NUMBER (#5204 gate)', async () => {
77+
// The env-override gate is per-key and automatic: `envKeyOf('sms',
78+
// 'daily_quota')` is `OS_SMS_DAILY_QUOTA`, and `coerceEnvValue` reshapes
79+
// the raw string by the DEFAULT's type — which is why the manifest's
80+
// `default: 0` has to be a number literal and not `'0'`.
81+
const svc = new SettingsService({ env: { OS_SMS_DAILY_QUOTA: '2500' } });
82+
svc.registerManifest(smsSettingsManifest);
83+
const resolved = await svc.get('sms', 'daily_quota');
84+
expect(resolved.value).toBe(2500);
85+
expect(typeof resolved.value).toBe('number');
86+
expect(resolved.locked).toBe(true);
87+
expect(resolved.source).toBe('env');
88+
});
89+
90+
it('passes a non-numeric env override through as a string — the CONSUMER clamps it (#5932)', async () => {
91+
// `coerceEnvValue` returns the raw string when it will not parse, and
92+
// `validatePatch` does not enforce the manifest's `min` today, so the
93+
// service layer is the only place this can be caught. `normalizeDailyQuota`
94+
// is what actually rejects it; this pins that the settings layer really
95+
// does hand the garbage over rather than filtering it.
96+
const svc = new SettingsService({ env: { OS_SMS_DAILY_QUOTA: 'unlimited' } });
97+
svc.registerManifest(smsSettingsManifest);
98+
const resolved = await svc.get('sms', 'daily_quota');
99+
expect(resolved.value).toBe('unlimited');
100+
});
101+
});

packages/services/service-settings/src/manifests/sms.manifest.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ const manifest = {
7171
{ type: 'text', key: 'twilio_messaging_service_sid', label: 'Messaging Service SID', required: false,
7272
visible: "${data.provider === 'twilio'}" },
7373

74+
{ type: 'group', id: 'limits', label: 'Spend limits', required: false,
75+
description: 'Caps the deployment’s outbound SMS volume. SMS is a paid channel and every send costs real money.' },
76+
{ type: 'number', key: 'daily_quota', label: 'Daily send limit', required: false, default: 0, min: 0,
77+
description: 'Maximum SMS this deployment may send per UTC day, across OTP sign-in, invitations and notifications. 0 means no limit. Sends beyond the limit are refused until 00:00 UTC.' },
78+
7479
{ type: 'action_button', id: 'test', label: 'Send test SMS', required: false, icon: 'Send',
7580
handler: { kind: 'http', method: 'POST', url: '/api/settings/sms/test' } },
7681
],

packages/services/service-settings/src/translations/es-ES.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export const esES: TranslationData = {
213213
provider: { title: 'Proveedor', description: 'Elige cómo envía este espacio de trabajo los SMS salientes.' },
214214
aliyun: { title: 'Aliyun SMS' },
215215
twilio: { title: 'Twilio' },
216+
limits: { title: 'Límites de gasto', description: 'Limita el volumen de SMS salientes del despliegue. El SMS es un canal de pago y cada envío cuesta dinero real.' },
216217
},
217218
keys: {
218219
provider: {
@@ -233,6 +234,10 @@ export const esES: TranslationData = {
233234
help: 'Remitente en formato E.164, p. ej. +15005550006. Se necesita esto o un Messaging Service SID.',
234235
},
235236
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
237+
daily_quota: {
238+
label: 'Límite de envíos diarios',
239+
help: 'Número máximo de SMS que este despliegue puede enviar por día UTC, contando inicio de sesión con OTP, invitaciones y notificaciones. 0 significa sin límite. Los envíos que superen el límite se rechazan hasta las 00:00 UTC.',
240+
},
236241
},
237242
actions: { test: { label: 'Enviar SMS de prueba' } },
238243
},

packages/services/service-settings/src/translations/ja-JP.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export const jaJP: TranslationData = {
213213
provider: { title: 'プロバイダー', description: 'このワークスペースが送信 SMS をどう送るかを選択します。' },
214214
aliyun: { title: 'Aliyun SMS' },
215215
twilio: { title: 'Twilio' },
216+
limits: { title: '送信上限', description: 'この配備の送信 SMS 量を制限します。SMS は有料チャネルで、1 通ごとに実費が発生します。' },
216217
},
217218
keys: {
218219
provider: {
@@ -233,6 +234,10 @@ export const jaJP: TranslationData = {
233234
help: 'E.164 形式の送信者。例:+15005550006。これか Messaging Service SID のいずれかが必要です。',
234235
},
235236
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
237+
daily_quota: {
238+
label: '1 日の送信上限',
239+
help: 'この配備が UTC の 1 日あたりに送信できる SMS の上限数(OTP サインイン・招待・通知を含む)。0 は無制限。上限を超える送信は 00:00 UTC まで拒否されます。',
240+
},
236241
},
237242
actions: { test: { label: 'テスト SMS を送信' } },
238243
},

packages/services/service-settings/src/translations/zh-CN.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const zhCN: TranslationData = {
6363
provider: { title: '服务商', description: '选择此工作区如何发送外发短信。' },
6464
aliyun: { title: '阿里云短信' },
6565
twilio: { title: 'Twilio' },
66+
limits: { title: '发送额度', description: '限制本部署的外发短信量。短信是付费通道,每一条都产生真实费用。' },
6667
},
6768
keys: {
6869
provider: {
@@ -87,6 +88,10 @@ export const zhCN: TranslationData = {
8788
help: 'E.164 格式的发信方,例如 +15005550006。此项与 Messaging Service SID 二选一。',
8889
},
8990
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
91+
daily_quota: {
92+
label: '每日发送上限',
93+
help: '本部署每个 UTC 自然日最多可发送的短信条数,涵盖 OTP 登录、邀请与通知。0 表示不限。超出上限的发送将被拒绝,直到 00:00 UTC。',
94+
},
9095
},
9196
actions: {
9297
test: { label: '发送测试短信' },

packages/services/service-sms/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@objectstack/core": "workspace:*",
22+
"@objectstack/plugin-auth": "workspace:*",
2223
"@objectstack/spec": "workspace:*"
2324
},
2425
"devDependencies": {

packages/services/service-sms/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ export {
88
type SmsServiceOptions,
99
} from './sms-service.js';
1010
export { SmsServicePlugin, type SmsServicePluginOptions } from './sms-plugin.js';
11+
export {
12+
SmsDailyQuota,
13+
SMS_QUOTA_EXCEEDED_CODE,
14+
SMS_QUOTA_EXCEEDED_ERROR,
15+
normalizeDailyQuota,
16+
secondsUntilNextUtcMidnight,
17+
utcDayStamp,
18+
type SmsDailyQuotaDecision,
19+
type SmsDailyQuotaOptions,
20+
type NormalizedDailyQuota,
21+
} from './sms-daily-quota.js';
1122
export {
1223
makeSmsTransport,
1324
SMS_TRANSPORT_PROVIDERS,

0 commit comments

Comments
 (0)