@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/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
- 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 };
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
- normalizeSmsRecipient: () => normalizeSmsRecipient
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.applySmsSettings(values, ctx);
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
- normalizeSmsRecipient
624
+ normalizeDailyQuota,
625
+ normalizeSmsRecipient,
626
+ secondsUntilNextUtcMidnight,
627
+ utcDayStamp
447
628
  });
448
629
  //# sourceMappingURL=index.js.map