diff --git a/.changeset/sms-daily-send-quota.md b/.changeset/sms-daily-send-quota.md new file mode 100644 index 0000000000..fdb0a4b12f --- /dev/null +++ b/.changeset/sms-daily-send-quota.md @@ -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 上的讨论。 diff --git a/packages/services/service-messaging/src/sms-channel.test.ts b/packages/services/service-messaging/src/sms-channel.test.ts index 7b37587106..2ca457054e 100644 --- a/packages/services/service-messaging/src/sms-channel.test.ts +++ b/packages/services/service-messaging/src/sms-channel.test.ts @@ -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'); + }); + }); }); diff --git a/packages/services/service-messaging/src/sms-channel.ts b/packages/services/service-messaging/src/sms-channel.ts index 021458b504..0f818cf867 100644 --- a/packages/services/service-messaging/src/sms-channel.ts +++ b/packages/services/service-messaging/src/sms-channel.ts @@ -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. * @@ -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'; }, }; diff --git a/packages/services/service-settings/src/manifests/sms.manifest.test.ts b/packages/services/service-settings/src/manifests/sms.manifest.test.ts index d7691a7125..ea06981607 100644 --- a/packages/services/service-settings/src/manifests/sms.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/sms.manifest.test.ts @@ -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', () => { @@ -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]), @@ -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'); + }); +}); diff --git a/packages/services/service-settings/src/manifests/sms.manifest.ts b/packages/services/service-settings/src/manifests/sms.manifest.ts index de29fedf34..f8d4ee3ea6 100644 --- a/packages/services/service-settings/src/manifests/sms.manifest.ts +++ b/packages/services/service-settings/src/manifests/sms.manifest.ts @@ -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' } }, ], diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 5e46908ad2..042ac4acf0 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -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: { @@ -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' } }, }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index e61a0f3d85..4b5378fac7 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -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: { @@ -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 を送信' } }, }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index f28fbc8368..af88619077 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -63,6 +63,7 @@ export const zhCN: TranslationData = { provider: { title: '服务商', description: '选择此工作区如何发送外发短信。' }, aliyun: { title: '阿里云短信' }, twilio: { title: 'Twilio' }, + limits: { title: '发送额度', description: '限制本部署的外发短信量。短信是付费通道,每一条都产生真实费用。' }, }, keys: { provider: { @@ -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: '发送测试短信' }, diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 9304c1ecc6..05fccc69d4 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/services/service-sms/src/index.ts b/packages/services/service-sms/src/index.ts index 23c3abc4c8..b13cbae2a3 100644 --- a/packages/services/service-sms/src/index.ts +++ b/packages/services/service-sms/src/index.ts @@ -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, diff --git a/packages/services/service-sms/src/sms-daily-quota.test.ts b/packages/services/service-sms/src/sms-daily-quota.test.ts new file mode 100644 index 0000000000..4d9ab0673f --- /dev/null +++ b/packages/services/service-sms/src/sms-daily-quota.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + SmsDailyQuota, + SMS_QUOTA_EXCEEDED_CODE, + normalizeDailyQuota, + secondsUntilNextUtcMidnight, + utcDayStamp, +} from './sms-daily-quota.js'; +import type { CounterStore } from '@objectstack/plugin-auth'; + +/** A memory counter store with the same tolerance the cache adapters have. */ +function memoryStore(): CounterStore & { entries: Map } { + const entries = new Map(); + return { + entries, + async get(key: string): Promise { + return entries.get(key) as T | undefined; + }, + async set(key: string, value: T): Promise { + entries.set(key, value); + }, + }; +} + +function logger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +/** 2026-08-06T12:00:00Z — midday, so the day boundary is unambiguous. */ +const NOON = Date.UTC(2026, 7, 6, 12, 0, 0); + +describe('normalizeDailyQuota — the clamp lives on the CONSUMER side (#5932)', () => { + it('reads a whole number as the ceiling', () => { + expect(normalizeDailyQuota(2000)).toEqual({ limit: 2000 }); + }); + + it('treats an unset / cleared value as "no limit", quietly', () => { + expect(normalizeDailyQuota(undefined)).toEqual({ limit: 0 }); + expect(normalizeDailyQuota(null)).toEqual({ limit: 0 }); + expect(normalizeDailyQuota('')).toEqual({ limit: 0 }); + expect(normalizeDailyQuota(' ')).toEqual({ limit: 0 }); + }); + + it('treats 0 as "no limit" (the documented disable value)', () => { + expect(normalizeDailyQuota(0)).toEqual({ limit: 0 }); + }); + + it('floors a fractional count — the stricter direction, no diagnostic', () => { + expect(normalizeDailyQuota(100.9)).toEqual({ limit: 100 }); + expect(normalizeDailyQuota('7.5')).toEqual({ limit: 7 }); + }); + + it('coerces a numeric string (an env override arrives before coercion in some hosts)', () => { + expect(normalizeDailyQuota('250')).toEqual({ limit: 250 }); + }); + + it('rejects garbage to "no limit" and NAMES the offending value', () => { + // Manifest `min: 0` is inert today (#5932) — every one of these can reach + // this reader intact, so each is pinned rather than assumed impossible. + expect(normalizeDailyQuota(-1)).toEqual({ limit: 0, rejected: '-1' }); + expect(normalizeDailyQuota(Number.NaN)).toEqual({ limit: 0, rejected: 'NaN' }); + expect(normalizeDailyQuota(Number.POSITIVE_INFINITY)).toEqual({ limit: 0, rejected: 'Infinity' }); + expect(normalizeDailyQuota('unlimited')).toEqual({ limit: 0, rejected: 'unlimited' }); + expect(normalizeDailyQuota(true)).toEqual({ limit: 0, rejected: 'true' }); + expect(normalizeDailyQuota({ n: 5 })).toEqual({ limit: 0, rejected: '{"n":5}' }); + }); +}); + +describe('SmsDailyQuota — the day window', () => { + it('stamps the key with the UTC calendar day', () => { + expect(utcDayStamp(NOON)).toBe('2026-08-06'); + expect(utcDayStamp(Date.UTC(2026, 7, 6, 23, 59, 59, 999))).toBe('2026-08-06'); + expect(utcDayStamp(Date.UTC(2026, 7, 7, 0, 0, 0))).toBe('2026-08-07'); + }); + + it('opens the window with exactly the seconds left until the next UTC midnight', () => { + expect(secondsUntilNextUtcMidnight(NOON)).toBe(12 * 3600); + expect(secondsUntilNextUtcMidnight(Date.UTC(2026, 7, 6, 23, 59, 59))).toBe(1); + // Never zero — `incrementFixedWindow` floors a TTL at 1s. + expect(secondsUntilNextUtcMidnight(Date.UTC(2026, 7, 6, 0, 0, 0))).toBe(24 * 3600); + }); + + it('rolls the counter over at 00:00 UTC — a fresh day starts at 1', async () => { + const store = memoryStore(); + let now = Date.UTC(2026, 7, 6, 23, 59, 0); + const quota = new SmsDailyQuota({ resolveStore: async () => store, now: () => now }); + quota.setQuota(2); + + expect(await quota.checkAndRecord()).toMatchObject({ ok: true, count: 1 }); + expect(await quota.checkAndRecord()).toMatchObject({ ok: true, count: 2 }); + expect(await quota.checkAndRecord()).toMatchObject({ ok: false, count: 3 }); + + // One minute later it is a new UTC day: a different key, a fresh budget. + now = Date.UTC(2026, 7, 7, 0, 0, 30); + expect(await quota.checkAndRecord()).toMatchObject({ ok: true, count: 1 }); + expect([...store.entries.keys()]).toEqual([ + 'sms-daily-sends:2026-08-06', + 'sms-daily-sends:2026-08-07', + ]); + }); + + it('keeps the window pinned to midnight rather than sliding forward per send', async () => { + const store = memoryStore(); + let now = Date.UTC(2026, 7, 6, 22, 0, 0); + const quota = new SmsDailyQuota({ resolveStore: async () => store, now: () => now }); + quota.setQuota(10); + await quota.checkAndRecord(); + const envelope = JSON.parse(String(store.entries.get('sms-daily-sends:2026-08-06'))); + expect(envelope.exp).toBe(Date.UTC(2026, 7, 7, 0, 0, 0)); + + now = Date.UTC(2026, 7, 6, 23, 0, 0); + await quota.checkAndRecord(); + const after = JSON.parse(String(store.entries.get('sms-daily-sends:2026-08-06'))); + expect(after.exp).toBe(envelope.exp); // NOT extended by the second send + expect(after.n).toBe(2); + }); +}); + +describe('SmsDailyQuota — enforcement', () => { + it('0 means unlimited: the counter is never even consulted', async () => { + const store = memoryStore(); + const quota = new SmsDailyQuota({ resolveStore: async () => store, now: () => NOON }); + quota.setQuota(0); + for (let i = 0; i < 50; i++) expect((await quota.checkAndRecord()).ok).toBe(true); + expect(store.entries.size).toBe(0); + }); + + it('admits exactly `limit` sends and refuses the next', async () => { + const store = memoryStore(); + const quota = new SmsDailyQuota({ resolveStore: async () => store, now: () => NOON }); + quota.setQuota(3); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect(await quota.checkAndRecord()).toMatchObject({ ok: false, count: 4, quota: 3 }); + }); + + it('a live setQuota change takes effect on the next send (no restart)', async () => { + const store = memoryStore(); + const quota = new SmsDailyQuota({ resolveStore: async () => store, now: () => NOON }); + quota.setQuota(1); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect((await quota.checkAndRecord()).ok).toBe(false); + quota.setQuota(10); // admin raises the ceiling + expect((await quota.checkAndRecord()).ok).toBe(true); + quota.setQuota(0); // …and then removes it entirely + expect((await quota.checkAndRecord()).ok).toBe(true); + }); + + it('counts through ONE store, so two service instances share the budget', async () => { + const store = memoryStore(); + const a = new SmsDailyQuota({ resolveStore: async () => store, now: () => NOON }); + const b = new SmsDailyQuota({ resolveStore: async () => store, now: () => NOON }); + a.setQuota(2); + b.setQuota(2); + expect((await a.checkAndRecord()).ok).toBe(true); + expect((await b.checkAndRecord()).ok).toBe(true); + expect((await a.checkAndRecord()).ok).toBe(false); + }); + + it('falls back to a bounded per-process store when no resolver is supplied', async () => { + const quota = new SmsDailyQuota({ now: () => NOON }); + quota.setQuota(1); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect((await quota.checkAndRecord()).ok).toBe(false); + }); +}); + +describe('SmsDailyQuota — observability (#5573: no key/token/secret/password field names)', () => { + const SENSITIVE = /key|token|secret|password/i; + + it('WARNs once per day at 80% consumption, with the count and no body', async () => { + const log = logger(); + const store = memoryStore(); + const quota = new SmsDailyQuota({ resolveStore: async () => store, logger: log, now: () => NOON }); + quota.setQuota(5); + for (let i = 0; i < 3; i++) await quota.checkAndRecord(); + expect(log.warn).not.toHaveBeenCalled(); + + await quota.checkAndRecord(); // 4/5 = 80% + expect(log.warn).toHaveBeenCalledTimes(1); + const [msg, meta] = log.warn.mock.calls[0]!; + expect(String(msg)).toMatch(/80% consumed/); + expect(meta).toEqual({ day: '2026-08-06', count: 4, quota: 5 }); + for (const field of Object.keys(meta as object)) expect(field).not.toMatch(SENSITIVE); + + await quota.checkAndRecord(); // 5/5 — still under the ceiling, no second line + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('WARNs once per day on the refusal, carrying the count and never a recipient/body', async () => { + const log = logger(); + const store = memoryStore(); + const quota = new SmsDailyQuota({ resolveStore: async () => store, logger: log, now: () => NOON }); + quota.setQuota(1); + await quota.checkAndRecord(); // 1/1 → also the 80% line + log.warn.mockClear(); + + await quota.checkAndRecord(); + await quota.checkAndRecord(); + await quota.checkAndRecord(); + expect(log.warn).toHaveBeenCalledTimes(1); // deduped, not a firehose + const [msg, meta] = log.warn.mock.calls[0]!; + expect(String(msg)).toMatch(/daily SMS quota reached/); + expect(meta).toMatchObject({ day: '2026-08-06', quota: 1 }); + for (const field of Object.keys(meta as object)) expect(field).not.toMatch(SENSITIVE); + }); + + it('reports a rejected quota value once per distinct value', () => { + const log = logger(); + const quota = new SmsDailyQuota({ logger: log }); + quota.setQuota(-5); + quota.setQuota(-5); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]![0])).toContain("'-5'"); + quota.setQuota('nope'); + expect(log.warn).toHaveBeenCalledTimes(2); + expect(quota.enforcedQuota).toBe(0); // degraded to "no limit", not to a block + }); +}); + +describe('SmsDailyQuota — fail-open (an SMS ceiling must not take sign-in down)', () => { + it('admits and WARNs once when the counter store is unreadable', async () => { + const log = logger(); + const broken: CounterStore = { + async get() { throw new Error('redis connection refused'); }, + async set() { throw new Error('redis connection refused'); }, + }; + const quota = new SmsDailyQuota({ resolveStore: async () => broken, logger: log, now: () => NOON }); + quota.setQuota(1); + + for (let i = 0; i < 5; i++) { + const d = await quota.checkAndRecord(); + expect(d.ok).toBe(true); // fail OPEN, every time + expect(d.count).toBeUndefined(); // no count is claimed when none was read + } + expect(log.warn).toHaveBeenCalledTimes(1); + expect(String(log.warn.mock.calls[0]![0])).toMatch(/FAILING OPEN/); + expect(String(log.warn.mock.calls[0]![0])).toContain('redis connection refused'); + }); + + it('admits when the store resolver itself explodes', async () => { + const quota = new SmsDailyQuota({ + resolveStore: async () => { throw new Error('no cache service'); }, + now: () => NOON, + }); + quota.setQuota(1); + expect((await quota.checkAndRecord()).ok).toBe(true); + expect((await quota.checkAndRecord()).ok).toBe(true); + }); +}); + +describe('SMS_QUOTA_EXCEEDED_CODE', () => { + it('is the same code the per-number OTP guard raises — the two walls look alike from outside', () => { + // plugin-auth's `assertPhoneOtpSendAllowed` throws + // `APIError('TOO_MANY_REQUESTS')`; #2814 asks the total-cost gate to be + // indistinguishable so an attacker cannot probe which budget they hit. + expect(SMS_QUOTA_EXCEEDED_CODE).toBe('TOO_MANY_REQUESTS'); + }); +}); diff --git a/packages/services/service-sms/src/sms-daily-quota.ts b/packages/services/service-sms/src/sms-daily-quota.ts new file mode 100644 index 0000000000..9313749320 --- /dev/null +++ b/packages/services/service-sms/src/sms-daily-quota.ts @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Global daily SMS send quota — the COST TOTAL gate (#2814). + * + * ## What this adds that the per-number guard cannot + * + * #2780 gave the OTP endpoints a per-NUMBER budget (60s cooldown + 5 sends per + * number per hour, `plugin-auth/src/otp-send-guard.ts`). That bounds what one + * phone number costs. It does not bound what the DEPLOYMENT costs: an attacker + * rotating through ten thousand distinct numbers keeps every one of them inside + * its own budget while the daily bill has no ceiling at all — classic SMS + * pumping / toll fraud. And the per-number guard sits in better-auth's + * `hooks.before`, so it only ever sees the auth endpoints: `notify(channels: + * ['sms'])` and the invitation path walk straight past it. + * + * This gate is therefore counted at the ONE place every outbound message + * already funnels through — `SmsService.send()` — so OTP, invitations and the + * messaging `sms` channel are all charged against the same budget, whatever + * door they came in by. + * + * ## Where it counts (#4790's answer, reused verbatim) + * + * A budget is only worth what its store is worth: counted per process, a + * declared "2000 per day" is really 2000×N across N nodes, and nothing says so + * (ADR-0049 — declared ≠ enforced). So this counts through the SAME resolution + * the auth counters use — `createLazyCounterStore` over the kernel `cache` + * service, resolved at COUNTING time (not at plugin init, the #4772 trap), + * with a bounded per-process fallback that announces itself. The counting + * algorithm is `incrementFixedWindow`, imported rather than re-implemented: + * this repo has exactly one fixed-window counter and #4790 said plainly that a + * third copy is not wanted. + * + * ## The window is a UTC calendar day, twice over + * + * "Daily quota" means the calendar day, not "24h from the first send", so the + * counter key carries the UTC date (`sms-daily-sends:2026-08-06`) AND the + * window is opened with exactly the seconds remaining until the next UTC + * midnight. Either mechanism alone would roll the budget over; together they + * cannot disagree — a clock skew that mis-sizes the TTL still lands on a fresh + * key at 00:00Z, and a store that ignores TTLs still starts a new key. + * + * ## Admission-time counting, and fail-open + * + * A unit is consumed when a send is ADMITTED, before the transport runs — + * exactly like `OtpSendGuard.checkAndRecord` and + * `createLazyCacheRateLimitStorage.consume`, both of which take the + * post-increment count and compare it to the cap. Two consequences, stated + * rather than discovered later: a transport failure still spends a unit (the + * conservative direction for a COST ceiling, and the alternative is a second + * store round-trip on every send), and attempts refused by this gate keep + * incrementing the day's counter, so the number in the log line is "attempts + * today", not "messages delivered today". + * + * Every store interaction is fail-OPEN: a cache outage must not take phone + * sign-in down with it (#2814 requirement 4). A degraded gate is announced once + * and then admits. + * + * ## What is NOT here + * + * The per-tenant dimension (`daily_quota_per_tenant`, keyed by + * `organizationId`) is deliberately absent: `SendSmsInput` + * (`@objectstack/spec/contracts/sms-service.ts`) carries no tenant identifier, + * and inventing a second, service-local spelling of one would be exactly the + * shadow contract AGENTS.md Prime Directive #12 forbids. See the issue thread + * on #2814 — the tenant identifier belongs on the spec contract or nowhere. + */ + +import { + InProcessCounterStore, + incrementFixedWindow, + type CounterStore, +} from '@objectstack/plugin-auth'; + +/** + * The error code a quota-refused send answers with, as the `CODE: message` + * prefix `SmsService` already uses for `VALIDATION_FAILED`. + * + * Deliberately the same code the per-number guard raises in `plugin-auth` + * (`APIError('TOO_MANY_REQUESTS')`), because #2814 asks the two walls to be + * indistinguishable from outside: an attacker must not be able to tell which + * budget they hit, and a legitimate caller needs no more than "not now". + * The message carries NO remaining-quota detail for the same reason. + */ +export const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS'; + +/** The refusal text handed back on `SendSmsResult.error`. Contains no counts. */ +export const SMS_QUOTA_EXCEEDED_ERROR = `${SMS_QUOTA_EXCEEDED_CODE}: daily SMS quota exhausted`; + +/** Key prefix for the day counter. Carries a date only — never a recipient. */ +const KEY_PREFIX = 'sms-daily-sends:'; + +/** Fraction of the quota at which the approaching-ceiling WARN fires. */ +const NEAR_LIMIT_RATIO = 0.8; + +type LoggerLike = { + info?(msg: string, meta?: Record): void; + warn?(msg: string, meta?: Record): void; +}; + +export interface SmsDailyQuotaOptions { + /** + * Resolve the store the day counter lives in — called on EVERY check, so a + * shared cache registered after this gate was constructed is picked up on the + * next send rather than never (#4772/#4790). `SmsServicePlugin` supplies + * `createLazyCounterStore(...)`; omitted ⇒ a per-process store, silently + * (the gate constructed standalone, e.g. in tests). + */ + resolveStore?: () => Promise; + /** Diagnostics sink. NEVER receives a message body or a recipient. */ + logger?: LoggerLike; + /** Clock override for tests. */ + now?: () => number; +} + +/** Outcome of one admission check. */ +export interface SmsDailyQuotaDecision { + /** Whether the send may proceed. */ + ok: boolean; + /** + * Attempts counted for the current UTC day AFTER this one, when the counter + * was actually consulted. Absent when the gate is off or degraded. + */ + count?: number; + /** The enforced ceiling this decision was measured against (`0` ⇒ off). */ + quota?: number; +} + +/** `YYYY-MM-DD` in UTC — the day the counter key is scoped to. */ +export function utcDayStamp(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +/** + * Seconds from `now` to the next UTC midnight, at least 1. This is the window + * `incrementFixedWindow` opens on the day's first send, so the counter expires + * with the day it belongs to instead of 24h after whenever it started. + */ +export function secondsUntilNextUtcMidnight(now: number): number { + const d = new Date(now); + const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1); + return Math.max(1, Math.ceil((next - now) / 1000)); +} + +/** + * Result of reading an authored quota value: the ceiling actually enforced, + * plus the offending input when one had to be discarded. + */ +export interface NormalizedDailyQuota { + /** Enforced ceiling. `0` means unlimited. Always a non-negative integer. */ + limit: number; + /** Set when the authored value was unusable and `0` was substituted. */ + rejected?: string; +} + +/** + * Clamp an authored `sms.daily_quota` into the value actually enforced. + * + * **This lives on the CONSUMER side on purpose (#5932).** `SettingsService` + * declares `min`/`max` on a manifest specifier but `validatePatch` does not + * enforce them today, so a `min: 0` declaration is inert: negative, fractional + * and outright non-numeric values all reach a reader intact. Anything that + * depends on the manifest having filtered them is declared-but-unenforced + * (ADR-0049), so the clamp is here, where the value becomes behaviour, and is + * pinned by tests. + * + * The rules, and why each is the safe direction for a paid channel: + * + * - **absent / empty string** → `0` (unlimited), quietly. That is "the operator + * has not configured a ceiling", which is the shipped default, not an error. + * - **finite number ≥ 0** → `Math.floor(v)`. A fractional message count is + * rounded DOWN, the stricter direction for a cost ceiling, and is not worth a + * diagnostic — `100.5` unambiguously means "at most 100 messages". + * - **negative, NaN, ±Infinity, or a non-numeric type** → `0` (unlimited) plus + * a named rejection the caller logs LOUDLY. + * + * That last rule is the one worth arguing. Two other readings exist and both + * are worse here. Refusing to send at all turns one typo in a settings form + * into a total outage of phone sign-in — the precise failure #2814 requirement + * 4 rules out ("配额闸不能把登录拖下水"), and the same reasoning `SettingsService` + * applies when it ignores a rejected `OS_*` override rather than acting on it. + * Substituting some other default invents a ceiling nobody declared and hides + * the typo behind plausible behaviour (#5152's lesson, where a typo'd + * `invite_only` read as `auto` left an operator believing a wall was up). + * Ignoring the value and SAYING SO leaves the deployment exactly where it was + * before the bad edit, with a line naming the value to fix. + */ +export function normalizeDailyQuota(raw: unknown): NormalizedDailyQuota { + if (raw === undefined || raw === null) return { limit: 0 }; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (trimmed.length === 0) return { limit: 0 }; + const parsed = Number(trimmed); + if (Number.isFinite(parsed) && parsed >= 0) return { limit: Math.floor(parsed) }; + return { limit: 0, rejected: trimmed }; + } + if (typeof raw === 'number') { + if (Number.isFinite(raw) && raw >= 0) return { limit: Math.floor(raw) }; + return { limit: 0, rejected: String(raw) }; + } + return { limit: 0, rejected: typeof raw === 'object' ? JSON.stringify(raw) : String(raw) }; +} + +/** + * The global daily send ceiling, counted once per admitted send. + * + * Constructed by `SmsServicePlugin` and handed to `SmsService`; `setQuota` is + * called on every `sms` settings change so an admin edit takes effect without a + * restart (same live-swap contract as the transport). + */ +export class SmsDailyQuota { + private limit = 0; + private readonly resolveStore: () => Promise; + private readonly logger?: LoggerLike; + private readonly now: () => number; + + /** + * Per-process fallback used when no resolver was supplied at all (the gate + * constructed standalone). The SAME bounded store the auth counters degrade + * to — one fallback implementation across the repo, not a second one that can + * drift (#4790). + */ + private readonly fallback = new InProcessCounterStore(); + + /** The last authored value reported as unusable — deduped, per value. */ + private reportedRejection?: string; + /** UTC day stamp the approaching-ceiling WARN has already fired for. */ + private nearLimitWarnedFor?: string; + /** UTC day stamp the ceiling-reached WARN has already fired for. */ + private exceededWarnedFor?: string; + /** Store-outage WARN is once per process — see `checkAndRecord`. */ + private degradedWarned = false; + + constructor(options: SmsDailyQuotaOptions = {}) { + this.logger = options.logger; + this.now = options.now ?? Date.now; + this.resolveStore = options.resolveStore ?? (async () => this.fallback); + } + + /** + * Apply an authored `sms.daily_quota`. Anything unusable degrades to + * "unlimited" and is reported once per distinct offending value — see + * {@link normalizeDailyQuota} for why that is the safe direction. + */ + setQuota(raw: unknown): void { + const { limit, rejected } = normalizeDailyQuota(raw); + this.limit = limit; + if (rejected !== undefined && rejected !== this.reportedRejection) { + this.reportedRejection = rejected; + this.logger?.warn?.( + `[sms] daily_quota value '${rejected}' is not a usable message count — the daily SMS quota is ` + + 'NOT enforced until it is corrected. Use a non-negative whole number, or 0 for "no limit".', + ); + } + } + + /** The ceiling actually in force (`0` ⇒ unlimited). @internal test seam */ + get enforcedQuota(): number { + return this.limit; + } + + /** + * Charge one send against today's budget and answer whether it may proceed. + * + * Never throws: a store outage fails OPEN (announced once per process), for + * the reason in the file header — an SMS cost ceiling that can block sign-in + * is a worse problem than the one it solves. + */ + async checkAndRecord(): Promise { + if (this.limit <= 0) return { ok: true, quota: 0 }; + const now = this.now(); + const day = utcDayStamp(now); + try { + const store = await this.resolveStore(); + const { count } = await incrementFixedWindow( + store, + KEY_PREFIX + day, + secondsUntilNextUtcMidnight(now), + now, + ); + + if (count > this.limit) { + if (this.exceededWarnedFor !== day) { + this.exceededWarnedFor = day; + this.logger?.warn?.( + `[sms] daily SMS quota reached — further sends are refused until 00:00 UTC.`, + { day, count, quota: this.limit }, + ); + } + return { ok: false, count, quota: this.limit }; + } + + if (count >= Math.ceil(this.limit * NEAR_LIMIT_RATIO) && this.nearLimitWarnedFor !== day) { + this.nearLimitWarnedFor = day; + this.logger?.warn?.( + `[sms] daily SMS quota is ${Math.round(NEAR_LIMIT_RATIO * 100)}% consumed.`, + { day, count, quota: this.limit }, + ); + } + return { ok: true, count, quota: this.limit }; + } catch (err) { + if (!this.degradedWarned) { + this.degradedWarned = true; + this.logger?.warn?.( + '[sms] daily SMS quota counter is unreadable (' + + String((err as Error)?.message ?? err) + + ') — the gate is FAILING OPEN and today\'s spend is unbounded until the counter store recovers. ' + + 'Sign-in is deliberately not taken down with it (#2814).', + ); + } + return { ok: true }; + } + } +} diff --git a/packages/services/service-sms/src/sms-plugin.test.ts b/packages/services/service-sms/src/sms-plugin.test.ts index 8e99f89047..2aa25d1b65 100644 --- a/packages/services/service-sms/src/sms-plugin.test.ts +++ b/packages/services/service-sms/src/sms-plugin.test.ts @@ -153,3 +153,83 @@ describe('SmsServicePlugin', () => { expect(good.message).not.toContain('5550006'); // masked }); }); + +describe('SmsServicePlugin — daily quota binding (#2814)', () => { + it('applies sms.daily_quota from settings at kernel:ready', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log', daily_quota: 2 } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + expect((await svc.send({ to: '+8613800000001', body: 'a' })).status).toBe('sent'); + expect((await svc.send({ to: '+8613800000002', body: 'b' })).status).toBe('sent'); + const refused = await svc.send({ to: '+8613800000003', body: 'c' }); + expect(refused.status).toBe('failed'); + expect(refused.error).toContain('TOO_MANY_REQUESTS'); + }); + + it('live-applies a quota change without a restart', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log', daily_quota: 1 } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + await svc.send({ to: '+8613800000001', body: 'a' }); + expect((await svc.send({ to: '+8613800000002', body: 'b' })).status).toBe('failed'); + + harness.setValues({ provider: 'log', daily_quota: 0 }); // admin lifts the cap + await harness.notifyChange(); + expect((await svc.send({ to: '+8613800000003', body: 'c' })).status).toBe('sent'); + }); + + it('binds the quota even when the host injected its own transport', async () => { + // "How much may this deployment spend today" is an operator policy about + // the deployment, not a property of whichever transport delivers — so the + // host-transport short-circuit that skips the PROVIDER settings must not + // skip this one. + const send = vi.fn(async () => ({ messageId: 'host_1' })); + const harness = fakeCtx({ settingsValues: { provider: 'aliyun', daily_quota: 1 } }); + const plugin = new SmsServicePlugin({ transport: { send } }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + expect((await svc.send({ to: '+8613800000001', body: 'a' })).status).toBe('sent'); + expect((await svc.send({ to: '+8613800000002', body: 'b' })).status).toBe('failed'); + // …and the injected transport is still the one that delivered. + expect(send).toHaveBeenCalledTimes(1); + }); + + it('degrades an unusable quota value to "no limit" and says so', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log', daily_quota: -3 } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + for (let i = 0; i < 5; i++) { + expect((await svc.send({ to: '+8613800000001', body: 'x' })).status).toBe('sent'); + } + const warned = harness.logger.warn.mock.calls.map((c: any[]) => String(c[0])).join('\n'); + expect(warned).toContain("daily_quota value '-3'"); + }); + + it('an unset namespace leaves the gate off', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log' } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + for (let i = 0; i < 10; i++) { + expect((await svc.send({ to: '+8613800000001', body: 'x' })).status).toBe('sent'); + } + }); +}); diff --git a/packages/services/service-sms/src/sms-plugin.ts b/packages/services/service-sms/src/sms-plugin.ts index ed069a5d72..f4e8513be9 100644 --- a/packages/services/service-sms/src/sms-plugin.ts +++ b/packages/services/service-sms/src/sms-plugin.ts @@ -2,7 +2,9 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { ISmsTransport } from '@objectstack/spec/contracts'; +import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth'; import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js'; +import { SmsDailyQuota } from './sms-daily-quota.js'; import { makeSmsTransport, type SmsProviderTag } from './transports/index.js'; /** @@ -97,11 +99,50 @@ export class SmsServicePlugin implements Plugin { private readonly options: SmsServicePluginOptions; private service?: SmsService; + private dailyQuota?: SmsDailyQuota; constructor(options: SmsServicePluginOptions = {}) { this.options = options; } + /** + * Build the daily cost gate (#2814) over the kernel `cache` service. + * + * `resolveCache` is copied in shape from `AuthPlugin.init()` on purpose, for + * the two reasons stated there: the `cache` service is registered ASYNC (so + * `getService` throws for it and `getServiceAsync` is the only accessor that + * works), and resolution has to happen when a counter is CONSUMED rather than + * at init, or a deployment that registers its cache after this plugin freezes + * a "no shared store" answer for the life of the process (#4772). The + * degraded case is announced by `createLazyCounterStore` itself, named for + * this subject. + */ + private buildDailyQuota(ctx: PluginContext): SmsDailyQuota { + const resolveCache = async (): Promise => { + let cache: any; + try { + cache = await (ctx as { getServiceAsync?: (n: string) => Promise }) + .getServiceAsync?.('cache'); + } catch { + return undefined; + } + if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') return cache; + return undefined; + }; + return new SmsDailyQuota({ + resolveStore: createLazyCounterStore({ + resolveCache, + logger: ctx.logger, + logPrefix: '[sms]', + subject: 'daily SMS send quota (#2814)', + degradedImpact: + 'The ceiling is still enforced, but PER NODE: an N-node deployment can spend up to N× the ' + + 'configured number of PAID SMS per day, which is exactly the total-cost hole this gate exists to close', + }), + logger: ctx.logger, + }); + } + private resolveInitialTransport(ctx: PluginContext): { transport: ISmsTransport; configured: boolean } { if (this.options.transport) return { transport: this.options.transport, configured: true }; const provider = this.options.provider ?? 'log'; @@ -129,11 +170,13 @@ export class SmsServicePlugin implements Plugin { } else { ctx.logger.info(`SmsServicePlugin: using '${this.options.provider ?? 'custom'}' provider`); } + this.dailyQuota = this.buildDailyQuota(ctx); this.service = new SmsService({ transport, configured, retries: this.options.retries, logger: ctx.logger, + dailyQuota: this.dailyQuota, }); ctx.registerService('sms', this.service); ctx.logger.info('SmsServicePlugin: sms service registered'); @@ -142,9 +185,6 @@ export class SmsServicePlugin implements Plugin { async start(ctx: PluginContext): Promise { ctx.hook('kernel:ready', async () => { if (!this.service) return; - // A host-injected transport is authoritative — settings only manage - // the provider-tag path. - if (this.options.transport) return; try { const settings = ctx.getService('settings'); if (!settings || typeof settings.getNamespace !== 'function') return; @@ -156,7 +196,17 @@ export class SmsServicePlugin implements Plugin { for (const [k, v] of Object.entries(payload.values as Record)) { values[k] = v?.value; } - this.applySmsSettings(values, ctx); + // #2814 — the daily cost ceiling binds for EVERY composition, + // including a host-injected transport: "how much may this + // deployment spend today" is an operator policy about the + // deployment, not a property of whichever transport delivers. + // Read by VALUE, never by `ResolvedSettingValue.source` — an + // env-locked `OS_SMS_DAILY_QUOTA` and an admin-saved row are the + // same instruction to this reader (#5536). + this.dailyQuota?.setQuota(values.daily_quota); + // A host-injected transport, by contrast, IS authoritative — the + // settings form only manages the provider-tag path. + if (!this.options.transport) this.applySmsSettings(values, ctx); } catch (err: any) { ctx.logger.warn('SmsServicePlugin: failed to apply sms settings: ' + (err?.message ?? err)); } @@ -169,8 +219,10 @@ export class SmsServicePlugin implements Plugin { // `sms/test` action — validate the (possibly unsaved) form values by // sending a real test message through a one-shot transport, mirroring - // the `mail/test` handler in EmailServicePlugin. - if (typeof settings.registerAction === 'function') { + // the `mail/test` handler in EmailServicePlugin. Still skipped when the + // host injected its own transport: the form's provider fields describe + // nothing that composition uses, so testing them would be theatre. + if (!this.options.transport && typeof settings.registerAction === 'function') { const svc = this.service; settings.registerAction('sms', 'test', async ({ values, payload, ctx: actionCtx }: any) => { const overrides = (payload && typeof payload === 'object' && payload.values && typeof payload.values === 'object') diff --git a/packages/services/service-sms/src/sms-service.test.ts b/packages/services/service-sms/src/sms-service.test.ts index d54940ac3d..be5f63449c 100644 --- a/packages/services/service-sms/src/sms-service.test.ts +++ b/packages/services/service-sms/src/sms-service.test.ts @@ -2,6 +2,11 @@ import { describe, it, expect, vi } from 'vitest'; import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js'; +import { + SmsDailyQuota, + SMS_QUOTA_EXCEEDED_CODE, + SMS_QUOTA_EXCEEDED_ERROR, +} from './sms-daily-quota.js'; const collectingLogger = () => { const lines: string[] = []; @@ -127,3 +132,75 @@ describe('LogSmsTransport', () => { expect(logger.lines.join('\n')).toContain('code 424242'); }); }); + +describe('SmsService — the daily cost ceiling is charged HERE (#2814)', () => { + const NOON = Date.UTC(2026, 7, 6, 12, 0, 0); + const quotaOf = (limit: number, logger?: { info: any; warn: any }) => { + const q = new SmsDailyQuota({ now: () => NOON, ...(logger ? { logger } : {}) }); + q.setQuota(limit); + return q; + }; + + it('refuses past the ceiling with the per-number guard’s code and no quota detail', async () => { + const send = vi.fn(async () => ({ messageId: 'prov_1' })); + const svc = new SmsService({ transport: { send }, configured: true, dailyQuota: quotaOf(2) }); + + expect((await svc.send({ to: '+8613800000001', body: 'a' })).status).toBe('sent'); + expect((await svc.send({ to: '+8613800000002', body: 'b' })).status).toBe('sent'); + + const refused = await svc.send({ to: '+8613800000003', body: 'c' }); + expect(refused.status).toBe('failed'); + expect(refused.error).toBe(SMS_QUOTA_EXCEEDED_ERROR); + expect(refused.error).toContain(SMS_QUOTA_EXCEEDED_CODE); + // "不泄露配额剩余细节" — the refusal carries no numbers at all. + expect(refused.error).not.toMatch(/\d/); + expect(send).toHaveBeenCalledTimes(2); // the transport was never reached + }); + + it('counts EVERY caller against one budget — OTP, invitation and notification alike', async () => { + const send = vi.fn(async () => ({ messageId: 'p' })); + const svc = new SmsService({ transport: { send }, configured: true, dailyQuota: quotaOf(2) }); + // Three different call shapes, one budget: the point of moving the gate + // into the service instead of the auth endpoints. + await svc.send({ to: '+8613800000001', body: 'otp 123456', templateParams: { code: '123456' } }); + await svc.send({ to: '+8613800000002', body: 'invite', templateParams: { content: 'invite' } }); + const third = await svc.send({ to: '+8613800000003', body: 'notify', templateParams: { content: 'notify' } }); + expect(third.status).toBe('failed'); + expect(third.error).toContain(SMS_QUOTA_EXCEEDED_CODE); + }); + + it('does not spend a unit on input the service rejects outright', async () => { + const send = vi.fn(async () => ({ messageId: 'p' })); + const quota = quotaOf(1); + const svc = new SmsService({ transport: { send }, configured: true, dailyQuota: quota }); + await expect(svc.send({ to: 'not-a-phone', body: 'x' })).rejects.toThrow(/VALIDATION_FAILED/); + // The malformed call cost nothing, so the one budgeted send still gets through. + expect((await svc.send({ to: '+8613800000001', body: 'x' })).status).toBe('sent'); + }); + + it('logs the refusal with a MASKED recipient and never the body', async () => { + const logger = collectingLogger(); + const svc = new SmsService({ + transport: { send: async () => ({ messageId: 'p' }) }, + configured: true, + logger, + dailyQuota: quotaOf(1), + }); + await svc.send({ to: '+8613812345678', body: 'code 424242' }); + await svc.send({ to: '+8613812345678', body: 'code 999999' }); + const out = logger.lines.join('\n'); + expect(out).toContain('daily quota exhausted'); + expect(out).not.toContain('424242'); + expect(out).not.toContain('999999'); + expect(out).not.toContain('+8613812345678'); + }); + + it('is entirely absent when no quota is wired (pre-#2814 behaviour)', async () => { + const send = vi.fn(async () => ({ messageId: 'p' })); + const svc = new SmsService({ transport: { send }, configured: true }); + for (let i = 0; i < 20; i++) { + expect((await svc.send({ to: '+8613800000001', body: 'x' })).status).toBe('sent'); + } + expect(send).toHaveBeenCalledTimes(20); + }); +}); diff --git a/packages/services/service-sms/src/sms-service.ts b/packages/services/service-sms/src/sms-service.ts index 135991cbc1..afd2f8f98e 100644 --- a/packages/services/service-sms/src/sms-service.ts +++ b/packages/services/service-sms/src/sms-service.ts @@ -8,6 +8,7 @@ import type { SendSmsResult, SmsTransportSendResult, } from '@objectstack/spec/contracts'; +import { SMS_QUOTA_EXCEEDED_ERROR, type SmsDailyQuota } from './sms-daily-quota.js'; /** * Normalize + validate a recipient phone number. Accepts E.164 and common @@ -74,6 +75,14 @@ export interface SmsServiceOptions { info: (msg: string, meta?: any) => void; warn: (msg: string, meta?: any) => void; }; + /** + * The deployment-wide daily send ceiling (#2814). Charged once per admitted + * send, HERE rather than at the auth endpoints, so OTP, invitations and the + * messaging `sms` channel are all counted against the one budget — see + * `sms-daily-quota.ts`. Omitted ⇒ no total-cost gate (the pre-#2814 + * behaviour). + */ + dailyQuota?: SmsDailyQuota; } /** @@ -119,6 +128,30 @@ export class SmsService implements ISmsService { }; const id = `sms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + // #2814 — the deployment's daily cost ceiling, charged after the input is + // known to be a real send (a malformed recipient never spent anything, so + // it must not spend a unit either) and before the transport, which is the + // only ordering that actually caps spend. Refusal is reported as a failed + // send carrying the SAME `TOO_MANY_REQUESTS` code the per-number guard + // raises, with no remaining-quota detail — outside, the two walls are + // indistinguishable on purpose. + // + // Measured caveat (#6039): on the auth OTP path that code does NOT reach + // the HTTP caller today. `AuthManager.deliverPhoneOtp` rethrows a plain + // `Error`, and better-call answers 500 for anything that is not an + // `APIError` — so the endpoint returns 500 while the per-number guard on + // the same endpoint returns 429. Closing that needs a change inside + // plugin-auth, which is why it is filed rather than papered over here. + if (this.options.dailyQuota) { + const decision = await this.options.dailyQuota.checkAndRecord(); + if (!decision.ok) { + this.options.logger?.warn?.( + `[SmsService] send to ${maskPhoneNumber(to)} refused: daily quota exhausted`, + ); + return { id, status: 'failed', error: SMS_QUOTA_EXCEEDED_ERROR }; + } + } const maxAttempts = (this.options.retries ?? 0) + 1; let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt++) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51faca1b17..92b82aa68a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2345,6 +2345,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/plugin-auth': + specifier: workspace:* + version: link:../../plugins/plugin-auth '@objectstack/spec': specifier: workspace:* version: link:../../spec