@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 +256 -0
- package/dist/index.d.mts +256 -2
- package/dist/index.d.ts +256 -2
- package/dist/index.js +187 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +181 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -4
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
|
-
|
|
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 };
|