@objectstack/service-sms 16.1.0 → 17.0.0-rc.1
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 +365 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -4
- package/.turbo/turbo-build.log +0 -22
- package/src/index.ts +0 -19
- package/src/sms-plugin.test.ts +0 -155
- package/src/sms-plugin.ts +0 -252
- package/src/sms-service.test.ts +0 -129
- package/src/sms-service.ts +0 -144
- package/src/transports/aliyun.ts +0 -115
- package/src/transports/index.ts +0 -39
- package/src/transports/transports.test.ts +0 -114
- package/src/transports/twilio.ts +0 -68
- package/tsconfig.json +0 -10
package/src/sms-plugin.ts
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
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
|
-
}
|
package/src/sms-service.test.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
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
|
-
});
|
package/src/sms-service.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
}
|
package/src/transports/aliyun.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
4
|
-
import type { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult } from '@objectstack/spec/contracts';
|
|
5
|
-
|
|
6
|
-
export interface AliyunSmsTransportOptions {
|
|
7
|
-
accessKeyId: string;
|
|
8
|
-
accessKeySecret: string;
|
|
9
|
-
/** 短信签名 SignName — the registered sender signature, e.g. `阿里云短信测试`. */
|
|
10
|
-
signName: string;
|
|
11
|
-
/**
|
|
12
|
-
* Default 模板 TemplateCode used when the input carries no `templateId`
|
|
13
|
-
* (Aliyun only delivers pre-registered templates — free-form bodies are
|
|
14
|
-
* refused by the API). A catch-all template with a single `${content}`
|
|
15
|
-
* variable makes generic notification sends possible.
|
|
16
|
-
*/
|
|
17
|
-
defaultTemplateCode?: string;
|
|
18
|
-
/** API endpoint host. Default `dysmsapi.aliyuncs.com`. */
|
|
19
|
-
endpoint?: string;
|
|
20
|
-
/** Injectable fetch for tests. */
|
|
21
|
-
fetchImpl?: typeof fetch;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const API_VERSION = '2017-05-25';
|
|
25
|
-
const ALGORITHM = 'ACS3-HMAC-SHA256';
|
|
26
|
-
|
|
27
|
-
const sha256Hex = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
28
|
-
const hmac256Hex = (key: string, s: string): string => createHmac('sha256', key).update(s, 'utf8').digest('hex');
|
|
29
|
-
|
|
30
|
-
/** RFC 3986 percent-encoding (Aliyun requires `%20`, `%2A`, `%7E` handling). */
|
|
31
|
-
const encode = (s: string): string =>
|
|
32
|
-
encodeURIComponent(s)
|
|
33
|
-
.replace(/\+/g, '%20')
|
|
34
|
-
.replace(/\*/g, '%2A')
|
|
35
|
-
.replace(/%7E/g, '~');
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Aliyun SMS (dysmsapi `SendSms`) transport, signed with the current
|
|
39
|
-
* ACS3-HMAC-SHA256 scheme — plain `fetch` + `node:crypto`, no vendor SDK.
|
|
40
|
-
*
|
|
41
|
-
* Aliyun is template-only: the transport sends `templateId` (falling back to
|
|
42
|
-
* the configured default TemplateCode) with `templateParams` (falling back to
|
|
43
|
-
* `{ content: body }` for the catch-all-template pattern). The rendered
|
|
44
|
-
* `body` itself is never transmitted outside `TemplateParam`.
|
|
45
|
-
*/
|
|
46
|
-
export class AliyunSmsTransport implements ISmsTransport {
|
|
47
|
-
private readonly endpoint: string;
|
|
48
|
-
private readonly fetchImpl: typeof fetch;
|
|
49
|
-
|
|
50
|
-
constructor(private readonly options: AliyunSmsTransportOptions) {
|
|
51
|
-
if (!options.accessKeyId || !options.accessKeySecret) {
|
|
52
|
-
throw new Error('AliyunSmsTransport: accessKeyId and accessKeySecret are required');
|
|
53
|
-
}
|
|
54
|
-
if (!options.signName) {
|
|
55
|
-
throw new Error('AliyunSmsTransport: signName is required');
|
|
56
|
-
}
|
|
57
|
-
this.endpoint = options.endpoint ?? 'dysmsapi.aliyuncs.com';
|
|
58
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult> {
|
|
62
|
-
const templateCode = message.templateId ?? this.options.defaultTemplateCode;
|
|
63
|
-
if (!templateCode) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
'AliyunSmsTransport: Aliyun requires a template — pass templateId or configure a default template code',
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
const templateParam = JSON.stringify(message.templateParams ?? { content: message.body });
|
|
69
|
-
|
|
70
|
-
const query: Record<string, string> = {
|
|
71
|
-
PhoneNumbers: message.to,
|
|
72
|
-
SignName: this.options.signName,
|
|
73
|
-
TemplateCode: templateCode,
|
|
74
|
-
TemplateParam: templateParam,
|
|
75
|
-
};
|
|
76
|
-
const canonicalQuery = Object.keys(query)
|
|
77
|
-
.sort()
|
|
78
|
-
.map((k) => `${encode(k)}=${encode(query[k])}`)
|
|
79
|
-
.join('&');
|
|
80
|
-
|
|
81
|
-
const bodyHash = sha256Hex(''); // POST with all parameters in the query string
|
|
82
|
-
const headers: Record<string, string> = {
|
|
83
|
-
host: this.endpoint,
|
|
84
|
-
'x-acs-action': 'SendSms',
|
|
85
|
-
'x-acs-content-sha256': bodyHash,
|
|
86
|
-
'x-acs-date': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
87
|
-
'x-acs-signature-nonce': randomUUID(),
|
|
88
|
-
'x-acs-version': API_VERSION,
|
|
89
|
-
};
|
|
90
|
-
const signedHeaderNames = Object.keys(headers).sort();
|
|
91
|
-
const canonicalHeaders = signedHeaderNames.map((k) => `${k}:${headers[k].trim()}\n`).join('');
|
|
92
|
-
const signedHeaders = signedHeaderNames.join(';');
|
|
93
|
-
|
|
94
|
-
const canonicalRequest = ['POST', '/', canonicalQuery, canonicalHeaders, signedHeaders, bodyHash].join('\n');
|
|
95
|
-
const stringToSign = `${ALGORITHM}\n${sha256Hex(canonicalRequest)}`;
|
|
96
|
-
const signature = hmac256Hex(this.options.accessKeySecret, stringToSign);
|
|
97
|
-
|
|
98
|
-
const response = await this.fetchImpl(`https://${this.endpoint}/?${canonicalQuery}`, {
|
|
99
|
-
method: 'POST',
|
|
100
|
-
headers: {
|
|
101
|
-
...headers,
|
|
102
|
-
Authorization: `${ALGORITHM} Credential=${this.options.accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`,
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
let payload: any = {};
|
|
107
|
-
try { payload = await response.json(); } catch { /* non-JSON error body */ }
|
|
108
|
-
if (!response.ok || payload?.Code !== 'OK') {
|
|
109
|
-
const code = payload?.Code ?? `HTTP_${response.status}`;
|
|
110
|
-
const detail = payload?.Message ?? response.statusText ?? 'request failed';
|
|
111
|
-
throw new Error(`Aliyun SendSms failed (${code}): ${detail}`);
|
|
112
|
-
}
|
|
113
|
-
return { messageId: String(payload.BizId ?? payload.RequestId ?? ''), response: payload.RequestId };
|
|
114
|
-
}
|
|
115
|
-
}
|
package/src/transports/index.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { ISmsTransport } from '@objectstack/spec/contracts';
|
|
4
|
-
import { LogSmsTransport } from '../sms-service.js';
|
|
5
|
-
import { AliyunSmsTransport } from './aliyun.js';
|
|
6
|
-
import { TwilioSmsTransport } from './twilio.js';
|
|
7
|
-
|
|
8
|
-
export { AliyunSmsTransport, type AliyunSmsTransportOptions } from './aliyun.js';
|
|
9
|
-
export { TwilioSmsTransport, type TwilioSmsTransportOptions } from './twilio.js';
|
|
10
|
-
|
|
11
|
-
export type SmsProviderTag = 'log' | 'aliyun' | 'twilio';
|
|
12
|
-
|
|
13
|
-
export interface MakeSmsTransportOptions {
|
|
14
|
-
provider: SmsProviderTag;
|
|
15
|
-
/** Provider-specific credentials/options (see the transport option types). */
|
|
16
|
-
options?: Record<string, unknown>;
|
|
17
|
-
logger?: { info: (msg: string, meta?: any) => void };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Build an ISmsTransport from a provider tag + opts. Used by
|
|
22
|
-
* SmsServicePlugin to materialise the transport selected by config /
|
|
23
|
-
* the `sms` settings namespace.
|
|
24
|
-
*
|
|
25
|
-
* Throws when a non-`log` provider is missing required credentials.
|
|
26
|
-
*/
|
|
27
|
-
export function makeSmsTransport(opts: MakeSmsTransportOptions): ISmsTransport {
|
|
28
|
-
const { provider, options = {}, logger } = opts;
|
|
29
|
-
switch (provider) {
|
|
30
|
-
case 'log':
|
|
31
|
-
return new LogSmsTransport(logger);
|
|
32
|
-
case 'aliyun':
|
|
33
|
-
return new AliyunSmsTransport(options as any);
|
|
34
|
-
case 'twilio':
|
|
35
|
-
return new TwilioSmsTransport(options as any);
|
|
36
|
-
default:
|
|
37
|
-
throw new Error(`makeSmsTransport: unknown provider '${provider}'`);
|
|
38
|
-
}
|
|
39
|
-
}
|