@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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +78 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +443 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +409 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +35 -0
- package/src/index.ts +19 -0
- package/src/sms-plugin.test.ts +155 -0
- package/src/sms-plugin.ts +252 -0
- package/src/sms-service.test.ts +129 -0
- package/src/sms-service.ts +144 -0
- package/src/transports/aliyun.ts +115 -0
- package/src/transports/index.ts +39 -0
- package/src/transports/transports.test.ts +114 -0
- package/src/transports/twilio.ts +68 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { AliyunSmsTransport } from './aliyun.js';
|
|
5
|
+
import { TwilioSmsTransport } from './twilio.js';
|
|
6
|
+
import { makeSmsTransport } from './index.js';
|
|
7
|
+
import { LogSmsTransport } from '../sms-service.js';
|
|
8
|
+
|
|
9
|
+
const jsonResponse = (body: any, status = 200) =>
|
|
10
|
+
({
|
|
11
|
+
ok: status >= 200 && status < 300,
|
|
12
|
+
status,
|
|
13
|
+
statusText: 'x',
|
|
14
|
+
json: async () => body,
|
|
15
|
+
}) as any;
|
|
16
|
+
|
|
17
|
+
describe('AliyunSmsTransport', () => {
|
|
18
|
+
const base = {
|
|
19
|
+
accessKeyId: 'ak',
|
|
20
|
+
accessKeySecret: 'secret',
|
|
21
|
+
signName: '测试签名',
|
|
22
|
+
defaultTemplateCode: 'SMS_123',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('requires credentials + sign name', () => {
|
|
26
|
+
expect(() => new AliyunSmsTransport({ ...base, accessKeyId: '' } as any)).toThrow(/accessKeyId/);
|
|
27
|
+
expect(() => new AliyunSmsTransport({ ...base, signName: '' } as any)).toThrow(/signName/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sends a signed SendSms request with template params', async () => {
|
|
31
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'biz_1', RequestId: 'req_1' }));
|
|
32
|
+
const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
|
|
33
|
+
const r = await t.send({ to: '+8613800000000', body: 'ignored', templateParams: { code: '123456' } });
|
|
34
|
+
expect(r.messageId).toBe('biz_1');
|
|
35
|
+
|
|
36
|
+
const [url, init] = fetchImpl.mock.calls[0] as any[];
|
|
37
|
+
expect(String(url)).toContain('https://dysmsapi.aliyuncs.com/?');
|
|
38
|
+
expect(String(url)).toContain('PhoneNumbers=%2B8613800000000');
|
|
39
|
+
expect(String(url)).toContain('TemplateCode=SMS_123');
|
|
40
|
+
expect(decodeURIComponent(String(url))).toContain('{"code":"123456"}');
|
|
41
|
+
expect(init.method).toBe('POST');
|
|
42
|
+
expect(init.headers.Authorization).toMatch(/^ACS3-HMAC-SHA256 Credential=ak,SignedHeaders=host;x-acs-action;/);
|
|
43
|
+
expect(init.headers['x-acs-action']).toBe('SendSms');
|
|
44
|
+
expect(init.headers['x-acs-version']).toBe('2017-05-25');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('falls back to { content: body } for the default catch-all template', async () => {
|
|
48
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'b' }));
|
|
49
|
+
const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
|
|
50
|
+
await t.send({ to: '13800000000', body: 'hello world' });
|
|
51
|
+
expect(decodeURIComponent(String(fetchImpl.mock.calls[0][0]))).toContain('{"content":"hello world"}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws when no template code is available (Aliyun is template-only)', async () => {
|
|
55
|
+
const t = new AliyunSmsTransport({ ...base, defaultTemplateCode: undefined, fetchImpl: vi.fn() as any });
|
|
56
|
+
await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/template/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('maps a non-OK Code to a thrown error', async () => {
|
|
60
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'isv.BUSINESS_LIMIT_CONTROL', Message: 'limit' }));
|
|
61
|
+
const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any });
|
|
62
|
+
await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/BUSINESS_LIMIT_CONTROL/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('TwilioSmsTransport', () => {
|
|
67
|
+
const base = { accountSid: 'AC123', authToken: 'tok', from: '+15005550006' };
|
|
68
|
+
|
|
69
|
+
it('requires credentials and a sender', () => {
|
|
70
|
+
expect(() => new TwilioSmsTransport({ ...base, authToken: '' } as any)).toThrow(/accountSid and authToken/);
|
|
71
|
+
expect(() => new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok' } as any)).toThrow(/from/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('POSTs the Messages resource with Basic auth + form body', async () => {
|
|
75
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM1', status: 'queued' }, 201));
|
|
76
|
+
const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any });
|
|
77
|
+
const r = await t.send({ to: '+15005550009', body: 'hi there' });
|
|
78
|
+
expect(r.messageId).toBe('SM1');
|
|
79
|
+
|
|
80
|
+
const [url, init] = fetchImpl.mock.calls[0] as any[];
|
|
81
|
+
expect(String(url)).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
|
|
82
|
+
expect(init.headers.Authorization).toBe(`Basic ${Buffer.from('AC123:tok').toString('base64')}`);
|
|
83
|
+
const form = new URLSearchParams(init.body);
|
|
84
|
+
expect(form.get('To')).toBe('+15005550009');
|
|
85
|
+
expect(form.get('Body')).toBe('hi there');
|
|
86
|
+
expect(form.get('From')).toBe('+15005550006');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('prefers MessagingServiceSid over From when configured', async () => {
|
|
90
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM2' }, 201));
|
|
91
|
+
const t = new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok', messagingServiceSid: 'MG9', fetchImpl: fetchImpl as any });
|
|
92
|
+
await t.send({ to: '+15005550009', body: 'x' });
|
|
93
|
+
const form = new URLSearchParams((fetchImpl.mock.calls[0] as any[])[1].body);
|
|
94
|
+
expect(form.get('MessagingServiceSid')).toBe('MG9');
|
|
95
|
+
expect(form.get('From')).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('maps an error response to a thrown error', async () => {
|
|
99
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ code: 21211, message: 'invalid To' }, 400));
|
|
100
|
+
const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any });
|
|
101
|
+
await expect(t.send({ to: '+1', body: 'x' })).rejects.toThrow(/21211.*invalid To/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('makeSmsTransport', () => {
|
|
106
|
+
it('builds by provider tag', () => {
|
|
107
|
+
expect(makeSmsTransport({ provider: 'log' })).toBeInstanceOf(LogSmsTransport);
|
|
108
|
+
expect(makeSmsTransport({ provider: 'aliyun', options: { accessKeyId: 'a', accessKeySecret: 'b', signName: 'c' } }))
|
|
109
|
+
.toBeInstanceOf(AliyunSmsTransport);
|
|
110
|
+
expect(makeSmsTransport({ provider: 'twilio', options: { accountSid: 'a', authToken: 'b', from: '+1' } }))
|
|
111
|
+
.toBeInstanceOf(TwilioSmsTransport);
|
|
112
|
+
expect(() => makeSmsTransport({ provider: 'nope' as any })).toThrow(/unknown provider/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult } from '@objectstack/spec/contracts';
|
|
4
|
+
|
|
5
|
+
export interface TwilioSmsTransportOptions {
|
|
6
|
+
accountSid: string;
|
|
7
|
+
authToken: string;
|
|
8
|
+
/** Sender number (E.164). One of `from` / `messagingServiceSid` is required. */
|
|
9
|
+
from?: string;
|
|
10
|
+
/** Twilio Messaging Service SID (alternative to a fixed `from` number). */
|
|
11
|
+
messagingServiceSid?: string;
|
|
12
|
+
/** API base URL override (tests). Default `https://api.twilio.com`. */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** Injectable fetch for tests. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Twilio Programmable Messaging transport — a single `POST
|
|
20
|
+
* /2010-04-01/Accounts/{sid}/Messages.json` with HTTP Basic auth. Plain
|
|
21
|
+
* `fetch`, no vendor SDK. Free-form: delivers `body` verbatim.
|
|
22
|
+
*/
|
|
23
|
+
export class TwilioSmsTransport implements ISmsTransport {
|
|
24
|
+
private readonly baseUrl: string;
|
|
25
|
+
private readonly fetchImpl: typeof fetch;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly options: TwilioSmsTransportOptions) {
|
|
28
|
+
if (!options.accountSid || !options.authToken) {
|
|
29
|
+
throw new Error('TwilioSmsTransport: accountSid and authToken are required');
|
|
30
|
+
}
|
|
31
|
+
if (!options.from && !options.messagingServiceSid) {
|
|
32
|
+
throw new Error('TwilioSmsTransport: one of from / messagingServiceSid is required');
|
|
33
|
+
}
|
|
34
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.twilio.com').replace(/\/$/, '');
|
|
35
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async send(message: NormalizedSmsMessage): Promise<SmsTransportSendResult> {
|
|
39
|
+
const form = new URLSearchParams({
|
|
40
|
+
To: message.to,
|
|
41
|
+
Body: message.body,
|
|
42
|
+
...(this.options.messagingServiceSid
|
|
43
|
+
? { MessagingServiceSid: this.options.messagingServiceSid }
|
|
44
|
+
: { From: this.options.from as string }),
|
|
45
|
+
});
|
|
46
|
+
const auth = Buffer.from(`${this.options.accountSid}:${this.options.authToken}`).toString('base64');
|
|
47
|
+
const response = await this.fetchImpl(
|
|
48
|
+
`${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.options.accountSid)}/Messages.json`,
|
|
49
|
+
{
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Basic ${auth}`,
|
|
53
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
54
|
+
},
|
|
55
|
+
body: form.toString(),
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
let payload: any = {};
|
|
60
|
+
try { payload = await response.json(); } catch { /* non-JSON error body */ }
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const code = payload?.code ?? `HTTP_${response.status}`;
|
|
63
|
+
const detail = payload?.message ?? response.statusText ?? 'request failed';
|
|
64
|
+
throw new Error(`Twilio send failed (${code}): ${detail}`);
|
|
65
|
+
}
|
|
66
|
+
return { messageId: String(payload.sid ?? ''), response: payload.status };
|
|
67
|
+
}
|
|
68
|
+
}
|