@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/dist/index.d.ts
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 };
|
package/dist/index.js
CHANGED
|
@@ -22,15 +22,136 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
AliyunSmsTransport: () => AliyunSmsTransport,
|
|
24
24
|
LogSmsTransport: () => LogSmsTransport,
|
|
25
|
+
SMS_QUOTA_EXCEEDED_CODE: () => SMS_QUOTA_EXCEEDED_CODE,
|
|
26
|
+
SMS_QUOTA_EXCEEDED_ERROR: () => SMS_QUOTA_EXCEEDED_ERROR,
|
|
27
|
+
SMS_TRANSPORT_PROVIDERS: () => SMS_TRANSPORT_PROVIDERS,
|
|
28
|
+
SmsDailyQuota: () => SmsDailyQuota,
|
|
25
29
|
SmsService: () => SmsService,
|
|
26
30
|
SmsServicePlugin: () => SmsServicePlugin,
|
|
27
31
|
TwilioSmsTransport: () => TwilioSmsTransport,
|
|
32
|
+
isSmsTransportProvider: () => isSmsTransportProvider,
|
|
28
33
|
makeSmsTransport: () => makeSmsTransport,
|
|
29
34
|
maskPhoneNumber: () => maskPhoneNumber,
|
|
30
|
-
|
|
35
|
+
normalizeDailyQuota: () => normalizeDailyQuota,
|
|
36
|
+
normalizeSmsRecipient: () => normalizeSmsRecipient,
|
|
37
|
+
secondsUntilNextUtcMidnight: () => secondsUntilNextUtcMidnight,
|
|
38
|
+
utcDayStamp: () => utcDayStamp
|
|
31
39
|
});
|
|
32
40
|
module.exports = __toCommonJS(index_exports);
|
|
33
41
|
|
|
42
|
+
// src/sms-daily-quota.ts
|
|
43
|
+
var import_plugin_auth = require("@objectstack/plugin-auth");
|
|
44
|
+
var SMS_QUOTA_EXCEEDED_CODE = "TOO_MANY_REQUESTS";
|
|
45
|
+
var SMS_QUOTA_EXCEEDED_ERROR = `${SMS_QUOTA_EXCEEDED_CODE}: daily SMS quota exhausted`;
|
|
46
|
+
var KEY_PREFIX = "sms-daily-sends:";
|
|
47
|
+
var NEAR_LIMIT_RATIO = 0.8;
|
|
48
|
+
function utcDayStamp(now) {
|
|
49
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
function secondsUntilNextUtcMidnight(now) {
|
|
52
|
+
const d = new Date(now);
|
|
53
|
+
const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
54
|
+
return Math.max(1, Math.ceil((next - now) / 1e3));
|
|
55
|
+
}
|
|
56
|
+
function normalizeDailyQuota(raw) {
|
|
57
|
+
if (raw === void 0 || raw === null) return { limit: 0 };
|
|
58
|
+
if (typeof raw === "string") {
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
if (trimmed.length === 0) return { limit: 0 };
|
|
61
|
+
const parsed = Number(trimmed);
|
|
62
|
+
if (Number.isFinite(parsed) && parsed >= 0) return { limit: Math.floor(parsed) };
|
|
63
|
+
return { limit: 0, rejected: trimmed };
|
|
64
|
+
}
|
|
65
|
+
if (typeof raw === "number") {
|
|
66
|
+
if (Number.isFinite(raw) && raw >= 0) return { limit: Math.floor(raw) };
|
|
67
|
+
return { limit: 0, rejected: String(raw) };
|
|
68
|
+
}
|
|
69
|
+
return { limit: 0, rejected: typeof raw === "object" ? JSON.stringify(raw) : String(raw) };
|
|
70
|
+
}
|
|
71
|
+
var SmsDailyQuota = class {
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
this.limit = 0;
|
|
74
|
+
/**
|
|
75
|
+
* Per-process fallback used when no resolver was supplied at all (the gate
|
|
76
|
+
* constructed standalone). The SAME bounded store the auth counters degrade
|
|
77
|
+
* to — one fallback implementation across the repo, not a second one that can
|
|
78
|
+
* drift (#4790).
|
|
79
|
+
*/
|
|
80
|
+
this.fallback = new import_plugin_auth.InProcessCounterStore();
|
|
81
|
+
/** Store-outage WARN is once per process — see `checkAndRecord`. */
|
|
82
|
+
this.degradedWarned = false;
|
|
83
|
+
this.logger = options.logger;
|
|
84
|
+
this.now = options.now ?? Date.now;
|
|
85
|
+
this.resolveStore = options.resolveStore ?? (async () => this.fallback);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Apply an authored `sms.daily_quota`. Anything unusable degrades to
|
|
89
|
+
* "unlimited" and is reported once per distinct offending value — see
|
|
90
|
+
* {@link normalizeDailyQuota} for why that is the safe direction.
|
|
91
|
+
*/
|
|
92
|
+
setQuota(raw) {
|
|
93
|
+
const { limit, rejected } = normalizeDailyQuota(raw);
|
|
94
|
+
this.limit = limit;
|
|
95
|
+
if (rejected !== void 0 && rejected !== this.reportedRejection) {
|
|
96
|
+
this.reportedRejection = rejected;
|
|
97
|
+
this.logger?.warn?.(
|
|
98
|
+
`[sms] daily_quota value '${rejected}' is not a usable message count \u2014 the daily SMS quota is NOT enforced until it is corrected. Use a non-negative whole number, or 0 for "no limit".`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The ceiling actually in force (`0` ⇒ unlimited). @internal test seam */
|
|
103
|
+
get enforcedQuota() {
|
|
104
|
+
return this.limit;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Charge one send against today's budget and answer whether it may proceed.
|
|
108
|
+
*
|
|
109
|
+
* Never throws: a store outage fails OPEN (announced once per process), for
|
|
110
|
+
* the reason in the file header — an SMS cost ceiling that can block sign-in
|
|
111
|
+
* is a worse problem than the one it solves.
|
|
112
|
+
*/
|
|
113
|
+
async checkAndRecord() {
|
|
114
|
+
if (this.limit <= 0) return { ok: true, quota: 0 };
|
|
115
|
+
const now = this.now();
|
|
116
|
+
const day = utcDayStamp(now);
|
|
117
|
+
try {
|
|
118
|
+
const store = await this.resolveStore();
|
|
119
|
+
const { count } = await (0, import_plugin_auth.incrementFixedWindow)(
|
|
120
|
+
store,
|
|
121
|
+
KEY_PREFIX + day,
|
|
122
|
+
secondsUntilNextUtcMidnight(now),
|
|
123
|
+
now
|
|
124
|
+
);
|
|
125
|
+
if (count > this.limit) {
|
|
126
|
+
if (this.exceededWarnedFor !== day) {
|
|
127
|
+
this.exceededWarnedFor = day;
|
|
128
|
+
this.logger?.warn?.(
|
|
129
|
+
`[sms] daily SMS quota reached \u2014 further sends are refused until 00:00 UTC.`,
|
|
130
|
+
{ day, count, quota: this.limit }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { ok: false, count, quota: this.limit };
|
|
134
|
+
}
|
|
135
|
+
if (count >= Math.ceil(this.limit * NEAR_LIMIT_RATIO) && this.nearLimitWarnedFor !== day) {
|
|
136
|
+
this.nearLimitWarnedFor = day;
|
|
137
|
+
this.logger?.warn?.(
|
|
138
|
+
`[sms] daily SMS quota is ${Math.round(NEAR_LIMIT_RATIO * 100)}% consumed.`,
|
|
139
|
+
{ day, count, quota: this.limit }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, count, quota: this.limit };
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (!this.degradedWarned) {
|
|
145
|
+
this.degradedWarned = true;
|
|
146
|
+
this.logger?.warn?.(
|
|
147
|
+
"[sms] daily SMS quota counter is unreadable (" + String(err?.message ?? err) + ") \u2014 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)."
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
34
155
|
// src/sms-service.ts
|
|
35
156
|
function normalizeSmsRecipient(raw) {
|
|
36
157
|
const stripped = String(raw ?? "").replace(/[\s\-().]/g, "");
|
|
@@ -88,6 +209,15 @@ var SmsService = class {
|
|
|
88
209
|
...input.templateParams ? { templateParams: input.templateParams } : {}
|
|
89
210
|
};
|
|
90
211
|
const id = `sms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
212
|
+
if (this.options.dailyQuota) {
|
|
213
|
+
const decision = await this.options.dailyQuota.checkAndRecord();
|
|
214
|
+
if (!decision.ok) {
|
|
215
|
+
this.options.logger?.warn?.(
|
|
216
|
+
`[SmsService] send to ${maskPhoneNumber(to)} refused: daily quota exhausted`
|
|
217
|
+
);
|
|
218
|
+
return { id, status: "failed", error: SMS_QUOTA_EXCEEDED_ERROR };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
91
221
|
const maxAttempts = (this.options.retries ?? 0) + 1;
|
|
92
222
|
let lastError;
|
|
93
223
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -112,6 +242,9 @@ var SmsService = class {
|
|
|
112
242
|
}
|
|
113
243
|
};
|
|
114
244
|
|
|
245
|
+
// src/sms-plugin.ts
|
|
246
|
+
var import_plugin_auth2 = require("@objectstack/plugin-auth");
|
|
247
|
+
|
|
115
248
|
// src/transports/aliyun.ts
|
|
116
249
|
var import_node_crypto = require("crypto");
|
|
117
250
|
var API_VERSION = "2017-05-25";
|
|
@@ -230,6 +363,10 @@ var TwilioSmsTransport = class {
|
|
|
230
363
|
};
|
|
231
364
|
|
|
232
365
|
// src/transports/index.ts
|
|
366
|
+
var SMS_TRANSPORT_PROVIDERS = ["log", "aliyun", "twilio"];
|
|
367
|
+
function isSmsTransportProvider(value) {
|
|
368
|
+
return typeof value === "string" && SMS_TRANSPORT_PROVIDERS.includes(value);
|
|
369
|
+
}
|
|
233
370
|
function makeSmsTransport(opts) {
|
|
234
371
|
const { provider, options = {}, logger } = opts;
|
|
235
372
|
switch (provider) {
|
|
@@ -300,6 +437,40 @@ var SmsServicePlugin = class {
|
|
|
300
437
|
this.type = "standard";
|
|
301
438
|
this.options = options;
|
|
302
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Build the daily cost gate (#2814) over the kernel `cache` service.
|
|
442
|
+
*
|
|
443
|
+
* `resolveCache` is copied in shape from `AuthPlugin.init()` on purpose, for
|
|
444
|
+
* the two reasons stated there: the `cache` service is registered ASYNC (so
|
|
445
|
+
* `getService` throws for it and `getServiceAsync` is the only accessor that
|
|
446
|
+
* works), and resolution has to happen when a counter is CONSUMED rather than
|
|
447
|
+
* at init, or a deployment that registers its cache after this plugin freezes
|
|
448
|
+
* a "no shared store" answer for the life of the process (#4772). The
|
|
449
|
+
* degraded case is announced by `createLazyCounterStore` itself, named for
|
|
450
|
+
* this subject.
|
|
451
|
+
*/
|
|
452
|
+
buildDailyQuota(ctx) {
|
|
453
|
+
const resolveCache = async () => {
|
|
454
|
+
let cache;
|
|
455
|
+
try {
|
|
456
|
+
cache = await ctx.getServiceAsync?.("cache");
|
|
457
|
+
} catch {
|
|
458
|
+
return void 0;
|
|
459
|
+
}
|
|
460
|
+
if (cache && typeof cache.get === "function" && typeof cache.set === "function") return cache;
|
|
461
|
+
return void 0;
|
|
462
|
+
};
|
|
463
|
+
return new SmsDailyQuota({
|
|
464
|
+
resolveStore: (0, import_plugin_auth2.createLazyCounterStore)({
|
|
465
|
+
resolveCache,
|
|
466
|
+
logger: ctx.logger,
|
|
467
|
+
logPrefix: "[sms]",
|
|
468
|
+
subject: "daily SMS send quota (#2814)",
|
|
469
|
+
degradedImpact: "The ceiling is still enforced, but PER NODE: an N-node deployment can spend up to N\xD7 the configured number of PAID SMS per day, which is exactly the total-cost hole this gate exists to close"
|
|
470
|
+
}),
|
|
471
|
+
logger: ctx.logger
|
|
472
|
+
});
|
|
473
|
+
}
|
|
303
474
|
resolveInitialTransport(ctx) {
|
|
304
475
|
if (this.options.transport) return { transport: this.options.transport, configured: true };
|
|
305
476
|
const provider = this.options.provider ?? "log";
|
|
@@ -323,11 +494,13 @@ var SmsServicePlugin = class {
|
|
|
323
494
|
} else {
|
|
324
495
|
ctx.logger.info(`SmsServicePlugin: using '${this.options.provider ?? "custom"}' provider`);
|
|
325
496
|
}
|
|
497
|
+
this.dailyQuota = this.buildDailyQuota(ctx);
|
|
326
498
|
this.service = new SmsService({
|
|
327
499
|
transport,
|
|
328
500
|
configured,
|
|
329
501
|
retries: this.options.retries,
|
|
330
|
-
logger: ctx.logger
|
|
502
|
+
logger: ctx.logger,
|
|
503
|
+
dailyQuota: this.dailyQuota
|
|
331
504
|
});
|
|
332
505
|
ctx.registerService("sms", this.service);
|
|
333
506
|
ctx.logger.info("SmsServicePlugin: sms service registered");
|
|
@@ -335,7 +508,6 @@ var SmsServicePlugin = class {
|
|
|
335
508
|
async start(ctx) {
|
|
336
509
|
ctx.hook("kernel:ready", async () => {
|
|
337
510
|
if (!this.service) return;
|
|
338
|
-
if (this.options.transport) return;
|
|
339
511
|
try {
|
|
340
512
|
const settings = ctx.getService("settings");
|
|
341
513
|
if (!settings || typeof settings.getNamespace !== "function") return;
|
|
@@ -346,7 +518,8 @@ var SmsServicePlugin = class {
|
|
|
346
518
|
for (const [k, v] of Object.entries(payload.values)) {
|
|
347
519
|
values[k] = v?.value;
|
|
348
520
|
}
|
|
349
|
-
this.
|
|
521
|
+
this.dailyQuota?.setQuota(values.daily_quota);
|
|
522
|
+
if (!this.options.transport) this.applySmsSettings(values, ctx);
|
|
350
523
|
} catch (err) {
|
|
351
524
|
ctx.logger.warn("SmsServicePlugin: failed to apply sms settings: " + (err?.message ?? err));
|
|
352
525
|
}
|
|
@@ -358,7 +531,7 @@ var SmsServicePlugin = class {
|
|
|
358
531
|
});
|
|
359
532
|
ctx.logger.info("SmsServicePlugin: bound to settings:changed for namespace=sms");
|
|
360
533
|
}
|
|
361
|
-
if (typeof settings.registerAction === "function") {
|
|
534
|
+
if (!this.options.transport && typeof settings.registerAction === "function") {
|
|
362
535
|
const svc = this.service;
|
|
363
536
|
settings.registerAction("sms", "test", async ({ values, payload, ctx: actionCtx }) => {
|
|
364
537
|
const overrides = payload && typeof payload === "object" && payload.values && typeof payload.values === "object" ? payload.values : payload ?? {};
|
|
@@ -438,11 +611,19 @@ var SmsServicePlugin = class {
|
|
|
438
611
|
0 && (module.exports = {
|
|
439
612
|
AliyunSmsTransport,
|
|
440
613
|
LogSmsTransport,
|
|
614
|
+
SMS_QUOTA_EXCEEDED_CODE,
|
|
615
|
+
SMS_QUOTA_EXCEEDED_ERROR,
|
|
616
|
+
SMS_TRANSPORT_PROVIDERS,
|
|
617
|
+
SmsDailyQuota,
|
|
441
618
|
SmsService,
|
|
442
619
|
SmsServicePlugin,
|
|
443
620
|
TwilioSmsTransport,
|
|
621
|
+
isSmsTransportProvider,
|
|
444
622
|
makeSmsTransport,
|
|
445
623
|
maskPhoneNumber,
|
|
446
|
-
|
|
624
|
+
normalizeDailyQuota,
|
|
625
|
+
normalizeSmsRecipient,
|
|
626
|
+
secondsUntilNextUtcMidnight,
|
|
627
|
+
utcDayStamp
|
|
447
628
|
});
|
|
448
629
|
//# sourceMappingURL=index.js.map
|