@objectstack/service-sms 14.5.0

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.
@@ -0,0 +1,155 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { SmsServicePlugin } from './sms-plugin.js';
5
+ import { SmsService, LogSmsTransport } from './sms-service.js';
6
+ import { AliyunSmsTransport, TwilioSmsTransport } from './transports/index.js';
7
+
8
+ /**
9
+ * Lightweight fake PluginContext (service registry + kernel:ready hooks +
10
+ * a fake settings service) — mirrors the messaging plugin's test harness.
11
+ */
12
+ function fakeCtx(opts: { settingsValues?: Record<string, unknown> } = {}) {
13
+ const services = new Map<string, unknown>();
14
+ const readyHooks: Array<() => Promise<void> | void> = [];
15
+ const actions = new Map<string, (input: any) => Promise<any>>();
16
+ const subscriptions: Array<{ ns: string; fn: () => void }> = [];
17
+ let values = opts.settingsValues;
18
+
19
+ if (values !== undefined) {
20
+ services.set('settings', {
21
+ async getNamespace(ns: string) {
22
+ if (ns !== 'sms') throw new Error('unknown namespace');
23
+ const wrapped: Record<string, { value: unknown }> = {};
24
+ for (const [k, v] of Object.entries(values ?? {})) wrapped[k] = { value: v };
25
+ return { values: wrapped };
26
+ },
27
+ subscribe(ns: string, fn: () => void) { subscriptions.push({ ns, fn }); return () => {}; },
28
+ registerAction(ns: string, id: string, fn: (input: any) => Promise<any>) {
29
+ actions.set(`${ns}/${id}`, fn);
30
+ },
31
+ });
32
+ }
33
+
34
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
35
+ const ctx = {
36
+ logger,
37
+ registerService(name: string, svc: unknown) { services.set(name, svc); },
38
+ getService(name: string) {
39
+ if (!services.has(name)) throw new Error(`service not found: ${name}`);
40
+ return services.get(name);
41
+ },
42
+ hook(event: string, fn: () => Promise<void> | void) {
43
+ if (event === 'kernel:ready') readyHooks.push(fn);
44
+ },
45
+ } as any;
46
+
47
+ return {
48
+ ctx,
49
+ services,
50
+ logger,
51
+ actions,
52
+ setValues: (v: Record<string, unknown>) => { values = v; },
53
+ notifyChange: async () => { for (const s of subscriptions) s.fn(); await new Promise((r) => setTimeout(r, 0)); },
54
+ fireReady: async () => { for (const fn of readyHooks) await fn(); },
55
+ };
56
+ }
57
+
58
+ describe('SmsServicePlugin', () => {
59
+ it('registers the sms service with the log fallback (unconfigured)', async () => {
60
+ const { ctx, services } = fakeCtx();
61
+ await new SmsServicePlugin().init(ctx);
62
+ const svc = services.get('sms') as SmsService;
63
+ expect(svc).toBeInstanceOf(SmsService);
64
+ expect(svc.isConfigured()).toBe(false);
65
+ expect(svc.options.transport).toBeInstanceOf(LogSmsTransport);
66
+ });
67
+
68
+ it('builds a provider transport from constructor options', async () => {
69
+ const { ctx, services } = fakeCtx();
70
+ await new SmsServicePlugin({
71
+ provider: 'twilio',
72
+ providerOptions: { accountSid: 'AC1', authToken: 't', from: '+15005550006' },
73
+ }).init(ctx);
74
+ const svc = services.get('sms') as SmsService;
75
+ expect(svc.isConfigured()).toBe(true);
76
+ expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport);
77
+ });
78
+
79
+ it('falls back to log (not a boot failure) on incomplete constructor credentials', async () => {
80
+ const { ctx, services, logger } = fakeCtx();
81
+ await new SmsServicePlugin({ provider: 'aliyun', providerOptions: {} }).init(ctx);
82
+ const svc = services.get('sms') as SmsService;
83
+ expect(svc.isConfigured()).toBe(false);
84
+ expect(logger.warn).toHaveBeenCalled();
85
+ });
86
+
87
+ it('rebuilds the transport from the sms settings namespace at kernel:ready', async () => {
88
+ const harness = fakeCtx({
89
+ settingsValues: {
90
+ provider: 'aliyun',
91
+ aliyun_access_key_id: 'ak',
92
+ aliyun_access_key_secret: 'sec',
93
+ aliyun_sign_name: '签名',
94
+ aliyun_template_code: 'SMS_1',
95
+ },
96
+ });
97
+ const plugin = new SmsServicePlugin();
98
+ await plugin.init(harness.ctx);
99
+ await plugin.start(harness.ctx);
100
+ await harness.fireReady();
101
+
102
+ const svc = harness.services.get('sms') as SmsService;
103
+ expect(svc.isConfigured()).toBe(true);
104
+ expect(svc.options.transport).toBeInstanceOf(AliyunSmsTransport);
105
+ });
106
+
107
+ it('keeps the previous transport when settings are incomplete', async () => {
108
+ const harness = fakeCtx({ settingsValues: { provider: 'twilio', twilio_account_sid: 'AC1' } });
109
+ const plugin = new SmsServicePlugin();
110
+ await plugin.init(harness.ctx);
111
+ await plugin.start(harness.ctx);
112
+ await harness.fireReady();
113
+
114
+ const svc = harness.services.get('sms') as SmsService;
115
+ expect(svc.isConfigured()).toBe(false);
116
+ expect(svc.options.transport).toBeInstanceOf(LogSmsTransport);
117
+ });
118
+
119
+ it('live-applies settings changes via subscribe', async () => {
120
+ const harness = fakeCtx({ settingsValues: { provider: 'log' } });
121
+ const plugin = new SmsServicePlugin();
122
+ await plugin.init(harness.ctx);
123
+ await plugin.start(harness.ctx);
124
+ await harness.fireReady();
125
+
126
+ const svc = harness.services.get('sms') as SmsService;
127
+ expect(svc.isConfigured()).toBe(false);
128
+
129
+ harness.setValues({
130
+ provider: 'twilio',
131
+ twilio_account_sid: 'AC1',
132
+ twilio_auth_token: 'tok',
133
+ twilio_from_number: '+15005550006',
134
+ });
135
+ await harness.notifyChange();
136
+ expect(svc.isConfigured()).toBe(true);
137
+ expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport);
138
+ });
139
+
140
+ it('registers an sms/test action that validates the recipient', async () => {
141
+ const harness = fakeCtx({ settingsValues: { provider: 'log' } });
142
+ const plugin = new SmsServicePlugin();
143
+ await plugin.init(harness.ctx);
144
+ await plugin.start(harness.ctx);
145
+ await harness.fireReady();
146
+
147
+ const test = harness.actions.get('sms/test');
148
+ expect(test).toBeDefined();
149
+ const bad = await test!({ values: { provider: 'log' }, payload: { to: 'not-a-phone' }, ctx: {} });
150
+ expect(bad.ok).toBe(false);
151
+ const good = await test!({ values: { provider: 'log' }, payload: { to: '+15005550006' }, ctx: {} });
152
+ expect(good.ok).toBe(true);
153
+ expect(good.message).not.toContain('5550006'); // masked
154
+ });
155
+ });
@@ -0,0 +1,252 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { ISmsTransport } from '@objectstack/spec/contracts';
5
+ import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js';
6
+ import { makeSmsTransport, type SmsProviderTag } from './transports/index.js';
7
+
8
+ /**
9
+ * Plugin configuration. Mirrors EmailServicePluginOptions: a directly
10
+ * injected transport wins, then `provider` + credentials, then the
11
+ * development `LogSmsTransport` fallback (no real send).
12
+ */
13
+ export interface SmsServicePluginOptions {
14
+ /** Pluggable delivery transport. Overrides `provider`/credentials. */
15
+ transport?: ISmsTransport;
16
+ /** Provider tag — `'log' | 'aliyun' | 'twilio'`. Default `'log'`. */
17
+ provider?: SmsProviderTag;
18
+ /** Provider-specific credentials/options (see transport option types). */
19
+ providerOptions?: Record<string, unknown>;
20
+ /** Retry attempts on transport throw. Default 0. */
21
+ retries?: number;
22
+ }
23
+
24
+ /** Translate an `sms` settings-namespace snapshot into transport inputs. */
25
+ function providerFromSettings(values: Record<string, unknown>): {
26
+ provider: SmsProviderTag;
27
+ options: Record<string, unknown>;
28
+ missing?: string;
29
+ } {
30
+ const provider = String(values.provider ?? 'log') as SmsProviderTag;
31
+ const str = (k: string): string | undefined => {
32
+ const v = values[k];
33
+ return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
34
+ };
35
+ if (provider === 'aliyun') {
36
+ const accessKeyId = str('aliyun_access_key_id');
37
+ const accessKeySecret = str('aliyun_access_key_secret');
38
+ const signName = str('aliyun_sign_name');
39
+ if (!accessKeyId || !accessKeySecret || !signName) {
40
+ return { provider, options: {}, missing: 'aliyun_access_key_id / aliyun_access_key_secret / aliyun_sign_name' };
41
+ }
42
+ return {
43
+ provider,
44
+ options: {
45
+ accessKeyId,
46
+ accessKeySecret,
47
+ signName,
48
+ ...(str('aliyun_template_code') ? { defaultTemplateCode: str('aliyun_template_code') } : {}),
49
+ },
50
+ };
51
+ }
52
+ if (provider === 'twilio') {
53
+ const accountSid = str('twilio_account_sid');
54
+ const authToken = str('twilio_auth_token');
55
+ const from = str('twilio_from_number');
56
+ const messagingServiceSid = str('twilio_messaging_service_sid');
57
+ if (!accountSid || !authToken || (!from && !messagingServiceSid)) {
58
+ return { provider, options: {}, missing: 'twilio_account_sid / twilio_auth_token / twilio_from_number (or messaging service SID)' };
59
+ }
60
+ return {
61
+ provider,
62
+ options: {
63
+ accountSid,
64
+ authToken,
65
+ ...(from ? { from } : {}),
66
+ ...(messagingServiceSid ? { messagingServiceSid } : {}),
67
+ },
68
+ };
69
+ }
70
+ return { provider: 'log', options: {} };
71
+ }
72
+
73
+ /**
74
+ * SmsServicePlugin — registers the `sms` service (#2780).
75
+ *
76
+ * Lifecycle:
77
+ * - `init`: build transport (injected → provider+credentials →
78
+ * LogSmsTransport fallback); register the SmsService so dependents
79
+ * (auth OTP, the messaging `sms` channel) can resolve it.
80
+ * - `start` (kernel:ready): bind the `sms` settings namespace so the
81
+ * admin UI can live-swap the provider without a restart, and register
82
+ * the `sms/test` action. Env-locked keys (OS_SMS_*) still win at the
83
+ * settings-resolver level.
84
+ *
85
+ * Deliberately NO persistence objects: SMS bodies carry OTP codes — see the
86
+ * ISmsService contract header.
87
+ */
88
+ export class SmsServicePlugin implements Plugin {
89
+ name = 'com.objectstack.service.sms';
90
+ version = '1.0.0';
91
+ type = 'standard' as const;
92
+
93
+ private readonly options: SmsServicePluginOptions;
94
+ private service?: SmsService;
95
+
96
+ constructor(options: SmsServicePluginOptions = {}) {
97
+ this.options = options;
98
+ }
99
+
100
+ private resolveInitialTransport(ctx: PluginContext): { transport: ISmsTransport; configured: boolean } {
101
+ if (this.options.transport) return { transport: this.options.transport, configured: true };
102
+ const provider = this.options.provider ?? 'log';
103
+ if (provider === 'log') return { transport: new LogSmsTransport(ctx.logger), configured: false };
104
+ try {
105
+ return {
106
+ transport: makeSmsTransport({ provider, options: this.options.providerOptions, logger: ctx.logger }),
107
+ configured: true,
108
+ };
109
+ } catch (err: any) {
110
+ // Incomplete constructor credentials must not take the kernel down —
111
+ // fall back to the dev transport; the settings bind (kernel:ready)
112
+ // can still swap in a working provider.
113
+ ctx.logger.warn(
114
+ `SmsServicePlugin: provider='${provider}' selected but transport build failed (${err?.message ?? err}) — falling back to LogSmsTransport.`,
115
+ );
116
+ return { transport: new LogSmsTransport(ctx.logger), configured: false };
117
+ }
118
+ }
119
+
120
+ async init(ctx: PluginContext): Promise<void> {
121
+ const { transport, configured } = this.resolveInitialTransport(ctx);
122
+ if (!configured) {
123
+ ctx.logger.info('SmsServicePlugin: no provider configured — using LogSmsTransport (SMS will NOT be sent)');
124
+ } else {
125
+ ctx.logger.info(`SmsServicePlugin: using '${this.options.provider ?? 'custom'}' provider`);
126
+ }
127
+ this.service = new SmsService({
128
+ transport,
129
+ configured,
130
+ retries: this.options.retries,
131
+ logger: ctx.logger,
132
+ });
133
+ ctx.registerService('sms', this.service);
134
+ ctx.logger.info('SmsServicePlugin: sms service registered');
135
+ }
136
+
137
+ async start(ctx: PluginContext): Promise<void> {
138
+ ctx.hook('kernel:ready', async () => {
139
+ if (!this.service) return;
140
+ // A host-injected transport is authoritative — settings only manage
141
+ // the provider-tag path.
142
+ if (this.options.transport) return;
143
+ try {
144
+ const settings = ctx.getService<any>('settings');
145
+ if (!settings || typeof settings.getNamespace !== 'function') return;
146
+
147
+ const applySettings = async () => {
148
+ try {
149
+ const payload = await settings.getNamespace('sms');
150
+ const values: Record<string, unknown> = {};
151
+ for (const [k, v] of Object.entries(payload.values as Record<string, any>)) {
152
+ values[k] = v?.value;
153
+ }
154
+ this.applySmsSettings(values, ctx);
155
+ } catch (err: any) {
156
+ ctx.logger.warn('SmsServicePlugin: failed to apply sms settings: ' + (err?.message ?? err));
157
+ }
158
+ };
159
+ await applySettings();
160
+ if (typeof settings.subscribe === 'function') {
161
+ settings.subscribe('sms', () => { void applySettings(); });
162
+ ctx.logger.info('SmsServicePlugin: bound to settings:changed for namespace=sms');
163
+ }
164
+
165
+ // `sms/test` action — validate the (possibly unsaved) form values by
166
+ // sending a real test message through a one-shot transport, mirroring
167
+ // the `mail/test` handler in EmailServicePlugin.
168
+ if (typeof settings.registerAction === 'function') {
169
+ const svc = this.service;
170
+ settings.registerAction('sms', 'test', async ({ values, payload, ctx: actionCtx }: any) => {
171
+ const overrides = (payload && typeof payload === 'object' && payload.values && typeof payload.values === 'object')
172
+ ? payload.values
173
+ : (payload ?? {});
174
+ const merged: Record<string, unknown> = { ...(values ?? {}), ...overrides };
175
+ const rawTo = (actionCtx?.body?.to as string | undefined) ?? (payload?.to as string | undefined);
176
+ const to = rawTo ? normalizeSmsRecipient(rawTo) : undefined;
177
+ if (!to) {
178
+ return { ok: false, severity: 'error', message: 'Provide a valid "to" phone number (E.164 recommended).' };
179
+ }
180
+
181
+ const resolved = providerFromSettings(merged);
182
+ if (resolved.missing) {
183
+ return { ok: false, severity: 'error', message: `${resolved.provider}: missing ${resolved.missing}.` };
184
+ }
185
+
186
+ let target: SmsService = svc;
187
+ if (resolved.provider !== 'log') {
188
+ try {
189
+ const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger });
190
+ target = new SmsService({ transport, configured: true, logger: ctx.logger });
191
+ } catch (err: any) {
192
+ return { ok: false, severity: 'error', message: `Failed to build ${resolved.provider} transport: ${err?.message ?? String(err)}` };
193
+ }
194
+ }
195
+
196
+ try {
197
+ const result = await target.send({
198
+ to,
199
+ body: 'ObjectStack SMS test message.',
200
+ templateParams: { content: 'ObjectStack SMS test message.' },
201
+ });
202
+ if (result.status === 'failed') {
203
+ return { ok: false, severity: 'error', message: result.error ?? 'Send failed.' };
204
+ }
205
+ return {
206
+ ok: true,
207
+ severity: 'info',
208
+ message: `Sent test SMS to ${maskPhoneNumber(to)} via ${resolved.provider} (id=${result.messageId ?? result.id}).`,
209
+ };
210
+ } catch (err: any) {
211
+ return { ok: false, severity: 'error', message: err?.message ?? String(err) };
212
+ }
213
+ });
214
+ }
215
+ } catch {
216
+ // settings service not registered — constructor opts remain authoritative.
217
+ }
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Translate the `sms` settings snapshot into a transport and hot-swap it
223
+ * on the running SmsService. Incomplete credentials keep the previous
224
+ * transport (with a warning) so a half-saved form can't break delivery.
225
+ */
226
+ private applySmsSettings(values: Record<string, unknown>, ctx: PluginContext): void {
227
+ if (!this.service) return;
228
+ const resolved = providerFromSettings(values);
229
+ if (resolved.missing) {
230
+ ctx.logger.warn(
231
+ `SmsServicePlugin: provider='${resolved.provider}' selected but ${resolved.missing} is empty — transport NOT rebuilt.`,
232
+ );
233
+ return;
234
+ }
235
+ if (resolved.provider === 'log') {
236
+ // Downgrade to the dev transport only when the operator explicitly
237
+ // selected `log`; an unset namespace keeps the constructor opts.
238
+ if (values.provider === 'log') {
239
+ this.service.setTransport(new LogSmsTransport(ctx.logger), false);
240
+ ctx.logger.info('SmsServicePlugin: sms settings applied (provider=log; SMS will NOT be sent).');
241
+ }
242
+ return;
243
+ }
244
+ try {
245
+ const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger });
246
+ this.service.setTransport(transport, true);
247
+ ctx.logger.info(`SmsServicePlugin: transport rebuilt from settings (provider=${resolved.provider}).`);
248
+ } catch (err: any) {
249
+ ctx.logger.warn('SmsServicePlugin: failed to rebuild transport: ' + (err?.message ?? err));
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,129 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js';
5
+
6
+ const collectingLogger = () => {
7
+ const lines: string[] = [];
8
+ return {
9
+ lines,
10
+ info: (msg: string) => { lines.push(String(msg)); },
11
+ warn: (msg: string) => { lines.push(String(msg)); },
12
+ };
13
+ };
14
+
15
+ describe('normalizeSmsRecipient', () => {
16
+ it('accepts E.164 and strips human separators', () => {
17
+ expect(normalizeSmsRecipient('+8613800000000')).toBe('+8613800000000');
18
+ expect(normalizeSmsRecipient('+1 (500) 555-0006')).toBe('+15005550006');
19
+ expect(normalizeSmsRecipient('138 0000 0000')).toBe('13800000000');
20
+ });
21
+ it('rejects garbage', () => {
22
+ expect(normalizeSmsRecipient('bob@example.com')).toBeUndefined();
23
+ expect(normalizeSmsRecipient('123')).toBeUndefined();
24
+ expect(normalizeSmsRecipient('')).toBeUndefined();
25
+ });
26
+ });
27
+
28
+ describe('maskPhoneNumber', () => {
29
+ it('keeps prefix + last two digits only', () => {
30
+ const masked = maskPhoneNumber('+8613812345678');
31
+ expect(masked.startsWith('+8613')).toBe(true);
32
+ expect(masked.endsWith('78')).toBe(true);
33
+ expect(masked).not.toContain('12345');
34
+ });
35
+ });
36
+
37
+ describe('SmsService', () => {
38
+ it('sends through the transport and reports the provider id', async () => {
39
+ const send = vi.fn(async () => ({ messageId: 'prov_1' }));
40
+ const svc = new SmsService({ transport: { send }, configured: true });
41
+ const r = await svc.send({ to: '+15005550006', body: 'hello' });
42
+ expect(r.status).toBe('sent');
43
+ expect(r.messageId).toBe('prov_1');
44
+ expect(send).toHaveBeenCalledWith({ to: '+15005550006', body: 'hello' });
45
+ });
46
+
47
+ it('throws on an invalid recipient BEFORE the transport is called', async () => {
48
+ const send = vi.fn();
49
+ const svc = new SmsService({ transport: { send }, configured: true });
50
+ await expect(svc.send({ to: 'not-a-phone', body: 'x' })).rejects.toThrow(/VALIDATION_FAILED/);
51
+ expect(send).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('resolves status:failed (not a throw) on transport errors', async () => {
55
+ const svc = new SmsService({
56
+ transport: { async send() { throw new Error('provider down'); } },
57
+ configured: true,
58
+ });
59
+ const r = await svc.send({ to: '+15005550006', body: 'x' });
60
+ expect(r.status).toBe('failed');
61
+ expect(r.error).toContain('provider down');
62
+ });
63
+
64
+ it('retries on transport throw when retries > 0', async () => {
65
+ let calls = 0;
66
+ const svc = new SmsService({
67
+ transport: { async send() { if (++calls < 2) throw new Error('flaky'); return { messageId: 'ok' }; } },
68
+ configured: true,
69
+ retries: 1,
70
+ });
71
+ const r = await svc.send({ to: '+15005550006', body: 'x' });
72
+ expect(r.status).toBe('sent');
73
+ expect(calls).toBe(2);
74
+ });
75
+
76
+ it('never logs the message body (OTP red line, #2780)', async () => {
77
+ const logger = collectingLogger();
78
+ const svc = new SmsService({
79
+ transport: { async send() { return { messageId: 'prov_1' }; } },
80
+ configured: true,
81
+ logger,
82
+ });
83
+ await svc.send({ to: '+8613812345678', body: '123456 is your code' });
84
+ // also exercise the failure path
85
+ svc.setTransport({ async send() { throw new Error('down'); } }, true);
86
+ await svc.send({ to: '+8613812345678', body: '654321 is your code' });
87
+ for (const line of logger.lines) {
88
+ expect(line).not.toContain('123456');
89
+ expect(line).not.toContain('654321');
90
+ expect(line).not.toContain('13812345678'); // full number masked too
91
+ }
92
+ });
93
+
94
+ it('surfaces isConfigured / setTransport upgrades', async () => {
95
+ const svc = new SmsService({ transport: new LogSmsTransport(), configured: false });
96
+ expect(svc.isConfigured()).toBe(false);
97
+ svc.setTransport({ async send() { return { messageId: 'x' }; } }, true);
98
+ expect(svc.isConfigured()).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe('LogSmsTransport', () => {
103
+ it('suppresses the body in production', async () => {
104
+ const logger = collectingLogger();
105
+ const transport = new LogSmsTransport(logger);
106
+ const prev = process.env.NODE_ENV;
107
+ process.env.NODE_ENV = 'production';
108
+ try {
109
+ await transport.send({ to: '+8613812345678', body: 'SECRET-999999' });
110
+ } finally {
111
+ process.env.NODE_ENV = prev;
112
+ }
113
+ expect(logger.lines.join('\n')).not.toContain('SECRET-999999');
114
+ expect(logger.lines.join('\n')).toContain('body suppressed');
115
+ });
116
+
117
+ it('prints the body outside production (local OTP testing)', async () => {
118
+ const logger = collectingLogger();
119
+ const transport = new LogSmsTransport(logger);
120
+ const prev = process.env.NODE_ENV;
121
+ process.env.NODE_ENV = 'test';
122
+ try {
123
+ await transport.send({ to: '+8613812345678', body: 'code 424242' });
124
+ } finally {
125
+ process.env.NODE_ENV = prev;
126
+ }
127
+ expect(logger.lines.join('\n')).toContain('code 424242');
128
+ });
129
+ });
@@ -0,0 +1,144 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ ISmsService,
5
+ ISmsTransport,
6
+ NormalizedSmsMessage,
7
+ SendSmsInput,
8
+ SendSmsResult,
9
+ SmsTransportSendResult,
10
+ } from '@objectstack/spec/contracts';
11
+
12
+ /**
13
+ * Normalize + validate a recipient phone number. Accepts E.164 and common
14
+ * human formats (spaces / dashes / dots / parens are stripped). Returns
15
+ * `undefined` when the result doesn't look like a phone number.
16
+ *
17
+ * Same shape rule as plugin-auth's `normalizePhoneNumber` — 6-15 digits
18
+ * with an optional leading `+` (kept local: the two packages must not
19
+ * depend on each other).
20
+ */
21
+ export function normalizeSmsRecipient(raw: string): string | undefined {
22
+ const stripped = String(raw ?? '').replace(/[\s\-().]/g, '');
23
+ return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined;
24
+ }
25
+
26
+ /**
27
+ * Mask a phone number for log lines: keep the prefix (country-code-ish) and
28
+ * the last two digits, hide the middle. `+8613812345678` → `+8613******78`.
29
+ * SMS logging policy: masked recipient + status ONLY — never the body
30
+ * (OTP codes travel in it; see the ISmsService contract header).
31
+ */
32
+ export function maskPhoneNumber(phone: string): string {
33
+ const p = String(phone ?? '');
34
+ if (p.length <= 6) return `${p.slice(0, 2)}****`;
35
+ return `${p.slice(0, 5)}${'*'.repeat(Math.max(2, p.length - 7))}${p.slice(-2)}`;
36
+ }
37
+
38
+ /**
39
+ * Development transport — never actually sends. Logs the masked recipient
40
+ * (and, OUTSIDE production only, the message body so local OTP flows are
41
+ * testable) and returns a synthetic message id.
42
+ *
43
+ * The production-body suppression is a hard rule from #2780: OTP codes must
44
+ * never land in logs. In dev the body IS the delivery — same pattern as the
45
+ * auth magic-link / invitation URLs, which are printed in dev only.
46
+ */
47
+ export class LogSmsTransport implements ISmsTransport {
48
+ private counter = 0;
49
+ constructor(private readonly logger?: { info: (msg: string, meta?: any) => void }) {}
50
+ async send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult> {
51
+ const messageId = `dev-sms-${Date.now()}-${++this.counter}`;
52
+ const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production';
53
+ this.logger?.info(
54
+ `[LogSmsTransport] would send SMS to ${maskPhoneNumber(message.to)}` +
55
+ (dev ? ` — body: ${message.body}` : ' (body suppressed outside dev)'),
56
+ { messageId },
57
+ );
58
+ return { messageId, response: 'logged' };
59
+ }
60
+ }
61
+
62
+ export interface SmsServiceOptions {
63
+ transport: ISmsTransport;
64
+ /**
65
+ * Whether `transport` is a real provider (Aliyun / Twilio / injected).
66
+ * `false` = development log fallback; surfaced via `isConfigured()` so
67
+ * consumers can gate SMS-dependent features in production.
68
+ */
69
+ configured: boolean;
70
+ /** Retry attempts on transport throw. Default 0 (no retry). */
71
+ retries?: number;
72
+ /** Logger for diagnostic output. NEVER receives message bodies. */
73
+ logger?: {
74
+ info: (msg: string, meta?: any) => void;
75
+ warn: (msg: string, meta?: any) => void;
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Concrete ISmsService implementation.
81
+ *
82
+ * Flow: validate + normalize input → transport.send() (with optional
83
+ * retry) → SendSmsResult. Deliberately NO persistence and NO body logging —
84
+ * see the contract header in `@objectstack/spec/contracts/sms-service.ts`.
85
+ */
86
+ export class SmsService implements ISmsService {
87
+ constructor(public options: SmsServiceOptions) {
88
+ if (!options.transport) throw new Error('SmsService: transport is required');
89
+ }
90
+
91
+ /**
92
+ * Hot-swap the underlying transport (settings namespace changed). The
93
+ * `configured` flag travels with the transport.
94
+ */
95
+ setTransport(transport: ISmsTransport, configured: boolean): void {
96
+ this.options.transport = transport;
97
+ this.options.configured = configured;
98
+ }
99
+
100
+ isConfigured(): boolean {
101
+ return this.options.configured;
102
+ }
103
+
104
+ async send(input: SendSmsInput): Promise<SendSmsResult> {
105
+ const to = normalizeSmsRecipient(input?.to ?? '');
106
+ if (!to) {
107
+ throw new Error(`VALIDATION_FAILED: '${maskPhoneNumber(String(input?.to ?? ''))}' is not a valid phone number`);
108
+ }
109
+ const body = String(input?.body ?? '').trim();
110
+ if (!body && !input?.templateId && !input?.templateParams) {
111
+ throw new Error('VALIDATION_FAILED: body (or templateId/templateParams) is required');
112
+ }
113
+
114
+ const normalized: NormalizedSmsMessage = {
115
+ to,
116
+ body,
117
+ ...(input.templateId ? { templateId: input.templateId } : {}),
118
+ ...(input.templateParams ? { templateParams: input.templateParams } : {}),
119
+ };
120
+
121
+ const id = `sms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
122
+ const maxAttempts = (this.options.retries ?? 0) + 1;
123
+ let lastError: unknown;
124
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
125
+ try {
126
+ const result = await this.options.transport.send(normalized);
127
+ this.options.logger?.info(
128
+ `[SmsService] sent to ${maskPhoneNumber(to)} (messageId=${result.messageId})`,
129
+ );
130
+ return { id, status: 'sent', messageId: result.messageId };
131
+ } catch (err) {
132
+ lastError = err;
133
+ if (attempt < maxAttempts) {
134
+ await new Promise((r) => setTimeout(r, Math.min(2000, 100 * 2 ** (attempt - 1))));
135
+ }
136
+ }
137
+ }
138
+ const errMessage = String((lastError as Error)?.message ?? lastError ?? 'send failed').slice(0, 500);
139
+ this.options.logger?.warn(
140
+ `[SmsService] send to ${maskPhoneNumber(to)} failed: ${errMessage}`,
141
+ );
142
+ return { id, status: 'failed', error: errMessage };
143
+ }
144
+ }