Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/sms-daily-send-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/service-sms": minor
"@objectstack/service-settings": minor
"@objectstack/service-messaging": minor
---

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

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

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

## 新增设置项

`sms` 命名空间新增 `daily_quota`(Daily send limit,number,默认 `0` = 不限):这套部署每个 **UTC 自然日**允许发出的短信总条数。超出后拒发,直到 00:00 UTC。env 覆盖沿用既有的每键机制,无需额外接线:`OS_SMS_DAILY_QUOTA=2500`。

`0` 是出厂姿态,所以升级本身不改变任何现有部署的发送行为——闸门要由运营者显式配置才会闭合。

## Observable behaviour change

**配置配额后,发送可能被拒**,两条路径的表现分别是:

- OTP / 邀请路径 —— `SendSmsResult.status='failed'`,`error` 为 `TOO_MANY_REQUESTS: daily SMS quota exhausted`。刻意与按号码闸抛出的 `TOO_MANY_REQUESTS` 用同一个码,且**不带任何剩余额度细节**:从外面看,两道墙必须长得一样,攻击者不该能试探出自己撞的是哪一道。
⚠️ 但这个码**目前到不了 HTTP 调用方**:`AuthManager.deliverPhoneOtp` 把它重抛成普通 `Error`,而 better-call 对非 `APIError` 一律回 500(实测,见 #6039)。也就是说 OTP 端点上,按号码闸回 429、总量闸回 500。补齐要动 plugin-auth,已单独立案。
- messaging `sms` channel —— `SendResult.ok=false`,且 `classifyError` 返回 `'rate_limited'`(此前一律 `'retryable'`)。投递落进 outbox 走退避重试 / 死信,不会被静默丢弃;`rate_limited` 与 `retryable` 走同一条重试阶梯,但把「额度用尽」与「网关抖动」在投递记录上区分开。

## 计数落在哪里

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

- 有 kernel `cache` 服务时计在共享 cache(集群共享与否取决于 cache 本身);
- 解析不到时降级为有界的进程内计数,并由解析器**点名**打一条 warn,说明降级的代价(N 节点部署最多可花 N× 配额);
- 解析在**计数被消费时**发生,而非插件 init —— 后注册的 cache 也能在下一次发送时被接上(#4772 的坑)。

窗口是 UTC 自然日,且由两个机制同时保证:计数键带 UTC 日期(`sms-daily-sends:2026-08-06`),窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾。

## 两条刻意的姿态

- **fail-open**:计数存储读不到时,闸门**放行**并打一次 warn。短信成本闸不能把登录拖下水(#2814 诉求 4)。
- **配额值的钳制在消费侧**:manifest 上的 `min: 0` 今天并不被 `validatePatch` 执行(#5932),所以负值 / `NaN` / `Infinity` / 非数字都会原样抵达读取方。这些一律降级为 `0`(不限)并**点名**打 warn,而不是拒发、也不是替运营者编一个别的默认值——一个设置表单里的手误不该变成手机登录的全站故障,而编一个没人声明过的上限只会把手误藏进看似合理的行为里。

## 不在本次范围

诉求中的**每租户日配额**(`daily_quota_per_tenant`)未实现:`SendSmsInput`(`@objectstack/spec/contracts`)不携带任何租户标识,而在 service 侧另造一个只此一家的拼法就是 Prime Directive #12 明令禁止的影子契约。租户维度要么落在 spec 契约上,要么不落——详见 #2814 上的讨论。
33 changes: 33 additions & 0 deletions packages/services/service-messaging/src/sms-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,37 @@ describe('sms channel', () => {
expect(r.error).toContain('gateway timeout');
expect(ch.classifyError?.(new Error('x'))).toBe('retryable');
});

describe('daily SMS quota exhaustion is rate_limited, not retryable (#2814)', () => {
/** Exactly what `SmsService.send` returns once the day's budget is spent. */
const QUOTA_REFUSAL = { id: 'sms-1', status: 'failed', error: 'TOO_MANY_REQUESTS: daily SMS quota exhausted' };

it('reports ok:false so the delivery lands in the outbox rather than being dropped', async () => {
const data = fakeData();
const ch = createSmsChannel({
getSms: () => ({ async send() { return QUOTA_REFUSAL; } }),
getData: () => data,
store: new NotificationTemplateStore({ getData: () => data }),
});
const r = await ch.send(silentCtx(), delivery());
expect(r.ok).toBe(false);
expect(r.error).toContain('TOO_MANY_REQUESTS');
// The dispatcher hands `SendResult.error` — a string — to classifyError.
expect(ch.classifyError?.(r.error)).toBe('rate_limited');
});

it('classifies a thrown quota error the same way', () => {
const data = fakeData();
const ch = createSmsChannel({
getSms: () => ({ async send() { return QUOTA_REFUSAL; } }),
getData: () => data,
store: new NotificationTemplateStore({ getData: () => data }),
});
expect(ch.classifyError?.(new Error('sms send failed: TOO_MANY_REQUESTS: daily SMS quota exhausted')))
.toBe('rate_limited');
// Everything else stays retryable — a transport hiccup is not a wall.
expect(ch.classifyError?.('sms send failed: gateway timeout')).toBe('retryable');
expect(ch.classifyError?.(undefined)).toBe('retryable');
});
});
});
23 changes: 22 additions & 1 deletion packages/services/service-messaging/src/sms-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ const PHONE_SHAPE = (s: string): string | undefined => {
return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined;
};

/**
* The code `@objectstack/service-sms` prefixes onto `SendSmsResult.error` when
* the deployment's daily send quota is exhausted (`SMS_QUOTA_EXCEEDED_CODE`,
* #2814). Spelled locally for the SAME reason as `PHONE_SHAPE` above — this
* package deliberately takes no dependency on service-sms and resolves whatever
* is registered under the `sms` service — and pinned from both ends: the
* producer exports the constant, and `sms-channel.test.ts` asserts this literal
* still classifies as `rate_limited`.
*
* It matters that this is not classified `retryable`: an exhausted daily budget
* is not a transient transport hiccup, and the outbox's retry ladder should
* back off rather than burn attempts against a wall that only opens at 00:00
* UTC.
*/
const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS';

/**
* The `sms` channel (#2780) — delivers a notification by SMS.
*
Expand Down Expand Up @@ -139,7 +155,12 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel {
}
},

classifyError(_err: unknown): ErrorClass {
classifyError(err: unknown): ErrorClass {
// The dispatcher hands this `SendResult.error` — the string built
// above — not a thrown Error, so the quota refusal arrives as
// `sms send failed: TOO_MANY_REQUESTS: …`.
const text = err instanceof Error ? err.message : String(err ?? '');
if (text.includes(SMS_QUOTA_EXCEEDED_CODE)) return 'rate_limited';
return 'retryable';
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

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

it('declares the #2814 daily send quota as an unlimited-by-default number', () => {
const spec = (smsSettingsManifest.specifiers as any[]).find((s) => s.key === 'daily_quota');
expect(spec, 'sms manifest must declare a `daily_quota` specifier').toBeDefined();
expect(spec.type).toBe('number');
// `0 = no limit` is the shipped posture: adding a cost ceiling must not
// change what an existing deployment sends the moment it upgrades.
expect(spec.default).toBe(0);
expect(spec.required).toBe(false);
// A NUMBER key carries no `options` table, so it never touches the #5204
// env-rejection surface (that path is keyed on `optionTables`) and needs
// nothing from a `valueDomain` specifier (#5933).
expect(spec.options).toBeUndefined();
});

it('marks provider secrets as encrypted password specifiers', () => {
const byKey = new Map(
(smsSettingsManifest.specifiers as any[]).filter((s) => s.key).map((s) => [s.key, s]),
Expand Down Expand Up @@ -48,3 +63,39 @@ describe('smsTestActionHandler (fallback)', () => {
expect(r.message).toMatch(/From number|Messaging Service/);
});
});

describe('sms.daily_quota through the settings resolver', () => {
it('is readable, defaults to 0 and reports its cascade source', async () => {
const svc = new SettingsService({ env: {} });
svc.registerManifest(smsSettingsManifest);
const resolved = await svc.get('sms', 'daily_quota');
expect(resolved.value).toBe(0);
expect(resolved.locked).toBeFalsy();
});

it('honours the OS_SMS_DAILY_QUOTA env override, coerced to a NUMBER (#5204 gate)', async () => {
// The env-override gate is per-key and automatic: `envKeyOf('sms',
// 'daily_quota')` is `OS_SMS_DAILY_QUOTA`, and `coerceEnvValue` reshapes
// the raw string by the DEFAULT's type — which is why the manifest's
// `default: 0` has to be a number literal and not `'0'`.
const svc = new SettingsService({ env: { OS_SMS_DAILY_QUOTA: '2500' } });
svc.registerManifest(smsSettingsManifest);
const resolved = await svc.get('sms', 'daily_quota');
expect(resolved.value).toBe(2500);
expect(typeof resolved.value).toBe('number');
expect(resolved.locked).toBe(true);
expect(resolved.source).toBe('env');
});

it('passes a non-numeric env override through as a string — the CONSUMER clamps it (#5932)', async () => {
// `coerceEnvValue` returns the raw string when it will not parse, and
// `validatePatch` does not enforce the manifest's `min` today, so the
// service layer is the only place this can be caught. `normalizeDailyQuota`
// is what actually rejects it; this pins that the settings layer really
// does hand the garbage over rather than filtering it.
const svc = new SettingsService({ env: { OS_SMS_DAILY_QUOTA: 'unlimited' } });
svc.registerManifest(smsSettingsManifest);
const resolved = await svc.get('sms', 'daily_quota');
expect(resolved.value).toBe('unlimited');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ const manifest = {
{ type: 'text', key: 'twilio_messaging_service_sid', label: 'Messaging Service SID', required: false,
visible: "${data.provider === 'twilio'}" },

{ type: 'group', id: 'limits', label: 'Spend limits', required: false,
description: 'Caps the deployment’s outbound SMS volume. SMS is a paid channel and every send costs real money.' },
{ type: 'number', key: 'daily_quota', label: 'Daily send limit', required: false, default: 0, min: 0,
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.' },

{ type: 'action_button', id: 'test', label: 'Send test SMS', required: false, icon: 'Send',
handler: { kind: 'http', method: 'POST', url: '/api/settings/sms/test' } },
],
Expand Down
5 changes: 5 additions & 0 deletions packages/services/service-settings/src/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export const esES: TranslationData = {
provider: { title: 'Proveedor', description: 'Elige cómo envía este espacio de trabajo los SMS salientes.' },
aliyun: { title: 'Aliyun SMS' },
twilio: { title: 'Twilio' },
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.' },
},
keys: {
provider: {
Expand All @@ -233,6 +234,10 @@ export const esES: TranslationData = {
help: 'Remitente en formato E.164, p. ej. +15005550006. Se necesita esto o un Messaging Service SID.',
},
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
daily_quota: {
label: 'Límite de envíos diarios',
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.',
},
},
actions: { test: { label: 'Enviar SMS de prueba' } },
},
Expand Down
5 changes: 5 additions & 0 deletions packages/services/service-settings/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export const jaJP: TranslationData = {
provider: { title: 'プロバイダー', description: 'このワークスペースが送信 SMS をどう送るかを選択します。' },
aliyun: { title: 'Aliyun SMS' },
twilio: { title: 'Twilio' },
limits: { title: '送信上限', description: 'この配備の送信 SMS 量を制限します。SMS は有料チャネルで、1 通ごとに実費が発生します。' },
},
keys: {
provider: {
Expand All @@ -233,6 +234,10 @@ export const jaJP: TranslationData = {
help: 'E.164 形式の送信者。例:+15005550006。これか Messaging Service SID のいずれかが必要です。',
},
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
daily_quota: {
label: '1 日の送信上限',
help: 'この配備が UTC の 1 日あたりに送信できる SMS の上限数(OTP サインイン・招待・通知を含む)。0 は無制限。上限を超える送信は 00:00 UTC まで拒否されます。',
},
},
actions: { test: { label: 'テスト SMS を送信' } },
},
Expand Down
5 changes: 5 additions & 0 deletions packages/services/service-settings/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const zhCN: TranslationData = {
provider: { title: '服务商', description: '选择此工作区如何发送外发短信。' },
aliyun: { title: '阿里云短信' },
twilio: { title: 'Twilio' },
limits: { title: '发送额度', description: '限制本部署的外发短信量。短信是付费通道,每一条都产生真实费用。' },
},
keys: {
provider: {
Expand All @@ -87,6 +88,10 @@ export const zhCN: TranslationData = {
help: 'E.164 格式的发信方,例如 +15005550006。此项与 Messaging Service SID 二选一。',
},
twilio_messaging_service_sid: { label: 'Messaging Service SID' },
daily_quota: {
label: '每日发送上限',
help: '本部署每个 UTC 自然日最多可发送的短信条数,涵盖 OTP 登录、邀请与通知。0 表示不限。超出上限的发送将被拒绝,直到 00:00 UTC。',
},
},
actions: {
test: { label: '发送测试短信' },
Expand Down
1 change: 1 addition & 0 deletions packages/services/service-sms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
11 changes: 11 additions & 0 deletions packages/services/service-sms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ export {
type SmsServiceOptions,
} from './sms-service.js';
export { SmsServicePlugin, type SmsServicePluginOptions } from './sms-plugin.js';
export {
SmsDailyQuota,
SMS_QUOTA_EXCEEDED_CODE,
SMS_QUOTA_EXCEEDED_ERROR,
normalizeDailyQuota,
secondsUntilNextUtcMidnight,
utcDayStamp,
type SmsDailyQuotaDecision,
type SmsDailyQuotaOptions,
type NormalizedDailyQuota,
} from './sms-daily-quota.js';
export {
makeSmsTransport,
SMS_TRANSPORT_PROVIDERS,
Expand Down
Loading
Loading