@objectstack/service-sms 17.0.0-rc.2 → 17.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,261 @@
1
1
  # @objectstack/service-sms
2
2
 
3
+ ## 17.0.0-rc.4
4
+
5
+ ### Minor Changes
6
+
7
+ - de770bf: fix(cli,service-sms)!: `OS_SMS_PROVIDER=twilo` now fails the boot instead of silently becoming the log transport (#5713)
8
+
9
+ **BREAKING for one configuration: a provider tag no SMS transport can build.**
10
+ `os serve` used to hand `OS_SMS_PROVIDER` (or `config.sms.provider`) straight to
11
+ `SmsServicePlugin` with nothing to compare it against. The plugin then caught the
12
+ `makeSmsTransport: unknown provider 'twilo'` throw, substituted `LogSmsTransport`,
13
+ and booted normally — measured, not inferred:
14
+
15
+ ```
16
+ new SmsServicePlugin({ provider: 'twilo' }).init(ctx)
17
+ booted_without_throw: true transport_class: 'LogSmsTransport'
18
+ isConfigured(): false logger.warn × 1, logger.error × 0
19
+ service.send(…) → { status: 'sent', messageId: 'dev-sms-…' }
20
+ ```
21
+
22
+ So a phone-OTP sign-in answered "code sent", the user waited for an SMS that was
23
+ never dispatched, and the one `warn` line scrolled past in the boot log. That is
24
+ the declared-but-not-delivered shape of Prime Directive #10, and the same one
25
+ #5132 closed for **mail** in the neighbouring arm of the very same capability
26
+ loop.
27
+
28
+ Three gates already guard the `sms` provider value and none of them could see
29
+ this path: the `sms` settings namespace declares `provider` as a `select` with an
30
+ options table, #5131 enforces that table on the write path, and #5204 closed the
31
+ `SettingsService` env-override branch. All three live behind `SettingsService` —
32
+ this read happens while the kernel is being assembled, _before_ a settings
33
+ service exists.
34
+
35
+ **`resolveSmsCapabilityArg` now refuses a provider tag outside
36
+ `log` / `aliyun` / `twilio`**, the way its neighbouring `resolveEmailCapabilityArg`
37
+ already did, and the capability loop turns that into the loud failure it should
38
+ be — a hard boot error when the app declared `requires: ['sms']`, otherwise a
39
+ `console.error` and no SMS service.
40
+
41
+ **What it deliberately does NOT do:** demand credentials. Unlike mail, SMS
42
+ provider credentials are not a boot-time input — the `sms` settings namespace
43
+ binds them at `kernel:ready`, and that is their documented home. A bare
44
+ `OS_SMS_PROVIDER=twilio` on a host whose Twilio keys live in Settings is a
45
+ complete configuration and passes through untouched. `SmsServicePlugin`'s own
46
+ fallback is likewise untouched: for a _known_ provider with incomplete
47
+ constructor credentials it is correct (the settings bind can still swap in a
48
+ working transport), and it remains the last line of defence for hosts that
49
+ construct the plugin themselves. `os serve` simply stops feeding it input it can
50
+ never use.
51
+
52
+ **Who is affected:** deployments that set `OS_SMS_PROVIDER` (or
53
+ `config.sms.provider`) to a value outside the supported three — in practice a
54
+ typo, or a provider that was never implemented — and relied on the fallback to
55
+ boot. An unset `OS_SMS_PROVIDER` still defaults to `log`; every supported tag
56
+ still boots with or without credentials.
57
+
58
+ **Migration — one line, either direction:**
59
+
60
+ - the environment is _not_ meant to send SMS → `OS_SMS_PROVIDER=log` (that
61
+ explicit value is the supported way to say so, and why refusing the others is
62
+ fair);
63
+ - the environment _is_ meant to send SMS → fix the tag to `aliyun` or `twilio`
64
+ and put the credentials in Settings → SMS Delivery (or
65
+ `config.sms.providerOptions`).
66
+
67
+ The error names the consequence and both fixes, per AGENTS.md's
68
+ degradation-log-level rule.
69
+
70
+ `@objectstack/service-sms` gains the vocabulary the CLI reads instead of
71
+ restating: `SMS_TRANSPORT_PROVIDERS` and `isSmsTransportProvider()`, with
72
+ `SmsProviderTag` now derived from the array rather than declared beside it. One
73
+ vocabulary, two consumers — a second literal list in the CLI is how the mail
74
+ settings dropdown and the mail transports drifted apart in the first place
75
+ (#5094).
76
+
77
+ - 9c90ea0: feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814)
78
+
79
+ #2780 给 OTP 端点落了**按号码**的防滥用(60s 冷却 + 每号码 5 条/小时)。那挡住的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」:攻击者轮换上万个不同号码时,每个号码都稳稳待在自己的预算里,而日累计账单没有任何上限——这正是 SMS pumping / toll fraud 的典型打法。更要紧的是,按号码那道闸住在 better-auth 的 `hooks.before` 里,只看得见 auth 端点:`notify(channels:['sms'])` 与邀请短信从旁边直接走过去,一条都不计数。
80
+
81
+ 本次新增一道**总量**闸,扣减点放在所有出站短信本来就必经的那一处 —— `SmsService.send()`。OTP、邀请、messaging `sms` channel 三条路无论从哪扇门进来,都记在同一本账上。
82
+
83
+ ## 新增设置项
84
+
85
+ `sms` 命名空间新增 `daily_quota`(Daily send limit,number,默认 `0` = 不限):这套部署每个 **UTC 自然日**允许发出的短信总条数。超出后拒发,直到 00:00 UTC。env 覆盖沿用既有的每键机制,无需额外接线:`OS_SMS_DAILY_QUOTA=2500`。
86
+
87
+ `0` 是出厂姿态,所以升级本身不改变任何现有部署的发送行为——闸门要由运营者显式配置才会闭合。
88
+
89
+ ## Observable behaviour change
90
+
91
+ **配置配额后,发送可能被拒**,两条路径的表现分别是:
92
+
93
+ - OTP / 邀请路径 —— `SendSmsResult.status='failed'`,`error` 为 `TOO_MANY_REQUESTS: daily SMS quota exhausted`。刻意与按号码闸抛出的 `TOO_MANY_REQUESTS` 用同一个码,且**不带任何剩余额度细节**:从外面看,两道墙必须长得一样,攻击者不该能试探出自己撞的是哪一道。
94
+ ⚠️ 但这个码**目前到不了 HTTP 调用方**:`AuthManager.deliverPhoneOtp` 把它重抛成普通 `Error`,而 better-call 对非 `APIError` 一律回 500(实测,见 #6039)。也就是说 OTP 端点上,按号码闸回 429、总量闸回 500。补齐要动 plugin-auth,已单独立案。
95
+ - messaging `sms` channel —— `SendResult.ok=false`,且 `classifyError` 返回 `'rate_limited'`(此前一律 `'retryable'`)。投递落进 outbox 走退避重试 / 死信,不会被静默丢弃;`rate_limited` 与 `retryable` 走同一条重试阶梯,但把「额度用尽」与「网关抖动」在投递记录上区分开。
96
+
97
+ ## 计数落在哪里
98
+
99
+ 复用仓内唯一那份定窗计数(`incrementFixedWindow`)与它的惰性存储解析(`createLazyCounterStore`,#4772/#4790),不写第三份:
100
+
101
+ - 有 kernel `cache` 服务时计在共享 cache(集群共享与否取决于 cache 本身);
102
+ - 解析不到时降级为有界的进程内计数,并由解析器**点名**打一条 warn,说明降级的代价(N 节点部署最多可花 N× 配额);
103
+ - 解析在**计数被消费时**发生,而非插件 init —— 后注册的 cache 也能在下一次发送时被接上(#4772 的坑)。
104
+
105
+ 窗口是 UTC 自然日,且由两个机制同时保证:计数键带 UTC 日期(`sms-daily-sends:2026-08-06`),窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾。
106
+
107
+ ## 两条刻意的姿态
108
+
109
+ - **fail-open**:计数存储读不到时,闸门**放行**并打一次 warn。短信成本闸不能把登录拖下水(#2814 诉求 4)。
110
+ - **配额值的钳制在消费侧**:manifest 上的 `min: 0` 今天并不被 `validatePatch` 执行(#5932),所以负值 / `NaN` / `Infinity` / 非数字都会原样抵达读取方。这些一律降级为 `0`(不限)并**点名**打 warn,而不是拒发、也不是替运营者编一个别的默认值——一个设置表单里的手误不该变成手机登录的全站故障,而编一个没人声明过的上限只会把手误藏进看似合理的行为里。
111
+
112
+ ## 不在本次范围
113
+
114
+ 诉求中的**每租户日配额**(`daily_quota_per_tenant`)未实现:`SendSmsInput`(`@objectstack/spec/contracts`)不携带任何租户标识,而在 service 侧另造一个只此一家的拼法就是 Prime Directive #12 明令禁止的影子契约。租户维度要么落在 spec 契约上,要么不落——详见 #2814 上的讨论。
115
+
116
+ ### Patch Changes
117
+
118
+ - Updated dependencies [9fe9c1d]
119
+ - Updated dependencies [d4e0809]
120
+ - Updated dependencies [f724f69]
121
+ - Updated dependencies [28ad90e]
122
+ - Updated dependencies [f8644c7]
123
+ - Updated dependencies [306ca50]
124
+ - Updated dependencies [978fed2]
125
+ - Updated dependencies [cfc293f]
126
+ - Updated dependencies [de70b42]
127
+ - Updated dependencies [fb3d99b]
128
+ - Updated dependencies [cdfbee2]
129
+ - Updated dependencies [29c6c9d]
130
+ - Updated dependencies [d21c001]
131
+ - Updated dependencies [f1cc3a3]
132
+ - Updated dependencies [ddc2527]
133
+ - Updated dependencies [553a47f]
134
+ - Updated dependencies [7a40b7a]
135
+ - Updated dependencies [7cf1531]
136
+ - Updated dependencies [586d6f7]
137
+ - Updated dependencies [2d14b35]
138
+ - Updated dependencies [a3a884d]
139
+ - Updated dependencies [cfed092]
140
+ - Updated dependencies [93929c2]
141
+ - Updated dependencies [2e284b2]
142
+ - Updated dependencies [1b49eaf]
143
+ - Updated dependencies [0161c7f]
144
+ - Updated dependencies [e900015]
145
+ - Updated dependencies [b5bdf48]
146
+ - Updated dependencies [a019e52]
147
+ - Updated dependencies [64fc6d5]
148
+ - Updated dependencies [b746aa0]
149
+ - Updated dependencies [947d4f9]
150
+ - Updated dependencies [eaaf03c]
151
+ - Updated dependencies [d17df80]
152
+ - Updated dependencies [7d0e7b5]
153
+ - Updated dependencies [6513c17]
154
+ - Updated dependencies [c142ced]
155
+ - Updated dependencies [eda599e]
156
+ - Updated dependencies [c001422]
157
+ - Updated dependencies [77022a9]
158
+ - Updated dependencies [52760bf]
159
+ - Updated dependencies [5543020]
160
+ - Updated dependencies [880d343]
161
+ - Updated dependencies [6e82972]
162
+ - Updated dependencies [4615a18]
163
+ - Updated dependencies [7f62706]
164
+ - Updated dependencies [667fa44]
165
+ - Updated dependencies [37e38d1]
166
+ - Updated dependencies [1eb13a0]
167
+ - Updated dependencies [c52e608]
168
+ - Updated dependencies [4dfd002]
169
+ - Updated dependencies [77be690]
170
+ - Updated dependencies [811c30c]
171
+ - Updated dependencies [b49ccfd]
172
+ - Updated dependencies [85d95e7]
173
+ - Updated dependencies [168f60f]
174
+ - Updated dependencies [244ca86]
175
+ - Updated dependencies [546ab3c]
176
+ - Updated dependencies [0b51bb6]
177
+ - Updated dependencies [08f93bc]
178
+ - Updated dependencies [d9971d3]
179
+ - Updated dependencies [eb3e650]
180
+ - Updated dependencies [abeb375]
181
+ - Updated dependencies [ef4efa8]
182
+ - Updated dependencies [cbb6a5c]
183
+ - Updated dependencies [795b6e1]
184
+ - Updated dependencies [175d789]
185
+ - Updated dependencies [55dbbba]
186
+ - Updated dependencies [72c3c86]
187
+ - Updated dependencies [7f1a635]
188
+ - Updated dependencies [9fa6bab]
189
+ - Updated dependencies [0f2fdcd]
190
+ - Updated dependencies [8ffa8b9]
191
+ - Updated dependencies [674ac99]
192
+ - Updated dependencies [61dc08e]
193
+ - Updated dependencies [8dcf607]
194
+ - Updated dependencies [b691ba9]
195
+ - Updated dependencies [502564d]
196
+ - Updated dependencies [471839d]
197
+ - Updated dependencies [46365ab]
198
+ - Updated dependencies [b508244]
199
+ - Updated dependencies [594508e]
200
+ - Updated dependencies [1c625ca]
201
+ - Updated dependencies [71f205d]
202
+ - Updated dependencies [414395b]
203
+ - Updated dependencies [c5adfe1]
204
+ - Updated dependencies [26e1029]
205
+ - Updated dependencies [4addd9d]
206
+ - Updated dependencies [108ba8d]
207
+ - Updated dependencies [b4ad984]
208
+ - Updated dependencies [a9f32df]
209
+ - Updated dependencies [aeb9b27]
210
+ - Updated dependencies [7d27da0]
211
+ - Updated dependencies [de113a4]
212
+ - Updated dependencies [db8c285]
213
+ - Updated dependencies [089767f]
214
+ - Updated dependencies [e4c8b6c]
215
+ - Updated dependencies [acb10f6]
216
+ - Updated dependencies [1c3da1f]
217
+ - Updated dependencies [a34fd2e]
218
+ - Updated dependencies [889ae47]
219
+ - Updated dependencies [4f4c3fb]
220
+ - Updated dependencies [7adc841]
221
+ - Updated dependencies [4845f85]
222
+ - Updated dependencies [7b005b4]
223
+ - Updated dependencies [94f7b6a]
224
+ - Updated dependencies [5c94f83]
225
+ - Updated dependencies [73e576f]
226
+ - Updated dependencies [c5a5996]
227
+ - Updated dependencies [b40f81c]
228
+ - Updated dependencies [ae490ef]
229
+ - Updated dependencies [f61c8cf]
230
+ - Updated dependencies [e3ef52b]
231
+ - Updated dependencies [07f1822]
232
+ - Updated dependencies [04fab5e]
233
+ - Updated dependencies [ef8b1ff]
234
+ - Updated dependencies [efedd28]
235
+ - Updated dependencies [5278e11]
236
+ - Updated dependencies [23dba62]
237
+ - Updated dependencies [ba98e26]
238
+ - Updated dependencies [fc5f536]
239
+ - Updated dependencies [f8cfbb4]
240
+ - Updated dependencies [c89d18c]
241
+ - Updated dependencies [aac90a5]
242
+ - Updated dependencies [1e6ab15]
243
+ - Updated dependencies [c87ef70]
244
+ - Updated dependencies [3cb0618]
245
+ - Updated dependencies [32a0874]
246
+ - Updated dependencies [7055c22]
247
+ - Updated dependencies [785a748]
248
+ - Updated dependencies [3af0354]
249
+ - Updated dependencies [866ff16]
250
+ - Updated dependencies [5a85e67]
251
+ - Updated dependencies [c183a12]
252
+ - Updated dependencies [8064b07]
253
+ - Updated dependencies [4a56dbd]
254
+ - Updated dependencies [06df4fa]
255
+ - @objectstack/spec@17.0.0-rc.4
256
+ - @objectstack/core@17.0.0-rc.4
257
+ - @objectstack/plugin-auth@17.0.0-rc.4
258
+
3
259
  ## 17.0.0-rc.2
4
260
 
5
261
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,6 +1,213 @@
1
1
  import { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult, ISmsService, SendSmsInput, SendSmsResult } from '@objectstack/spec/contracts';
2
+ import { CounterStore } from '@objectstack/plugin-auth';
2
3
  import { Plugin, PluginContext } from '@objectstack/core';
3
4
 
5
+ /**
6
+ * Global daily SMS send quota — the COST TOTAL gate (#2814).
7
+ *
8
+ * ## What this adds that the per-number guard cannot
9
+ *
10
+ * #2780 gave the OTP endpoints a per-NUMBER budget (60s cooldown + 5 sends per
11
+ * number per hour, `plugin-auth/src/otp-send-guard.ts`). That bounds what one
12
+ * phone number costs. It does not bound what the DEPLOYMENT costs: an attacker
13
+ * rotating through ten thousand distinct numbers keeps every one of them inside
14
+ * its own budget while the daily bill has no ceiling at all — classic SMS
15
+ * pumping / toll fraud. And the per-number guard sits in better-auth's
16
+ * `hooks.before`, so it only ever sees the auth endpoints: `notify(channels:
17
+ * ['sms'])` and the invitation path walk straight past it.
18
+ *
19
+ * This gate is therefore counted at the ONE place every outbound message
20
+ * already funnels through — `SmsService.send()` — so OTP, invitations and the
21
+ * messaging `sms` channel are all charged against the same budget, whatever
22
+ * door they came in by.
23
+ *
24
+ * ## Where it counts (#4790's answer, reused verbatim)
25
+ *
26
+ * A budget is only worth what its store is worth: counted per process, a
27
+ * declared "2000 per day" is really 2000×N across N nodes, and nothing says so
28
+ * (ADR-0049 — declared ≠ enforced). So this counts through the SAME resolution
29
+ * the auth counters use — `createLazyCounterStore` over the kernel `cache`
30
+ * service, resolved at COUNTING time (not at plugin init, the #4772 trap),
31
+ * with a bounded per-process fallback that announces itself. The counting
32
+ * algorithm is `incrementFixedWindow`, imported rather than re-implemented:
33
+ * this repo has exactly one fixed-window counter and #4790 said plainly that a
34
+ * third copy is not wanted.
35
+ *
36
+ * ## The window is a UTC calendar day, twice over
37
+ *
38
+ * "Daily quota" means the calendar day, not "24h from the first send", so the
39
+ * counter key carries the UTC date (`sms-daily-sends:2026-08-06`) AND the
40
+ * window is opened with exactly the seconds remaining until the next UTC
41
+ * midnight. Either mechanism alone would roll the budget over; together they
42
+ * cannot disagree — a clock skew that mis-sizes the TTL still lands on a fresh
43
+ * key at 00:00Z, and a store that ignores TTLs still starts a new key.
44
+ *
45
+ * ## Admission-time counting, and fail-open
46
+ *
47
+ * A unit is consumed when a send is ADMITTED, before the transport runs —
48
+ * exactly like `OtpSendGuard.checkAndRecord` and
49
+ * `createLazyCacheRateLimitStorage.consume`, both of which take the
50
+ * post-increment count and compare it to the cap. Two consequences, stated
51
+ * rather than discovered later: a transport failure still spends a unit (the
52
+ * conservative direction for a COST ceiling, and the alternative is a second
53
+ * store round-trip on every send), and attempts refused by this gate keep
54
+ * incrementing the day's counter, so the number in the log line is "attempts
55
+ * today", not "messages delivered today".
56
+ *
57
+ * Every store interaction is fail-OPEN: a cache outage must not take phone
58
+ * sign-in down with it (#2814 requirement 4). A degraded gate is announced once
59
+ * and then admits.
60
+ *
61
+ * ## What is NOT here
62
+ *
63
+ * The per-tenant dimension (`daily_quota_per_tenant`, keyed by
64
+ * `organizationId`) is deliberately absent: `SendSmsInput`
65
+ * (`@objectstack/spec/contracts/sms-service.ts`) carries no tenant identifier,
66
+ * and inventing a second, service-local spelling of one would be exactly the
67
+ * shadow contract AGENTS.md Prime Directive #12 forbids. See the issue thread
68
+ * on #2814 — the tenant identifier belongs on the spec contract or nowhere.
69
+ */
70
+
71
+ /**
72
+ * The error code a quota-refused send answers with, as the `CODE: message`
73
+ * prefix `SmsService` already uses for `VALIDATION_FAILED`.
74
+ *
75
+ * Deliberately the same code the per-number guard raises in `plugin-auth`
76
+ * (`APIError('TOO_MANY_REQUESTS')`), because #2814 asks the two walls to be
77
+ * indistinguishable from outside: an attacker must not be able to tell which
78
+ * budget they hit, and a legitimate caller needs no more than "not now".
79
+ * The message carries NO remaining-quota detail for the same reason.
80
+ */
81
+ declare const SMS_QUOTA_EXCEEDED_CODE = "TOO_MANY_REQUESTS";
82
+ /** The refusal text handed back on `SendSmsResult.error`. Contains no counts. */
83
+ declare const SMS_QUOTA_EXCEEDED_ERROR = "TOO_MANY_REQUESTS: daily SMS quota exhausted";
84
+ type LoggerLike = {
85
+ info?(msg: string, meta?: Record<string, unknown>): void;
86
+ warn?(msg: string, meta?: Record<string, unknown>): void;
87
+ };
88
+ interface SmsDailyQuotaOptions {
89
+ /**
90
+ * Resolve the store the day counter lives in — called on EVERY check, so a
91
+ * shared cache registered after this gate was constructed is picked up on the
92
+ * next send rather than never (#4772/#4790). `SmsServicePlugin` supplies
93
+ * `createLazyCounterStore(...)`; omitted ⇒ a per-process store, silently
94
+ * (the gate constructed standalone, e.g. in tests).
95
+ */
96
+ resolveStore?: () => Promise<CounterStore>;
97
+ /** Diagnostics sink. NEVER receives a message body or a recipient. */
98
+ logger?: LoggerLike;
99
+ /** Clock override for tests. */
100
+ now?: () => number;
101
+ }
102
+ /** Outcome of one admission check. */
103
+ interface SmsDailyQuotaDecision {
104
+ /** Whether the send may proceed. */
105
+ ok: boolean;
106
+ /**
107
+ * Attempts counted for the current UTC day AFTER this one, when the counter
108
+ * was actually consulted. Absent when the gate is off or degraded.
109
+ */
110
+ count?: number;
111
+ /** The enforced ceiling this decision was measured against (`0` ⇒ off). */
112
+ quota?: number;
113
+ }
114
+ /** `YYYY-MM-DD` in UTC — the day the counter key is scoped to. */
115
+ declare function utcDayStamp(now: number): string;
116
+ /**
117
+ * Seconds from `now` to the next UTC midnight, at least 1. This is the window
118
+ * `incrementFixedWindow` opens on the day's first send, so the counter expires
119
+ * with the day it belongs to instead of 24h after whenever it started.
120
+ */
121
+ declare function secondsUntilNextUtcMidnight(now: number): number;
122
+ /**
123
+ * Result of reading an authored quota value: the ceiling actually enforced,
124
+ * plus the offending input when one had to be discarded.
125
+ */
126
+ interface NormalizedDailyQuota {
127
+ /** Enforced ceiling. `0` means unlimited. Always a non-negative integer. */
128
+ limit: number;
129
+ /** Set when the authored value was unusable and `0` was substituted. */
130
+ rejected?: string;
131
+ }
132
+ /**
133
+ * Clamp an authored `sms.daily_quota` into the value actually enforced.
134
+ *
135
+ * **This lives on the CONSUMER side on purpose (#5932).** `SettingsService`
136
+ * declares `min`/`max` on a manifest specifier but `validatePatch` does not
137
+ * enforce them today, so a `min: 0` declaration is inert: negative, fractional
138
+ * and outright non-numeric values all reach a reader intact. Anything that
139
+ * depends on the manifest having filtered them is declared-but-unenforced
140
+ * (ADR-0049), so the clamp is here, where the value becomes behaviour, and is
141
+ * pinned by tests.
142
+ *
143
+ * The rules, and why each is the safe direction for a paid channel:
144
+ *
145
+ * - **absent / empty string** → `0` (unlimited), quietly. That is "the operator
146
+ * has not configured a ceiling", which is the shipped default, not an error.
147
+ * - **finite number ≥ 0** → `Math.floor(v)`. A fractional message count is
148
+ * rounded DOWN, the stricter direction for a cost ceiling, and is not worth a
149
+ * diagnostic — `100.5` unambiguously means "at most 100 messages".
150
+ * - **negative, NaN, ±Infinity, or a non-numeric type** → `0` (unlimited) plus
151
+ * a named rejection the caller logs LOUDLY.
152
+ *
153
+ * That last rule is the one worth arguing. Two other readings exist and both
154
+ * are worse here. Refusing to send at all turns one typo in a settings form
155
+ * into a total outage of phone sign-in — the precise failure #2814 requirement
156
+ * 4 rules out ("配额闸不能把登录拖下水"), and the same reasoning `SettingsService`
157
+ * applies when it ignores a rejected `OS_*` override rather than acting on it.
158
+ * Substituting some other default invents a ceiling nobody declared and hides
159
+ * the typo behind plausible behaviour (#5152's lesson, where a typo'd
160
+ * `invite_only` read as `auto` left an operator believing a wall was up).
161
+ * Ignoring the value and SAYING SO leaves the deployment exactly where it was
162
+ * before the bad edit, with a line naming the value to fix.
163
+ */
164
+ declare function normalizeDailyQuota(raw: unknown): NormalizedDailyQuota;
165
+ /**
166
+ * The global daily send ceiling, counted once per admitted send.
167
+ *
168
+ * Constructed by `SmsServicePlugin` and handed to `SmsService`; `setQuota` is
169
+ * called on every `sms` settings change so an admin edit takes effect without a
170
+ * restart (same live-swap contract as the transport).
171
+ */
172
+ declare class SmsDailyQuota {
173
+ private limit;
174
+ private readonly resolveStore;
175
+ private readonly logger?;
176
+ private readonly now;
177
+ /**
178
+ * Per-process fallback used when no resolver was supplied at all (the gate
179
+ * constructed standalone). The SAME bounded store the auth counters degrade
180
+ * to — one fallback implementation across the repo, not a second one that can
181
+ * drift (#4790).
182
+ */
183
+ private readonly fallback;
184
+ /** The last authored value reported as unusable — deduped, per value. */
185
+ private reportedRejection?;
186
+ /** UTC day stamp the approaching-ceiling WARN has already fired for. */
187
+ private nearLimitWarnedFor?;
188
+ /** UTC day stamp the ceiling-reached WARN has already fired for. */
189
+ private exceededWarnedFor?;
190
+ /** Store-outage WARN is once per process — see `checkAndRecord`. */
191
+ private degradedWarned;
192
+ constructor(options?: SmsDailyQuotaOptions);
193
+ /**
194
+ * Apply an authored `sms.daily_quota`. Anything unusable degrades to
195
+ * "unlimited" and is reported once per distinct offending value — see
196
+ * {@link normalizeDailyQuota} for why that is the safe direction.
197
+ */
198
+ setQuota(raw: unknown): void;
199
+ /** The ceiling actually in force (`0` ⇒ unlimited). @internal test seam */
200
+ get enforcedQuota(): number;
201
+ /**
202
+ * Charge one send against today's budget and answer whether it may proceed.
203
+ *
204
+ * Never throws: a store outage fails OPEN (announced once per process), for
205
+ * the reason in the file header — an SMS cost ceiling that can block sign-in
206
+ * is a worse problem than the one it solves.
207
+ */
208
+ checkAndRecord(): Promise<SmsDailyQuotaDecision>;
209
+ }
210
+
4
211
  /**
5
212
  * Normalize + validate a recipient phone number. Accepts E.164 and common
6
213
  * human formats (spaces / dashes / dots / parens are stripped). Returns
@@ -50,6 +257,14 @@ interface SmsServiceOptions {
50
257
  info: (msg: string, meta?: any) => void;
51
258
  warn: (msg: string, meta?: any) => void;
52
259
  };
260
+ /**
261
+ * The deployment-wide daily send ceiling (#2814). Charged once per admitted
262
+ * send, HERE rather than at the auth endpoints, so OTP, invitations and the
263
+ * messaging `sms` channel are all counted against the one budget — see
264
+ * `sms-daily-quota.ts`. Omitted ⇒ no total-cost gate (the pre-#2814
265
+ * behaviour).
266
+ */
267
+ dailyQuota?: SmsDailyQuota;
53
268
  }
54
269
  /**
55
270
  * Concrete ISmsService implementation.
@@ -129,7 +344,32 @@ declare class TwilioSmsTransport implements ISmsTransport {
129
344
  send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult>;
130
345
  }
131
346
 
132
- type SmsProviderTag = 'log' | 'aliyun' | 'twilio';
347
+ /**
348
+ * The provider vocabulary — every tag `makeSmsTransport` below can build, and
349
+ * nothing else. It is the **one** literal: the `SmsProviderTag` type is derived
350
+ * from it, the `switch` is exhaustive over it, and callers that have to judge an
351
+ * operator-supplied provider string (the CLI's `sms` capability arm, #5713) read
352
+ * it from here rather than restating the list.
353
+ *
354
+ * Two literals describing one vocabulary is how the mail settings dropdown and
355
+ * the mail transports drifted apart (#5094) — `sendgrid`/`ses` were offered with
356
+ * no transport behind them while `resend` had a working transport nobody could
357
+ * pick. The SMS boot path had the same shape from the other side: `os serve`
358
+ * passed `OS_SMS_PROVIDER` straight into `SmsServicePlugin` with nothing to
359
+ * compare it against, so a typo (`twilo`) reached `makeSmsTransport`, threw
360
+ * there, was caught, and became `LogSmsTransport` — a server that answers every
361
+ * OTP send `status: 'sent'` and delivers nothing.
362
+ */
363
+ declare const SMS_TRANSPORT_PROVIDERS: readonly ["log", "aliyun", "twilio"];
364
+ /** A provider tag `makeSmsTransport` can materialise a transport for. */
365
+ type SmsProviderTag = (typeof SMS_TRANSPORT_PROVIDERS)[number];
366
+ /**
367
+ * Narrow an unknown value to a buildable provider tag. The counterpart of
368
+ * `isEmailTransportProvider` in `@objectstack/plugin-email`, and used by the CLI
369
+ * for the same reason: a provider that cannot deliver must be refused where the
370
+ * operator declared it, not silently downgraded where it is materialised.
371
+ */
372
+ declare function isSmsTransportProvider(value: unknown): value is SmsProviderTag;
133
373
  interface MakeSmsTransportOptions {
134
374
  provider: SmsProviderTag;
135
375
  /** Provider-specific credentials/options (see the transport option types). */
@@ -188,7 +428,21 @@ declare class SmsServicePlugin implements Plugin {
188
428
  type: "standard";
189
429
  private readonly options;
190
430
  private service?;
431
+ private dailyQuota?;
191
432
  constructor(options?: SmsServicePluginOptions);
433
+ /**
434
+ * Build the daily cost gate (#2814) over the kernel `cache` service.
435
+ *
436
+ * `resolveCache` is copied in shape from `AuthPlugin.init()` on purpose, for
437
+ * the two reasons stated there: the `cache` service is registered ASYNC (so
438
+ * `getService` throws for it and `getServiceAsync` is the only accessor that
439
+ * works), and resolution has to happen when a counter is CONSUMED rather than
440
+ * at init, or a deployment that registers its cache after this plugin freezes
441
+ * a "no shared store" answer for the life of the process (#4772). The
442
+ * degraded case is announced by `createLazyCounterStore` itself, named for
443
+ * this subject.
444
+ */
445
+ private buildDailyQuota;
192
446
  private resolveInitialTransport;
193
447
  init(ctx: PluginContext): Promise<void>;
194
448
  start(ctx: PluginContext): Promise<void>;
@@ -200,4 +454,4 @@ declare class SmsServicePlugin implements Plugin {
200
454
  private applySmsSettings;
201
455
  }
202
456
 
203
- export { AliyunSmsTransport, type AliyunSmsTransportOptions, LogSmsTransport, type MakeSmsTransportOptions, type SmsProviderTag, SmsService, type SmsServiceOptions, SmsServicePlugin, type SmsServicePluginOptions, TwilioSmsTransport, type TwilioSmsTransportOptions, makeSmsTransport, maskPhoneNumber, normalizeSmsRecipient };
457
+ export { AliyunSmsTransport, type AliyunSmsTransportOptions, LogSmsTransport, type MakeSmsTransportOptions, type NormalizedDailyQuota, SMS_QUOTA_EXCEEDED_CODE, SMS_QUOTA_EXCEEDED_ERROR, SMS_TRANSPORT_PROVIDERS, SmsDailyQuota, type SmsDailyQuotaDecision, type SmsDailyQuotaOptions, type SmsProviderTag, SmsService, type SmsServiceOptions, SmsServicePlugin, type SmsServicePluginOptions, TwilioSmsTransport, type TwilioSmsTransportOptions, isSmsTransportProvider, makeSmsTransport, maskPhoneNumber, normalizeDailyQuota, normalizeSmsRecipient, secondsUntilNextUtcMidnight, utcDayStamp };