diff --git a/.changeset/config.json b/.changeset/config.json index 99350f52d5..c393b904e9 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -44,6 +44,7 @@ "@objectstack/plugin-reports", "@objectstack/plugin-security", "@objectstack/plugin-sharing", + "@objectstack/plugin-sms", "@objectstack/plugin-webhooks", "@objectstack/trigger-record-change", "@objectstack/trigger-api", diff --git a/.changeset/sms-infrastructure-phone-otp.md b/.changeset/sms-infrastructure-phone-otp.md new file mode 100644 index 0000000000..14684bc5bb --- /dev/null +++ b/.changeset/sms-infrastructure-phone-otp.md @@ -0,0 +1,38 @@ +--- +"@objectstack/plugin-sms": minor +"@objectstack/plugin-auth": minor +"@objectstack/service-messaging": minor +"@objectstack/service-settings": minor +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +feat(messaging/auth): SMS infrastructure + phone-number OTP first-login/reset (#2780) + +#2766 shipped phone+password sign-in but no OTP — the platform had no SMS +delivery capability. This adds the missing infrastructure end to end: + +- **New `@objectstack/plugin-sms`** — `ISmsService`/`ISmsTransport` contracts + (spec) with Aliyun SMS (ACS3-HMAC-SHA256, template-based) and Twilio + transports plus a dev log fallback. Configured through the new `sms` + settings namespace (live provider rebind, encrypted secrets, send-test + action; `OS_SMS_*` env keys win at the resolver). Deliberately NO message + persistence and NO body logging — SMS bodies carry OTP codes. +- **Messaging `sms` channel** — registered at kernel:ready when an `sms` + service is present; `notify(channels:['sms'])` resolves + `sys_user.phone_number`, renders `(topic,'sms',locale)` templates, and + inherits outbox retry/dead-letter. +- **Phone OTP flows open** — the phoneNumber plugin's `sendOTP` / + `sendPasswordResetOTP` now deliver via SMS, enabling + `/phone-number/send-otp` + `/verify` (OTP sign-in/verification) and + `/phone-number/request-password-reset` + `/reset-password` (self-service + reset). Without a deliverable SMS service they keep failing loudly + (NOT_SUPPORTED); `features.phoneNumberOtp` advertises real availability. + Shipped with the abuse hardening: explicit `allowedAttempts: 3`, always-on + per-number cooldown (60s) + rolling-hour cap (5, secondaryStorage-shared + across nodes), `/phone-number/*` in the settings-bound per-IP rate-limit + rules, and OTP codes never reach logs or error messages. +- **Import SMS invites** — `/admin/import-users`'s `invite` policy now + supports phone-only rows: a credential-free invitation SMS points the + employee at phone-OTP first sign-in followed by self-set password; mixed + files validate the reachable channel per row. diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index f57e51585e..574ee1af20 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -5,7 +5,7 @@ description: Complete guide to all ObjectStack packages, services, drivers, plug # Package Overview -ObjectStack is organized into **70 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/framework/tree/main/packages). +ObjectStack is organized into **71 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/framework/tree/main/packages). ### Package categories at a glance @@ -15,7 +15,7 @@ ObjectStack is organized into **70 package manifests** across multiple categorie | **Client / DX** | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` | | **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) | | **Drivers** | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` | -| **Plugins** | 18 | `plugin-auth`, `plugin-security`, `plugin-org-scoping`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | +| **Plugins** | 19 | `plugin-auth`, `plugin-security`, `plugin-org-scoping`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-sms`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) | | **Platform services** | 15 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-storage` | @@ -363,6 +363,13 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern - **Features**: Provider adapters, MJML templates, delivery tracking - **When to use**: Transactional and workflow-driven email +### @objectstack/plugin-sms + +**SMS Plugin** — Outbound SMS delivery (`sms` service). + +- **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel +- **When to use**: Phone OTP first-login / self-service reset, SMS notifications + ### @objectstack/plugin-webhooks **Webhooks Plugin** — Outbound HTTP webhook delivery. diff --git a/packages/cli/package.json b/packages/cli/package.json index f088cb1472..f4c3764554 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,6 +62,7 @@ "@objectstack/plugin-reports": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", + "@objectstack/plugin-sms": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", "@objectstack/rest": "workspace:*", "@objectstack/runtime": "workspace:^", diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 785e0ef861..c5136a4c4c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -185,7 +185,9 @@ export default class Serve extends Command { * mirror this list on their per-project kernels. */ static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([ - 'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sharing', 'messaging', + // The first six form the pinned foundational prefix (see + // serve-defaults.test.ts) — grow the slate AFTER them. + 'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sms', 'sharing', 'messaging', // `analytics` is foundational post-ADR-0021: the AnalyticsService backs the // dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even // when an app declares no `analyticsCubes`, because a `dataset` can be @@ -1659,6 +1661,15 @@ export default class Serve extends Command { export: 'EmailServicePlugin', nameMatch: ['plugin-email', 'EmailServicePlugin'], }, + sms: { + // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and + // the messaging `sms` channel. Provider config lives in the `sms` + // settings namespace (OS_SMS_* env keys win at the resolver); + // unconfigured ⇒ dev LogSmsTransport (no real send). + pkg: '@objectstack/plugin-sms', + export: 'SmsServicePlugin', + nameMatch: ['plugin-sms', 'SmsServicePlugin'], + }, sharing: { pkg: '@objectstack/plugin-sharing', export: 'SharingServicePlugin', @@ -1758,6 +1769,19 @@ export default class Serve extends Command { )); arg.provider = 'log'; } + } else if (cap === 'sms') { + // Compose SmsServicePlugin options from config.sms + OS_SMS_* env + // (#2780). Same precedence as email: env beats config. Provider + // credentials normally live in the `sms` settings namespace + // (bound at kernel:ready); constructor opts cover pre-settings + // boot and hosts without the settings service. + const cfgSms = (config as any).sms ?? {}; + const provider = (process.env.OS_SMS_PROVIDER || cfgSms.provider || 'log').toLowerCase(); + arg = { + provider, + ...(cfgSms.providerOptions ? { providerOptions: cfgSms.providerOptions } : {}), + ...(cfgSms.retries != null ? { retries: cfgSms.retries } : {}), + }; } else if (cap === 'storage') { // Storage is now in the default capability slate. If the host // hasn't configured a backend explicitly we fall back to the diff --git a/packages/plugins/plugin-auth/src/admin-import-users.test.ts b/packages/plugins/plugin-auth/src/admin-import-users.test.ts index 7695e7a051..57dcc6954d 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.test.ts @@ -18,7 +18,9 @@ function makeDeps(opts: { existingUsers?: Array>; phoneEnabled?: boolean; emailAvailable?: boolean; + smsInviteAvailable?: boolean; resetFails?: boolean; + smsFails?: boolean; } = {}) { const existing = opts.existingUsers ?? []; let nextId = 1; @@ -37,15 +39,20 @@ function makeDeps(opts: { const insert = vi.fn(async () => ({})); const warn = vi.fn(); const noteMustChangePasswordIssued = vi.fn(); + const sendInviteSms = vi.fn(async () => { + if (opts.smsFails) throw new Error('sms provider down'); + }); const deps: IdentityImportDeps = { getAuthApi: async () => ({ createUser, requestPasswordReset }), getDataEngine: () => ({ find, update, insert }), phoneNumberEnabled: () => opts.phoneEnabled ?? false, emailServiceAvailable: () => opts.emailAvailable ?? true, + smsInviteAvailable: () => opts.smsInviteAvailable ?? false, + sendInviteSms, noteMustChangePasswordIssued, logger: { warn }, }; - return { deps, createUser, requestPasswordReset, find, update, insert, warn, noteMustChangePasswordIssued }; + return { deps, createUser, requestPasswordReset, sendInviteSms, find, update, insert, warn, noteMustChangePasswordIssued }; } /** Red line: no generated password may reach any persistence/log surface. */ @@ -255,6 +262,82 @@ describe('runAdminImportUsers — invite policy', () => { expect(row.code).toBe('INVITE_EMAIL_FAILED'); expect((res.body.data as any).summary.created).toBe(1); }); + + // #2780 — SMS invite variant for phone-only rows. + it('sends an SMS invite (not a reset email) to phone-only rows when SMS is available', async () => { + const m = makeDeps({ phoneEnabled: true, smsInviteAvailable: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'invite', format: 'json', + rows: [ + { email: 'a@x.co', name: 'Mail' }, + { phone_number: '+86 138 0000 0002', name: 'PhoneOnly' }, + ], + }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + // Email row → reset email; phone-only row → invitation SMS to the + // NORMALIZED number, never a reset email to the placeholder address. + expect(m.requestPasswordReset).toHaveBeenCalledTimes(1); + expect(m.requestPasswordReset.mock.calls[0][0].body.email).toBe('a@x.co'); + expect(m.sendInviteSms).toHaveBeenCalledTimes(1); + expect(m.sendInviteSms.mock.calls[0][0]).toBe('+8613800000002'); + // The phone-only account got a placeholder email that never leaks the phone. + const phoneCreate = m.createUser.mock.calls.find((c) => c[0].body?.data?.phoneNumber); + expect(phoneCreate![0].body.email).toMatch(/@placeholder\.invalid$/); + // No temp passwords under invite. + expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true); + }); + + it('keeps the row created (with INVITE_SMS_FAILED) when the SMS fails — no rollback', async () => { + const m = makeDeps({ phoneEnabled: true, smsInviteAvailable: true, smsFails: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ phone_number: '+8613800000003' }] }), + ACTOR, + ); + const row = (res.body.data as any).rows[0]; + expect(row.ok).toBe(true); + expect(row.action).toBe('created'); + expect(row.code).toBe('INVITE_SMS_FAILED'); + expect((res.body.data as any).summary.created).toBe(1); + }); + + it('with SMS but no email service: phone-only rows invite, email rows fail per-row', async () => { + const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'invite', format: 'json', + rows: [ + { email: 'a@x.co' }, + { phone_number: '+8613800000004' }, + ], + }), + ACTOR, + ); + expect(res.status).toBe(200); // not rejected outright — one channel works + const rows = (res.body.data as any).rows; + expect(rows[0].code).toBe('EMAIL_SERVICE_REQUIRED'); + expect(rows[1].action).toBe('created'); + expect(m.sendInviteSms).toHaveBeenCalledTimes(1); + expect(m.requestPasswordReset).not.toHaveBeenCalled(); + }); + + it('still rejects invite outright when NEITHER email nor SMS is wired', async () => { + const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ phone_number: '+8613800000005' }] }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('EMAIL_SERVICE_REQUIRED'); + expect(m.createUser).not.toHaveBeenCalled(); + }); }); describe('runAdminImportUsers — upsert', () => { diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index 642c63ad64..09174ff93d 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -14,10 +14,14 @@ * `ImportProtocolLike` whose `createData` drives `auth.api.createUser`. * * Password policies: - * - `invite` — every row needs a REAL email. Accounts are created with a - * random throwaway password the user never sees, then a - * reset-password email ("set your password") is requested - * for each created account. Requires a wired EmailService. + * - `invite` — every row needs a reachable identity. Accounts are created + * with a random throwaway password the user never sees, then + * a "set your password" invitation goes out per created + * account: a reset-password email for rows with a REAL + * email (requires a wired EmailService), or — #2780 — an + * invitation SMS for phone-only rows (requires a wired, + * deliverable SmsService + the phoneNumber plugin; the user + * first signs in via phone OTP and then sets a password). * - `temporary` — each created account gets a generated temporary password, * `must_change_password` is stamped (403 PASSWORD_EXPIRED * until changed), and the passwords are returned ONCE in the @@ -63,6 +67,14 @@ export interface IdentityImportDeps { getMetaItem?(ref: { type: string; name: string }): Promise; phoneNumberEnabled(): boolean; emailServiceAvailable(): boolean; + /** + * #2780 — can phone-only rows take the SMS-invite path? True when the + * phoneNumber plugin is on AND a deliverable SMS service is wired (the + * user must be able to complete phone-OTP first sign-in). + */ + smsInviteAvailable(): boolean; + /** #2780 — deliver the invitation SMS (no credential in the message). */ + sendInviteSms(phone: string): Promise; noteMustChangePasswordIssued(): void; logger?: { warn(msg: string): void }; } @@ -82,7 +94,12 @@ interface RowIdentity { */ function resolveRowIdentity( row: Record, - opts: { policy: 'invite' | 'temporary'; phoneEnabled: boolean }, + opts: { + policy: 'invite' | 'temporary'; + phoneEnabled: boolean; + emailInviteOk: boolean; + smsInviteOk: boolean; + }, ): RowIdentity { const rawEmail = typeof row.email === 'string' ? row.email.trim() : ''; const hasEmail = rawEmail.length > 0; @@ -109,13 +126,26 @@ function resolveRowIdentity( if (!hasEmail && !phone) { return { invalid: { code: 'NO_IDENTITY', error: 'Row needs an email or a phone_number' } }; } - if (opts.policy === 'invite' && !hasEmail) { - return { - invalid: { - code: 'INVITE_REQUIRES_EMAIL', - error: 'The invite policy needs a real email for this row — use the temporary policy for phone-only users', - }, - }; + if (opts.policy === 'invite') { + // Every invite row must be REACHABLE through a wired channel: email rows + // need the email service, phone-only rows the SMS-invite path (#2780). + // Validated per row so a mixed file fails only the rows it must. + if (hasEmail && !opts.emailInviteOk) { + return { + invalid: { + code: 'EMAIL_SERVICE_REQUIRED', + error: 'This row\'s invitation needs a configured email service — wire an EmailService or use the temporary policy', + }, + }; + } + if (!hasEmail && !opts.smsInviteOk) { + return { + invalid: { + code: 'INVITE_REQUIRES_EMAIL', + error: 'The invite policy needs a real email for this row — configure SMS delivery (phone OTP) for SMS invitations, or use the temporary policy', + }, + }; + } } return { email: hasEmail ? rawEmail.toLowerCase() : undefined, phone }; } @@ -143,10 +173,13 @@ export async function runAdminImportUsers( if (matchBy === 'phone' && !deps.phoneNumberEnabled()) { return fail(400, 'PHONE_NOT_ENABLED', 'matchBy "phone" requires the phoneNumber auth plugin (auth.plugins.phoneNumber)'); } - if (policy === 'invite' && !deps.emailServiceAvailable()) { - // Reject up front — otherwise N accounts get created whose invitation - // emails all fail (and outside dev we refuse to log the reset links). - return fail(400, 'EMAIL_SERVICE_REQUIRED', 'The invite policy requires a configured email service. Use the temporary policy or wire an EmailService.'); + const emailInviteOk = deps.emailServiceAvailable(); + const smsInviteOk = deps.smsInviteAvailable(); + if (policy === 'invite' && !emailInviteOk && !smsInviteOk) { + // Reject up front — otherwise N accounts get created whose invitations + // all fail (and outside dev we refuse to log the reset links). With at + // least one channel wired, per-row validation covers the rest. + return fail(400, 'EMAIL_SERVICE_REQUIRED', 'The invite policy requires a configured email service (or SMS delivery for phone-only rows). Use the temporary policy or wire an EmailService / SmsService.'); } if (body?.async === true) { // Async jobs are a persistence surface — they can't carry temporary @@ -185,7 +218,7 @@ export async function runAdminImportUsers( const validIndex: number[] = []; for (let i = 0; i < prepared.rows.length; i++) { const row = { ...prepared.rows[i] }; - const identity = resolveRowIdentity(row, { policy, phoneEnabled }); + const identity = resolveRowIdentity(row, { policy, phoneEnabled, emailInviteOk, smsInviteOk }); if (identity.invalid) { results[i] = { row: i + 1, ok: false, action: 'failed', code: identity.invalid.code, error: identity.invalid.error }; continue; @@ -200,7 +233,7 @@ export async function runAdminImportUsers( // ── Identity write protocol (the part generic import must NOT do) ──── const temporaryPasswords = new Map(); // record id → temp password - const createdEmails = new Map(); + const createdEmails = new Map(); const authApi = await deps.getAuthApi(); if (typeof authApi.createUser !== 'function') { return fail(501, 'not_supported', 'The better-auth admin plugin is not enabled (auth.plugins.admin)'); @@ -255,7 +288,7 @@ export async function runAdminImportUsers( deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`); } } else { - createdEmails.set(id, { email, placeholder }); + createdEmails.set(id, { email, placeholder, ...(phone ? { phone } : {}) }); } return { id }; }, @@ -301,12 +334,29 @@ export async function runAdminImportUsers( // ── Post-write phases (skipped on dryRun) ───────────────────────────── if (!prepared.dryRun) { - // invite: request a set-your-password email per created account. + // invite: send a set-your-password invitation per created account — + // a reset-password email for real-email rows, an invitation SMS for + // phone-only (placeholder-email) rows (#2780). The SMS carries no + // credential: the user requests their own OTP at first sign-in, which + // keeps the placeholder-email interception logic fully aligned (the + // placeholder address itself is never a delivery target on any channel). if (policy === 'invite') { for (const r of results) { if (!r || r.action !== 'created' || !r.id) continue; const target = createdEmails.get(r.id); - if (!target || target.placeholder) continue; + if (!target) continue; + if (target.placeholder) { + if (!target.phone) continue; // defensive — placeholder rows were validated to carry a phone + try { + await deps.sendInviteSms(target.phone); + } catch (e) { + // The account exists; only the SMS failed. Not a rollback — + // remediation is re-sending or an admin set-user-password. + r.code = 'INVITE_SMS_FAILED'; + r.error = `User created, but the invitation SMS failed: ${((e as Error)?.message ?? String(e)).slice(0, 200)}`; + } + continue; + } try { await authApi.requestPasswordReset({ body: { email: target.email } }); } catch (e) { diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 58ae8d947a..94699045f4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1080,6 +1080,150 @@ describe('AuthManager', () => { }); }); + // #2780 — phone-number OTP delivered over the SMS service. + describe('phone-number OTP over SMS (#2780)', () => { + const PHONE = '+8613800000000'; + + const fakeSms = (opts: { failed?: boolean } = {}) => { + const sent: any[] = []; + return { + sent, + service: { + async send(input: any) { + sent.push(input); + return opts.failed + ? { id: 'sms_1', status: 'failed', error: 'provider down' } + : { id: 'sms_1', status: 'sent', messageId: 'prov_1' }; + }, + isConfigured: () => true, + } as any, + }; + }; + + const bootOtp = async (config: any = {}) => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((cfg: any) => { + capturedConfig = cfg; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { phoneNumber: true }, + ...config, + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + const plugin = capturedConfig.plugins.find((p: any) => p.id === 'phone-number'); + return { manager, plugin, opts: plugin.options }; + }; + + it('passes allowedAttempts explicitly (default 3) and a phone-shape validator', async () => { + const { opts } = await bootOtp(); + expect(opts.allowedAttempts).toBe(3); + expect(opts.phoneNumberValidator('+86 138-0000-0000')).toBe(true); + expect(opts.phoneNumberValidator('bob@example.com')).toBe(false); + }); + + it('sendOTP throws NOT_SUPPORTED without an SMS service (pre-#2780 behaviour preserved)', async () => { + const { opts } = await bootOtp(); + await expect(opts.sendOTP({ phoneNumber: PHONE, code: '123456' })).rejects.toThrow(/NOT_SUPPORTED/); + }); + + it('sendOTP delivers the code in the SMS body (and only there)', async () => { + const { manager, opts } = await bootOtp(); + const sms = fakeSms(); + manager.setSmsService(sms.service); + + await opts.sendOTP({ phoneNumber: PHONE, code: '123456' }); + expect(sms.sent).toHaveLength(1); + expect(sms.sent[0].to).toBe(PHONE); + expect(sms.sent[0].body).toContain('123456'); + expect(sms.sent[0].templateParams).toEqual({ code: '123456' }); + }); + + it('enforces the per-number cooldown (TOO_MANY_REQUESTS, no second SMS)', async () => { + const { manager, opts } = await bootOtp(); + const sms = fakeSms(); + manager.setSmsService(sms.service); + + await opts.sendOTP({ phoneNumber: PHONE, code: '111111' }); + await expect(opts.sendOTP({ phoneNumber: PHONE, code: '222222' })) + .rejects.toThrow(/Too many verification codes/); + expect(sms.sent).toHaveLength(1); + }); + + it('sendPasswordResetOTP shares the same guarded SMS path (cross-flow budget)', async () => { + const { manager, opts } = await bootOtp(); + const sms = fakeSms(); + manager.setSmsService(sms.service); + + await opts.sendPasswordResetOTP({ phoneNumber: PHONE, code: '333333' }); + expect(sms.sent[0].body).toContain('333333'); + // The cooldown spans both flows — a reset send blocks an immediate sign-in send. + await expect(opts.sendOTP({ phoneNumber: PHONE, code: '444444' })) + .rejects.toThrow(/Too many verification codes/); + }); + + it('surfaces a failed SMS delivery WITHOUT the code in the error', async () => { + const { manager, opts } = await bootOtp(); + const sms = fakeSms({ failed: true }); + manager.setSmsService(sms.service); + + await expect(opts.sendOTP({ phoneNumber: PHONE, code: '555555' })) + .rejects.toSatisfy((e: Error) => /provider down/.test(e.message) && !e.message.includes('555555')); + }); + + it('honours phoneOtp knobs (cooldown off ⇒ back-to-back sends allowed)', async () => { + const { manager, opts } = await bootOtp({ phoneOtp: { cooldownSeconds: 0, maxPerHour: 0 } }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + await opts.sendOTP({ phoneNumber: PHONE, code: '111111' }); + await opts.sendOTP({ phoneNumber: PHONE, code: '222222' }); + expect(sms.sent).toHaveLength(2); + }); + + it('features.phoneNumberOtp requires plugin + deliverable SMS', async () => { + const { manager } = await bootOtp(); + expect((manager.getPublicConfig() as any).features.phoneNumberOtp).toBe(false); + expect(manager.isSmsServiceAvailable()).toBe(false); + + manager.setSmsService(fakeSms().service); + expect(manager.isSmsServiceAvailable()).toBe(true); + expect(manager.isPhoneOtpDeliverable()).toBe(true); + expect((manager.getPublicConfig() as any).features.phoneNumberOtp).toBe(true); + }); + + it('an unconfigured (log-only) SMS service does not advertise OTP in production', async () => { + const { manager } = await bootOtp(); + manager.setSmsService({ async send() { return { id: 'x', status: 'sent' }; }, isConfigured: () => false } as any); + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + expect(manager.isPhoneOtpDeliverable()).toBe(false); + expect((manager.getPublicConfig() as any).features.phoneNumberOtp).toBe(false); + } finally { + process.env.NODE_ENV = prev; + } + // Outside production the dev log transport keeps OTP testable. + expect(manager.isPhoneOtpDeliverable()).toBe(true); + }); + + it('sendPhoneInviteSms sends a credential-free invite and throws without a service', async () => { + const { manager } = await bootOtp(); + await expect(manager.sendPhoneInviteSms(PHONE)).rejects.toThrow(/SMS_SERVICE_REQUIRED/); + + const sms = fakeSms(); + manager.setSmsService(sms.service); + await manager.sendPhoneInviteSms(PHONE); + expect(sms.sent).toHaveLength(1); + expect(sms.sent[0].to).toBe(PHONE); + expect(sms.sent[0].body).toMatch(/verification code/); + expect(sms.sent[0].body).toContain('http://localhost:3000'); + }); + }); + // #2766 V1.5 — placeholder addresses must never become real recipients. describe('placeholder-email interception (#2766 V1.5)', () => { const PLACEHOLDER = 'u-abcdefghijklmnopqrst@placeholder.invalid'; @@ -1206,6 +1350,7 @@ describe('AuthManager', () => { deviceAuthorization: false, admin: false, phoneNumber: false, + phoneNumberOtp: false, multiOrgEnabled: false, privacyUrl: 'https://objectstack.ai/privacy', termsUrl: 'https://objectstack.ai/terms', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index fd03cf7df3..c295492a26 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -11,12 +11,13 @@ import type { OidcProvidersConfig, } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; -import type { IEmailService } from '@objectstack/spec/contracts'; +import type { IEmailService, ISmsService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpServerEnabled } from '@objectstack/types'; import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { isPlaceholderEmail } from './placeholder-email.js'; +import { OtpSendGuard } from './otp-send-guard.js'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -318,6 +319,36 @@ export interface AuthManagerOptions extends Partial { */ emailService?: IEmailService; + /** + * Optional outbound SMS service used by the phoneNumber plugin's OTP + * callbacks (`sendOTP`, `sendPasswordResetOTP`) and the import SMS-invite + * path (#2780). When omitted, `/phone-number/send-otp` fails loudly with + * NOT_SUPPORTED (the pre-SMS behaviour) instead of silently logging. + * + * Resolved lazily through {@link AuthManager.getSmsService}; safe to set + * after construction. AuthPlugin wires this from the kernel service + * registry (`sms`, see `@objectstack/plugin-sms`) on `kernel:ready`. + */ + smsService?: ISmsService; + + /** + * #2780 — knobs for the phone-number OTP surface. All optional; the + * defaults are deliberately conservative because every OTP send costs + * real money (SMS pumping abuse — see otp-send-guard.ts). + */ + phoneOtp?: { + /** Per-number cooldown between sends, seconds. Default 60. `0` disables. */ + cooldownSeconds?: number; + /** Per-number rolling-hour send cap. Default 5. `0` disables. */ + maxPerHour?: number; + /** Wrong-code attempts before the OTP is invalidated (better-auth `allowedAttempts`). Default 3. */ + allowedAttempts?: number; + /** OTP validity window, seconds (better-auth `expiresIn`). Default 300. */ + expiresIn?: number; + /** OTP length (better-auth `otpLength`). Default 6. */ + otpLength?: number; + }; + /** * Display name used by built-in auth email templates (`{{appName}}` * placeholder). Defaults to `'ObjectStack'` when omitted. @@ -1541,20 +1572,41 @@ export class AuthManager { if (enabled.phoneNumber) { const { phoneNumber } = await import('better-auth/plugins/phone-number'); - // #2766 V1.5 — phone+password sign-in only (`POST /sign-in/phone-number`). - // The OTP surface needs an SMS provider the platform doesn't have yet: - // `sendOTP` throws explicitly so /phone-number/send-otp fails loudly - // (NOT_SUPPORTED) instead of silently logging, and signUpOnVerification - // is deliberately NOT configured — phone-only accounts are created by - // the admin create-user/import routes with a placeholder email + // #2766 V1.5 wired phone+password sign-in; #2780 opens the OTP surface + // (`/phone-number/send-otp` + `/verify`, `/request-password-reset` + + // `/reset-password`) whenever an SMS service is available — resolved + // lazily per send so the plugin list stays stable while the capability + // upgrades at kernel:ready. Without one, sendOTP still fails loudly + // (NOT_SUPPORTED) instead of silently logging. signUpOnVerification + // stays deliberately NOT configured — phone-only accounts are created + // by the admin create-user/import routes with a placeholder email // (see placeholder-email.ts), never by OTP self-signup. + const otpCfg = this.config.phoneOtp ?? {}; plugins.push(phoneNumber({ schema: buildPhoneNumberPluginSchema(), - sendOTP: () => { - throw new Error( - 'NOT_SUPPORTED: phone-number OTP requires an SMS delivery service, which is not configured. ' + - 'Phone sign-in is password-based (POST /sign-in/phone-number).', - ); + // Wrong-code attempts before the stored OTP is invalidated. Explicit + // (even though 3 is the better-auth default) — #2780 names it a + // security requirement, so it must not drift with a dependency bump. + allowedAttempts: otpCfg.allowedAttempts ?? 3, + ...(otpCfg.expiresIn != null ? { expiresIn: otpCfg.expiresIn } : {}), + ...(otpCfg.otpLength != null ? { otpLength: otpCfg.otpLength } : {}), + // Reject garbage before an SMS is paid for (same shape rule as the + // admin endpoints' normalizePhoneNumber). + phoneNumberValidator: (phone: string) => + /^\+?[0-9]{6,15}$/.test(String(phone ?? '').replace(/[\s\-().]/g, '')), + // Sign-in / verify OTP. Throws surface to the endpoint (better-auth + // awaits this callback on /phone-number/send-otp), so the guard's + // TOO_MANY_REQUESTS becomes an honest 429 to the client. + sendOTP: async ({ phoneNumber: phone, code }) => { + await this.deliverPhoneOtp(phone, code, 'sign-in verification'); + }, + // Self-service password reset OTP (`/phone-number/request-password- + // reset`). better-auth invokes this via runInBackgroundOrAwait — + // errors are logged, the route still answers {status:true} — so a + // throw here can neither 500 the request nor leak whether the number + // is registered. + sendPasswordResetOTP: async ({ phoneNumber: phone, code }) => { + await this.deliverPhoneOtp(phone, code, 'password reset'); }, })); } @@ -2000,6 +2052,109 @@ export class AuthManager { return this.config.emailService; } + /** + * Inject (or replace) the outbound SMS service used by the phone-number + * OTP callbacks and the SMS invite path (#2780). Mirrors + * {@link setEmailService}: resolved lazily per send, so it is safe to set + * after construction — AuthPlugin wires it on `kernel:ready` once + * `ctx.getService('sms')` (plugin-sms) resolves. + */ + setSmsService(sms: ISmsService | undefined): void { + this.config.smsService = sms; + } + + /** @internal Used by callback closures. */ + private getSmsService(): ISmsService | undefined { + return this.config.smsService; + } + + /** Lazy per-number OTP send guard (#2780) — see otp-send-guard.ts. */ + private _otpSendGuard?: OtpSendGuard; + private getOtpSendGuard(): OtpSendGuard { + if (!this._otpSendGuard) { + const otpCfg = this.config.phoneOtp ?? {}; + this._otpSendGuard = new OtpSendGuard({ + ...(otpCfg.cooldownSeconds != null ? { cooldownSeconds: otpCfg.cooldownSeconds } : {}), + ...(otpCfg.maxPerHour != null ? { maxPerHour: otpCfg.maxPerHour } : {}), + // Share better-auth's cross-node KV when wired (ADR-0069 D2) so the + // per-number budget is enforced against ONE store across nodes. + ...(this.config.secondaryStorage ? { storage: this.config.secondaryStorage } : {}), + }); + } + return this._otpSendGuard; + } + + /** + * #2780 — deliver a phone OTP through the SMS service. + * + * Security posture (all named requirements of #2780): + * - No SMS service ⇒ throw NOT_SUPPORTED (loud, like the pre-SMS wiring). + * - Per-number cooldown + hourly cap BEFORE the provider is called + * (SMS-pumping cost abuse); violations throw TOO_MANY_REQUESTS, which + * /phone-number/send-otp surfaces as an honest 429 while the + * request-password-reset route logs-and-200s (no enumeration oracle). + * - The code is embedded in the message body ONLY — it must never reach + * a log line or an error message (the SmsService logs masked numbers + * and statuses, never bodies). + */ + private async deliverPhoneOtp(phone: string, code: string, purpose: string): Promise { + const sms = this.getSmsService(); + if (!sms || !this.isPhoneOtpDeliverable()) { + // Absent service, or a log-only transport in production (the code + // would vanish into a log no user can read) — fail loudly, exactly + // like the pre-SMS wiring. + throw new Error( + 'NOT_SUPPORTED: phone-number OTP requires a configured SMS delivery service. ' + + 'Phone sign-in is password-based (POST /sign-in/phone-number).', + ); + } + const decision = await this.getOtpSendGuard().checkAndRecord(phone); + if (!decision.ok) { + const { APIError } = await import('better-auth/api'); + throw new APIError('TOO_MANY_REQUESTS', { + message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`, + }); + } + const otpCfg = this.config.phoneOtp ?? {}; + const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60)); + const result = await sms.send({ + to: phone, + body: `${code} is your ${this.getAppName()} ${purpose} code. It expires in ${minutes} minutes.`, + // Template-only providers (Aliyun) substitute into a registered OTP + // template; `code` is the conventional variable name. + templateParams: { code }, + }); + if (result.status === 'failed') { + // `result.error` is transport detail (never the code) — safe to surface. + throw new Error(`Phone OTP could not be sent: ${result.error ?? 'SMS delivery failed'}`); + } + } + + /** + * #2780 — send the SMS variant of an import invitation: the account + * exists (placeholder email, phone identity) and the user should sign in + * with a verification code, then set a password. Carries NO credential — + * the OTP is requested by the user themself at the sign-in page. + * + * Throws when no SMS service is wired (callers gate on + * {@link isSmsServiceAvailable} first). + */ + async sendPhoneInviteSms(phone: string): Promise { + const sms = this.getSmsService(); + if (!sms) { + throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.'); + } + const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); + const body = + `Your ${this.getAppName()} account is ready. Sign in with this phone number using a verification code` + + (baseUrl ? ` at ${baseUrl}` : '') + + ', then set your password.'; + const result = await sms.send({ to: phone, body, templateParams: { content: body } }); + if (result.status === 'failed') { + throw new Error(`Invitation SMS failed: ${result.error ?? 'SMS delivery failed'}`); + } + } + /** * Override the brand name surfaced in built-in auth emails (`{{appName}}`), * sourced from the live `branding.workspace_name` setting. @@ -2383,6 +2538,10 @@ export class AuthManager { admin: pluginConfig.admin ?? (readBooleanEnv('OS_SCIM_ENABLED') ?? (pluginConfig as any).scim ?? false), // #2766 V1.5 — mirrors `enabled.phoneNumber` in buildPluginList(). phoneNumber: (pluginConfig as any).phoneNumber ?? false, + // #2780 — OTP sign-in / self-service reset is only advertised when the + // SMS path can actually deliver (plugin on + deliverable SMS service), + // so the login UI never shows a dead "sign in with code" option. + phoneNumberOtp: ((pluginConfig as any).phoneNumber ?? false) && this.isPhoneOtpDeliverable(), ...(termsUrl ? { termsUrl } : {}), ...(privacyUrl ? { privacyUrl } : {}), }; @@ -2834,6 +2993,29 @@ export class AuthManager { return !!this.getEmailService(); } + /** + * #2780 — is an SMS service wired? Gate for the import SMS-invite variant + * (mirrors {@link isEmailServiceAvailable} for the invite policy). + */ + public isSmsServiceAvailable(): boolean { + return !!this.getSmsService(); + } + + /** + * #2780 — can a phone OTP actually reach a handset? True when an SMS + * service is wired AND it is either backed by a real provider or we are + * outside production (the dev LogSmsTransport prints the message, so local + * OTP flows stay testable end-to-end). In production an unconfigured + * log-only service keeps OTP OFF — advertising it would strand every user + * at a code that never arrives. + */ + public isPhoneOtpDeliverable(): boolean { + const sms = this.getSmsService(); + if (!sms) return false; + if (typeof sms.isConfigured !== 'function' || sms.isConfigured()) return true; + return (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; + } + /** * #2766 V1 — flip the "someone must change their password" cache on this * node the moment an admin issues a flag, so the gate is enforced on the diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index e47f425a0b..354baa5bf5 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -334,6 +334,26 @@ export class AuthPlugin implements Plugin { } } + // #2780 — inject the SMS service so the phoneNumber plugin's OTP + // callbacks (send-otp / password-reset OTP) and the import + // SMS-invite path can deliver. Same lazy-resolution contract as + // the email service: absent ⇒ OTP endpoints keep failing loudly + // (NOT_SUPPORTED) while phone+password sign-in still works. + let smsSvc: any; + try { smsSvc = ctx.getService('sms'); } catch { smsSvc = undefined; } + if (smsSvc) { + this.authManager.setSmsService(smsSvc); + if (this.authManager.isPhoneNumberEnabled()) { + ctx.logger.info( + this.authManager.isPhoneOtpDeliverable() + ? 'Auth: sms service wired (phone-number OTP sign-in / reset enabled)' + : 'Auth: sms service present but NOT configured with a real provider — phone-number OTP stays disabled in production', + ); + } + } else if (this.authManager.isPhoneNumberEnabled()) { + ctx.logger.info('Auth: no sms service registered — phone-number OTP disabled (password sign-in only)'); + } + // Bind the email brand name (`{{appName}}`) to the live // `branding.workspace_name` setting so the admin UI can rename the // product without a redeploy. Only an *explicitly set* value @@ -731,6 +751,14 @@ export class AuthPlugin implements Plugin { '/sign-up/email': { window, max }, '/request-password-reset': { window, max }, '/reset-password': { window, max }, + // #2780 — OTP endpoints cost an SMS per hit (pumping abuse); + // the per-number cooldown (otp-send-guard.ts) is always on, + // this adds the operator-tuned per-IP dimension. The plugin + // also ships its own /phone-number* default (10/min). + '/phone-number/send-otp': { window, max }, + '/phone-number/request-password-reset': { window, max }, + '/phone-number/verify': { window, max }, + '/phone-number/reset-password': { window, max }, }, } as AuthManagerOptions['rateLimit']; } @@ -1267,6 +1295,11 @@ export class AuthPlugin implements Plugin { : {}), phoneNumberEnabled: () => this.authManager!.isPhoneNumberEnabled(), emailServiceAvailable: () => this.authManager!.isEmailServiceAvailable(), + // #2780 — SMS invites need the phone plugin (the invitee's + // first sign-in is a phone OTP) plus deliverable SMS. + smsInviteAvailable: () => + this.authManager!.isPhoneNumberEnabled() && this.authManager!.isPhoneOtpDeliverable(), + sendInviteSms: (phone: string) => this.authManager!.sendPhoneInviteSms(phone), noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(), logger: ctx.logger, }, diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 1e739d715e..97016388a2 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -15,6 +15,7 @@ export * from './set-initial-password.js'; export * from './admin-user-endpoints.js'; export * from './placeholder-email.js'; export * from './admin-import-users.js'; +export * from './otp-send-guard.js'; export * from './register-sso-provider.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; diff --git a/packages/plugins/plugin-auth/src/otp-send-guard.test.ts b/packages/plugins/plugin-auth/src/otp-send-guard.test.ts new file mode 100644 index 0000000000..6c22b522f4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/otp-send-guard.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { OtpSendGuard, type OtpGuardStorage } from './otp-send-guard.js'; + +const PHONE = '+8613800000000'; + +function clock(start = 1_000_000) { + let now = start; + return { now: () => now, advance: (ms: number) => { now += ms; } }; +} + +describe('OtpSendGuard', () => { + it('allows the first send and enforces the cooldown on the second', async () => { + const c = clock(); + const guard = new OtpSendGuard({ cooldownSeconds: 60, maxPerHour: 5, now: c.now }); + + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + const denied = await guard.checkAndRecord(PHONE); + expect(denied.ok).toBe(false); + expect(denied.retryAfterSeconds).toBeGreaterThan(0); + expect(denied.retryAfterSeconds).toBeLessThanOrEqual(60); + + c.advance(61_000); + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + }); + + it('tracks numbers independently', async () => { + const c = clock(); + const guard = new OtpSendGuard({ cooldownSeconds: 60, now: c.now }); + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + expect((await guard.checkAndRecord('+15005550006')).ok).toBe(true); + }); + + it('enforces the rolling hourly cap even outside the cooldown', async () => { + const c = clock(); + const guard = new OtpSendGuard({ cooldownSeconds: 1, maxPerHour: 3, now: c.now }); + for (let i = 0; i < 3; i++) { + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + c.advance(2_000); + } + const denied = await guard.checkAndRecord(PHONE); + expect(denied.ok).toBe(false); + + // The window rolls: an hour after the FIRST send, one slot frees up. + c.advance(3_600_000 - 4_000); + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + }); + + it('0 disables both dimensions', async () => { + const guard = new OtpSendGuard({ cooldownSeconds: 0, maxPerHour: 0 }); + for (let i = 0; i < 10; i++) { + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + } + }); + + it('uses the shared storage when provided (cross-node)', async () => { + const c = clock(); + const kv = new Map(); + const storage: OtpGuardStorage = { + get: (k) => kv.get(k) ?? null, + set: (k, v) => { kv.set(k, v); }, + }; + const nodeA = new OtpSendGuard({ cooldownSeconds: 60, storage, now: c.now }); + const nodeB = new OtpSendGuard({ cooldownSeconds: 60, storage, now: c.now }); + + expect((await nodeA.checkAndRecord(PHONE)).ok).toBe(true); + // A different node sees the same budget. + expect((await nodeB.checkAndRecord(PHONE)).ok).toBe(false); + }); + + it('fails OPEN when the storage breaks (throttle must not take sign-in down)', async () => { + const storage: OtpGuardStorage = { + get: () => { throw new Error('redis down'); }, + set: () => { throw new Error('redis down'); }, + }; + const guard = new OtpSendGuard({ cooldownSeconds: 60, storage }); + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + expect((await guard.checkAndRecord(PHONE)).ok).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/otp-send-guard.ts b/packages/plugins/plugin-auth/src/otp-send-guard.ts new file mode 100644 index 0000000000..49aa585f86 --- /dev/null +++ b/packages/plugins/plugin-auth/src/otp-send-guard.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Per-phone-number OTP send guard (#2780). + * + * SMS is a *paid* channel: every `/phone-number/send-otp` call costs real + * money and better-auth sends to ANY number (the endpoint also serves the + * change-phone verification flow), so an attacker can pump SMS to arbitrary + * numbers ("SMS pumping" / toll fraud). better-auth's per-IP rate limit + * doesn't survive IP rotation — this guard adds the missing per-NUMBER + * dimension, independent of caller IP: + * + * - **Cooldown**: at most one send per number per `cooldownSeconds`. + * - **Hourly cap**: at most `maxPerHour` sends per number per rolling hour. + * + * State lives in better-auth's `secondaryStorage` when wired (one shared + * store across nodes — same reasoning as the rate-limit counters, ADR-0069 + * D2) and falls back to an in-process map otherwise. Counting is + * best-effort (no cross-node atomicity), which is fine for an anti-abuse + * throttle — the budget is small either way. + * + * Keys carry only the phone number and timestamps — never OTP codes. + */ + +/** + * Subset of better-auth's SecondaryStorage the guard needs. Return types are + * deliberately loose (`unknown`) to stay assignable from better-auth's own + * interface across versions; `load()` type-checks what comes back. + */ +export interface OtpGuardStorage { + get(key: string): unknown; + set(key: string, value: string, ttl?: number): unknown; +} + +export interface OtpSendGuardOptions { + /** Seconds a number must wait between two sends. Default 60. `0` disables. */ + cooldownSeconds?: number; + /** Max sends per number per rolling hour. Default 5. `0` disables. */ + maxPerHour?: number; + /** Shared cross-node store (better-auth secondaryStorage). Optional. */ + storage?: OtpGuardStorage; + /** Clock override for tests. */ + now?: () => number; +} + +export interface OtpSendDecision { + ok: boolean; + /** Seconds until the next send is allowed (set when `ok` is false). */ + retryAfterSeconds?: number; +} + +const KEY_PREFIX = 'phone-otp-sends:'; +const HOUR_MS = 3_600_000; + +export class OtpSendGuard { + private readonly cooldownMs: number; + private readonly maxPerHour: number; + private readonly storage?: OtpGuardStorage; + private readonly now: () => number; + /** In-process fallback store: phone → send timestamps (ms). */ + private readonly local = new Map(); + + constructor(options: OtpSendGuardOptions = {}) { + this.cooldownMs = Math.max(0, Math.floor(options.cooldownSeconds ?? 60)) * 1000; + this.maxPerHour = Math.max(0, Math.floor(options.maxPerHour ?? 5)); + this.storage = options.storage; + this.now = options.now ?? Date.now; + } + + /** + * Check whether `phoneNumber` may receive another OTP now and, if so, + * record the send. Never throws — a broken store fails OPEN (an SMS + * throttle must not take sign-in down with it). + */ + async checkAndRecord(phoneNumber: string): Promise { + if (this.cooldownMs === 0 && this.maxPerHour === 0) return { ok: true }; + const now = this.now(); + try { + const key = KEY_PREFIX + phoneNumber; + const history = (await this.load(key)).filter((t) => now - t < HOUR_MS); + + const last = history.length ? Math.max(...history) : undefined; + if (this.cooldownMs > 0 && last !== undefined && now - last < this.cooldownMs) { + return { ok: false, retryAfterSeconds: Math.ceil((this.cooldownMs - (now - last)) / 1000) }; + } + if (this.maxPerHour > 0 && history.length >= this.maxPerHour) { + const oldest = Math.min(...history); + return { ok: false, retryAfterSeconds: Math.ceil((HOUR_MS - (now - oldest)) / 1000) }; + } + + history.push(now); + await this.save(key, history); + return { ok: true }; + } catch { + return { ok: true }; // fail open — see doc comment + } + } + + private async load(key: string): Promise { + if (this.storage) { + const raw = await this.storage.get(key); + if (typeof raw !== 'string' || raw.length === 0) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((t) => typeof t === 'number') : []; + } catch { + return []; + } + } + return this.local.get(key) ?? []; + } + + private async save(key: string, history: number[]): Promise { + if (this.storage) { + // TTL = the rolling window; the entry self-expires once irrelevant. + await this.storage.set(key, JSON.stringify(history), Math.ceil(HOUR_MS / 1000)); + return; + } + this.local.set(key, history); + // Opportunistic pruning keeps the fallback map bounded under abuse. + if (this.local.size > 10_000) { + const cutoff = this.now() - HOUR_MS; + for (const [k, v] of this.local) { + const alive = v.filter((t) => t > cutoff); + if (alive.length === 0) this.local.delete(k); + else this.local.set(k, alive); + } + } + } +} diff --git a/packages/plugins/plugin-sms/LICENSE b/packages/plugins/plugin-sms/LICENSE new file mode 100644 index 0000000000..16bc23f404 --- /dev/null +++ b/packages/plugins/plugin-sms/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/plugin-sms/package.json b/packages/plugins/plugin-sms/package.json new file mode 100644 index 0000000000..ba97409383 --- /dev/null +++ b/packages/plugins/plugin-sms/package.json @@ -0,0 +1,35 @@ +{ + "name": "@objectstack/plugin-sms", + "version": "14.2.0", + "license": "Apache-2.0", + "description": "SMS service plugin for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + }, + "keywords": [ + "objectstack", + "plugin", + "sms", + "aliyun", + "twilio" + ] +} diff --git a/packages/plugins/plugin-sms/src/index.ts b/packages/plugins/plugin-sms/src/index.ts new file mode 100644 index 0000000000..c1ab678c6a --- /dev/null +++ b/packages/plugins/plugin-sms/src/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { + SmsService, + LogSmsTransport, + maskPhoneNumber, + normalizeSmsRecipient, + type SmsServiceOptions, +} from './sms-service.js'; +export { SmsServicePlugin, type SmsServicePluginOptions } from './sms-plugin.js'; +export { + makeSmsTransport, + AliyunSmsTransport, + TwilioSmsTransport, + type SmsProviderTag, + type MakeSmsTransportOptions, + type AliyunSmsTransportOptions, + type TwilioSmsTransportOptions, +} from './transports/index.js'; diff --git a/packages/plugins/plugin-sms/src/sms-plugin.test.ts b/packages/plugins/plugin-sms/src/sms-plugin.test.ts new file mode 100644 index 0000000000..8e99f89047 --- /dev/null +++ b/packages/plugins/plugin-sms/src/sms-plugin.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SmsServicePlugin } from './sms-plugin.js'; +import { SmsService, LogSmsTransport } from './sms-service.js'; +import { AliyunSmsTransport, TwilioSmsTransport } from './transports/index.js'; + +/** + * Lightweight fake PluginContext (service registry + kernel:ready hooks + + * a fake settings service) — mirrors the messaging plugin's test harness. + */ +function fakeCtx(opts: { settingsValues?: Record } = {}) { + const services = new Map(); + const readyHooks: Array<() => Promise | void> = []; + const actions = new Map Promise>(); + const subscriptions: Array<{ ns: string; fn: () => void }> = []; + let values = opts.settingsValues; + + if (values !== undefined) { + services.set('settings', { + async getNamespace(ns: string) { + if (ns !== 'sms') throw new Error('unknown namespace'); + const wrapped: Record = {}; + for (const [k, v] of Object.entries(values ?? {})) wrapped[k] = { value: v }; + return { values: wrapped }; + }, + subscribe(ns: string, fn: () => void) { subscriptions.push({ ns, fn }); return () => {}; }, + registerAction(ns: string, id: string, fn: (input: any) => Promise) { + actions.set(`${ns}/${id}`, fn); + }, + }); + } + + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + logger, + registerService(name: string, svc: unknown) { services.set(name, svc); }, + getService(name: string) { + if (!services.has(name)) throw new Error(`service not found: ${name}`); + return services.get(name); + }, + hook(event: string, fn: () => Promise | void) { + if (event === 'kernel:ready') readyHooks.push(fn); + }, + } as any; + + return { + ctx, + services, + logger, + actions, + setValues: (v: Record) => { values = v; }, + notifyChange: async () => { for (const s of subscriptions) s.fn(); await new Promise((r) => setTimeout(r, 0)); }, + fireReady: async () => { for (const fn of readyHooks) await fn(); }, + }; +} + +describe('SmsServicePlugin', () => { + it('registers the sms service with the log fallback (unconfigured)', async () => { + const { ctx, services } = fakeCtx(); + await new SmsServicePlugin().init(ctx); + const svc = services.get('sms') as SmsService; + expect(svc).toBeInstanceOf(SmsService); + expect(svc.isConfigured()).toBe(false); + expect(svc.options.transport).toBeInstanceOf(LogSmsTransport); + }); + + it('builds a provider transport from constructor options', async () => { + const { ctx, services } = fakeCtx(); + await new SmsServicePlugin({ + provider: 'twilio', + providerOptions: { accountSid: 'AC1', authToken: 't', from: '+15005550006' }, + }).init(ctx); + const svc = services.get('sms') as SmsService; + expect(svc.isConfigured()).toBe(true); + expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport); + }); + + it('falls back to log (not a boot failure) on incomplete constructor credentials', async () => { + const { ctx, services, logger } = fakeCtx(); + await new SmsServicePlugin({ provider: 'aliyun', providerOptions: {} }).init(ctx); + const svc = services.get('sms') as SmsService; + expect(svc.isConfigured()).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('rebuilds the transport from the sms settings namespace at kernel:ready', async () => { + const harness = fakeCtx({ + settingsValues: { + provider: 'aliyun', + aliyun_access_key_id: 'ak', + aliyun_access_key_secret: 'sec', + aliyun_sign_name: '签名', + aliyun_template_code: 'SMS_1', + }, + }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + expect(svc.isConfigured()).toBe(true); + expect(svc.options.transport).toBeInstanceOf(AliyunSmsTransport); + }); + + it('keeps the previous transport when settings are incomplete', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'twilio', twilio_account_sid: 'AC1' } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + expect(svc.isConfigured()).toBe(false); + expect(svc.options.transport).toBeInstanceOf(LogSmsTransport); + }); + + it('live-applies settings changes via subscribe', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log' } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const svc = harness.services.get('sms') as SmsService; + expect(svc.isConfigured()).toBe(false); + + harness.setValues({ + provider: 'twilio', + twilio_account_sid: 'AC1', + twilio_auth_token: 'tok', + twilio_from_number: '+15005550006', + }); + await harness.notifyChange(); + expect(svc.isConfigured()).toBe(true); + expect(svc.options.transport).toBeInstanceOf(TwilioSmsTransport); + }); + + it('registers an sms/test action that validates the recipient', async () => { + const harness = fakeCtx({ settingsValues: { provider: 'log' } }); + const plugin = new SmsServicePlugin(); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + await harness.fireReady(); + + const test = harness.actions.get('sms/test'); + expect(test).toBeDefined(); + const bad = await test!({ values: { provider: 'log' }, payload: { to: 'not-a-phone' }, ctx: {} }); + expect(bad.ok).toBe(false); + const good = await test!({ values: { provider: 'log' }, payload: { to: '+15005550006' }, ctx: {} }); + expect(good.ok).toBe(true); + expect(good.message).not.toContain('5550006'); // masked + }); +}); diff --git a/packages/plugins/plugin-sms/src/sms-plugin.ts b/packages/plugins/plugin-sms/src/sms-plugin.ts new file mode 100644 index 0000000000..c1b85aac88 --- /dev/null +++ b/packages/plugins/plugin-sms/src/sms-plugin.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { ISmsTransport } from '@objectstack/spec/contracts'; +import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js'; +import { makeSmsTransport, type SmsProviderTag } from './transports/index.js'; + +/** + * Plugin configuration. Mirrors EmailServicePluginOptions: a directly + * injected transport wins, then `provider` + credentials, then the + * development `LogSmsTransport` fallback (no real send). + */ +export interface SmsServicePluginOptions { + /** Pluggable delivery transport. Overrides `provider`/credentials. */ + transport?: ISmsTransport; + /** Provider tag — `'log' | 'aliyun' | 'twilio'`. Default `'log'`. */ + provider?: SmsProviderTag; + /** Provider-specific credentials/options (see transport option types). */ + providerOptions?: Record; + /** Retry attempts on transport throw. Default 0. */ + retries?: number; +} + +/** Translate an `sms` settings-namespace snapshot into transport inputs. */ +function providerFromSettings(values: Record): { + provider: SmsProviderTag; + options: Record; + missing?: string; +} { + const provider = String(values.provider ?? 'log') as SmsProviderTag; + const str = (k: string): string | undefined => { + const v = values[k]; + return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined; + }; + if (provider === 'aliyun') { + const accessKeyId = str('aliyun_access_key_id'); + const accessKeySecret = str('aliyun_access_key_secret'); + const signName = str('aliyun_sign_name'); + if (!accessKeyId || !accessKeySecret || !signName) { + return { provider, options: {}, missing: 'aliyun_access_key_id / aliyun_access_key_secret / aliyun_sign_name' }; + } + return { + provider, + options: { + accessKeyId, + accessKeySecret, + signName, + ...(str('aliyun_template_code') ? { defaultTemplateCode: str('aliyun_template_code') } : {}), + }, + }; + } + if (provider === 'twilio') { + const accountSid = str('twilio_account_sid'); + const authToken = str('twilio_auth_token'); + const from = str('twilio_from_number'); + const messagingServiceSid = str('twilio_messaging_service_sid'); + if (!accountSid || !authToken || (!from && !messagingServiceSid)) { + return { provider, options: {}, missing: 'twilio_account_sid / twilio_auth_token / twilio_from_number (or messaging service SID)' }; + } + return { + provider, + options: { + accountSid, + authToken, + ...(from ? { from } : {}), + ...(messagingServiceSid ? { messagingServiceSid } : {}), + }, + }; + } + return { provider: 'log', options: {} }; +} + +/** + * SmsServicePlugin — registers the `sms` service (#2780). + * + * Lifecycle: + * - `init`: build transport (injected → provider+credentials → + * LogSmsTransport fallback); register the SmsService so dependents + * (auth OTP, the messaging `sms` channel) can resolve it. + * - `start` (kernel:ready): bind the `sms` settings namespace so the + * admin UI can live-swap the provider without a restart, and register + * the `sms/test` action. Env-locked keys (OS_SMS_*) still win at the + * settings-resolver level. + * + * Deliberately NO persistence objects: SMS bodies carry OTP codes — see the + * ISmsService contract header. + */ +export class SmsServicePlugin implements Plugin { + name = 'com.objectstack.service.sms'; + version = '1.0.0'; + type = 'standard' as const; + + private readonly options: SmsServicePluginOptions; + private service?: SmsService; + + constructor(options: SmsServicePluginOptions = {}) { + this.options = options; + } + + private resolveInitialTransport(ctx: PluginContext): { transport: ISmsTransport; configured: boolean } { + if (this.options.transport) return { transport: this.options.transport, configured: true }; + const provider = this.options.provider ?? 'log'; + if (provider === 'log') return { transport: new LogSmsTransport(ctx.logger), configured: false }; + try { + return { + transport: makeSmsTransport({ provider, options: this.options.providerOptions, logger: ctx.logger }), + configured: true, + }; + } catch (err: any) { + // Incomplete constructor credentials must not take the kernel down — + // fall back to the dev transport; the settings bind (kernel:ready) + // can still swap in a working provider. + ctx.logger.warn( + `SmsServicePlugin: provider='${provider}' selected but transport build failed (${err?.message ?? err}) — falling back to LogSmsTransport.`, + ); + return { transport: new LogSmsTransport(ctx.logger), configured: false }; + } + } + + async init(ctx: PluginContext): Promise { + const { transport, configured } = this.resolveInitialTransport(ctx); + if (!configured) { + ctx.logger.info('SmsServicePlugin: no provider configured — using LogSmsTransport (SMS will NOT be sent)'); + } else { + ctx.logger.info(`SmsServicePlugin: using '${this.options.provider ?? 'custom'}' provider`); + } + this.service = new SmsService({ + transport, + configured, + retries: this.options.retries, + logger: ctx.logger, + }); + ctx.registerService('sms', this.service); + ctx.logger.info('SmsServicePlugin: sms service registered'); + } + + async start(ctx: PluginContext): Promise { + ctx.hook('kernel:ready', async () => { + if (!this.service) return; + // A host-injected transport is authoritative — settings only manage + // the provider-tag path. + if (this.options.transport) return; + try { + const settings = ctx.getService('settings'); + if (!settings || typeof settings.getNamespace !== 'function') return; + + const applySettings = async () => { + try { + const payload = await settings.getNamespace('sms'); + const values: Record = {}; + for (const [k, v] of Object.entries(payload.values as Record)) { + values[k] = v?.value; + } + this.applySmsSettings(values, ctx); + } catch (err: any) { + ctx.logger.warn('SmsServicePlugin: failed to apply sms settings: ' + (err?.message ?? err)); + } + }; + await applySettings(); + if (typeof settings.subscribe === 'function') { + settings.subscribe('sms', () => { void applySettings(); }); + ctx.logger.info('SmsServicePlugin: bound to settings:changed for namespace=sms'); + } + + // `sms/test` action — validate the (possibly unsaved) form values by + // sending a real test message through a one-shot transport, mirroring + // the `mail/test` handler in EmailServicePlugin. + if (typeof settings.registerAction === 'function') { + const svc = this.service; + settings.registerAction('sms', 'test', async ({ values, payload, ctx: actionCtx }: any) => { + const overrides = (payload && typeof payload === 'object' && payload.values && typeof payload.values === 'object') + ? payload.values + : (payload ?? {}); + const merged: Record = { ...(values ?? {}), ...overrides }; + const rawTo = (actionCtx?.body?.to as string | undefined) ?? (payload?.to as string | undefined); + const to = rawTo ? normalizeSmsRecipient(rawTo) : undefined; + if (!to) { + return { ok: false, severity: 'error', message: 'Provide a valid "to" phone number (E.164 recommended).' }; + } + + const resolved = providerFromSettings(merged); + if (resolved.missing) { + return { ok: false, severity: 'error', message: `${resolved.provider}: missing ${resolved.missing}.` }; + } + + let target: SmsService = svc; + if (resolved.provider !== 'log') { + try { + const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger }); + target = new SmsService({ transport, configured: true, logger: ctx.logger }); + } catch (err: any) { + return { ok: false, severity: 'error', message: `Failed to build ${resolved.provider} transport: ${err?.message ?? String(err)}` }; + } + } + + try { + const result = await target.send({ + to, + body: 'ObjectStack SMS test message.', + templateParams: { content: 'ObjectStack SMS test message.' }, + }); + if (result.status === 'failed') { + return { ok: false, severity: 'error', message: result.error ?? 'Send failed.' }; + } + return { + ok: true, + severity: 'info', + message: `Sent test SMS to ${maskPhoneNumber(to)} via ${resolved.provider} (id=${result.messageId ?? result.id}).`, + }; + } catch (err: any) { + return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + } + }); + } + } catch { + // settings service not registered — constructor opts remain authoritative. + } + }); + } + + /** + * Translate the `sms` settings snapshot into a transport and hot-swap it + * on the running SmsService. Incomplete credentials keep the previous + * transport (with a warning) so a half-saved form can't break delivery. + */ + private applySmsSettings(values: Record, ctx: PluginContext): void { + if (!this.service) return; + const resolved = providerFromSettings(values); + if (resolved.missing) { + ctx.logger.warn( + `SmsServicePlugin: provider='${resolved.provider}' selected but ${resolved.missing} is empty — transport NOT rebuilt.`, + ); + return; + } + if (resolved.provider === 'log') { + // Downgrade to the dev transport only when the operator explicitly + // selected `log`; an unset namespace keeps the constructor opts. + if (values.provider === 'log') { + this.service.setTransport(new LogSmsTransport(ctx.logger), false); + ctx.logger.info('SmsServicePlugin: sms settings applied (provider=log; SMS will NOT be sent).'); + } + return; + } + try { + const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger }); + this.service.setTransport(transport, true); + ctx.logger.info(`SmsServicePlugin: transport rebuilt from settings (provider=${resolved.provider}).`); + } catch (err: any) { + ctx.logger.warn('SmsServicePlugin: failed to rebuild transport: ' + (err?.message ?? err)); + } + } +} diff --git a/packages/plugins/plugin-sms/src/sms-service.test.ts b/packages/plugins/plugin-sms/src/sms-service.test.ts new file mode 100644 index 0000000000..d54940ac3d --- /dev/null +++ b/packages/plugins/plugin-sms/src/sms-service.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js'; + +const collectingLogger = () => { + const lines: string[] = []; + return { + lines, + info: (msg: string) => { lines.push(String(msg)); }, + warn: (msg: string) => { lines.push(String(msg)); }, + }; +}; + +describe('normalizeSmsRecipient', () => { + it('accepts E.164 and strips human separators', () => { + expect(normalizeSmsRecipient('+8613800000000')).toBe('+8613800000000'); + expect(normalizeSmsRecipient('+1 (500) 555-0006')).toBe('+15005550006'); + expect(normalizeSmsRecipient('138 0000 0000')).toBe('13800000000'); + }); + it('rejects garbage', () => { + expect(normalizeSmsRecipient('bob@example.com')).toBeUndefined(); + expect(normalizeSmsRecipient('123')).toBeUndefined(); + expect(normalizeSmsRecipient('')).toBeUndefined(); + }); +}); + +describe('maskPhoneNumber', () => { + it('keeps prefix + last two digits only', () => { + const masked = maskPhoneNumber('+8613812345678'); + expect(masked.startsWith('+8613')).toBe(true); + expect(masked.endsWith('78')).toBe(true); + expect(masked).not.toContain('12345'); + }); +}); + +describe('SmsService', () => { + it('sends through the transport and reports the provider id', async () => { + const send = vi.fn(async () => ({ messageId: 'prov_1' })); + const svc = new SmsService({ transport: { send }, configured: true }); + const r = await svc.send({ to: '+15005550006', body: 'hello' }); + expect(r.status).toBe('sent'); + expect(r.messageId).toBe('prov_1'); + expect(send).toHaveBeenCalledWith({ to: '+15005550006', body: 'hello' }); + }); + + it('throws on an invalid recipient BEFORE the transport is called', async () => { + const send = vi.fn(); + const svc = new SmsService({ transport: { send }, configured: true }); + await expect(svc.send({ to: 'not-a-phone', body: 'x' })).rejects.toThrow(/VALIDATION_FAILED/); + expect(send).not.toHaveBeenCalled(); + }); + + it('resolves status:failed (not a throw) on transport errors', async () => { + const svc = new SmsService({ + transport: { async send() { throw new Error('provider down'); } }, + configured: true, + }); + const r = await svc.send({ to: '+15005550006', body: 'x' }); + expect(r.status).toBe('failed'); + expect(r.error).toContain('provider down'); + }); + + it('retries on transport throw when retries > 0', async () => { + let calls = 0; + const svc = new SmsService({ + transport: { async send() { if (++calls < 2) throw new Error('flaky'); return { messageId: 'ok' }; } }, + configured: true, + retries: 1, + }); + const r = await svc.send({ to: '+15005550006', body: 'x' }); + expect(r.status).toBe('sent'); + expect(calls).toBe(2); + }); + + it('never logs the message body (OTP red line, #2780)', async () => { + const logger = collectingLogger(); + const svc = new SmsService({ + transport: { async send() { return { messageId: 'prov_1' }; } }, + configured: true, + logger, + }); + await svc.send({ to: '+8613812345678', body: '123456 is your code' }); + // also exercise the failure path + svc.setTransport({ async send() { throw new Error('down'); } }, true); + await svc.send({ to: '+8613812345678', body: '654321 is your code' }); + for (const line of logger.lines) { + expect(line).not.toContain('123456'); + expect(line).not.toContain('654321'); + expect(line).not.toContain('13812345678'); // full number masked too + } + }); + + it('surfaces isConfigured / setTransport upgrades', async () => { + const svc = new SmsService({ transport: new LogSmsTransport(), configured: false }); + expect(svc.isConfigured()).toBe(false); + svc.setTransport({ async send() { return { messageId: 'x' }; } }, true); + expect(svc.isConfigured()).toBe(true); + }); +}); + +describe('LogSmsTransport', () => { + it('suppresses the body in production', async () => { + const logger = collectingLogger(); + const transport = new LogSmsTransport(logger); + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await transport.send({ to: '+8613812345678', body: 'SECRET-999999' }); + } finally { + process.env.NODE_ENV = prev; + } + expect(logger.lines.join('\n')).not.toContain('SECRET-999999'); + expect(logger.lines.join('\n')).toContain('body suppressed'); + }); + + it('prints the body outside production (local OTP testing)', async () => { + const logger = collectingLogger(); + const transport = new LogSmsTransport(logger); + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + try { + await transport.send({ to: '+8613812345678', body: 'code 424242' }); + } finally { + process.env.NODE_ENV = prev; + } + expect(logger.lines.join('\n')).toContain('code 424242'); + }); +}); diff --git a/packages/plugins/plugin-sms/src/sms-service.ts b/packages/plugins/plugin-sms/src/sms-service.ts new file mode 100644 index 0000000000..135991cbc1 --- /dev/null +++ b/packages/plugins/plugin-sms/src/sms-service.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + ISmsService, + ISmsTransport, + NormalizedSmsMessage, + SendSmsInput, + SendSmsResult, + SmsTransportSendResult, +} from '@objectstack/spec/contracts'; + +/** + * Normalize + validate a recipient phone number. Accepts E.164 and common + * human formats (spaces / dashes / dots / parens are stripped). Returns + * `undefined` when the result doesn't look like a phone number. + * + * Same shape rule as plugin-auth's `normalizePhoneNumber` — 6-15 digits + * with an optional leading `+` (kept local: the two packages must not + * depend on each other). + */ +export function normalizeSmsRecipient(raw: string): string | undefined { + const stripped = String(raw ?? '').replace(/[\s\-().]/g, ''); + return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined; +} + +/** + * Mask a phone number for log lines: keep the prefix (country-code-ish) and + * the last two digits, hide the middle. `+8613812345678` → `+8613******78`. + * SMS logging policy: masked recipient + status ONLY — never the body + * (OTP codes travel in it; see the ISmsService contract header). + */ +export function maskPhoneNumber(phone: string): string { + const p = String(phone ?? ''); + if (p.length <= 6) return `${p.slice(0, 2)}****`; + return `${p.slice(0, 5)}${'*'.repeat(Math.max(2, p.length - 7))}${p.slice(-2)}`; +} + +/** + * Development transport — never actually sends. Logs the masked recipient + * (and, OUTSIDE production only, the message body so local OTP flows are + * testable) and returns a synthetic message id. + * + * The production-body suppression is a hard rule from #2780: OTP codes must + * never land in logs. In dev the body IS the delivery — same pattern as the + * auth magic-link / invitation URLs, which are printed in dev only. + */ +export class LogSmsTransport implements ISmsTransport { + private counter = 0; + constructor(private readonly logger?: { info: (msg: string, meta?: any) => void }) {} + async send(message: NormalizedSmsMessage): Promise { + const messageId = `dev-sms-${Date.now()}-${++this.counter}`; + const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; + this.logger?.info( + `[LogSmsTransport] would send SMS to ${maskPhoneNumber(message.to)}` + + (dev ? ` — body: ${message.body}` : ' (body suppressed outside dev)'), + { messageId }, + ); + return { messageId, response: 'logged' }; + } +} + +export interface SmsServiceOptions { + transport: ISmsTransport; + /** + * Whether `transport` is a real provider (Aliyun / Twilio / injected). + * `false` = development log fallback; surfaced via `isConfigured()` so + * consumers can gate SMS-dependent features in production. + */ + configured: boolean; + /** Retry attempts on transport throw. Default 0 (no retry). */ + retries?: number; + /** Logger for diagnostic output. NEVER receives message bodies. */ + logger?: { + info: (msg: string, meta?: any) => void; + warn: (msg: string, meta?: any) => void; + }; +} + +/** + * Concrete ISmsService implementation. + * + * Flow: validate + normalize input → transport.send() (with optional + * retry) → SendSmsResult. Deliberately NO persistence and NO body logging — + * see the contract header in `@objectstack/spec/contracts/sms-service.ts`. + */ +export class SmsService implements ISmsService { + constructor(public options: SmsServiceOptions) { + if (!options.transport) throw new Error('SmsService: transport is required'); + } + + /** + * Hot-swap the underlying transport (settings namespace changed). The + * `configured` flag travels with the transport. + */ + setTransport(transport: ISmsTransport, configured: boolean): void { + this.options.transport = transport; + this.options.configured = configured; + } + + isConfigured(): boolean { + return this.options.configured; + } + + async send(input: SendSmsInput): Promise { + const to = normalizeSmsRecipient(input?.to ?? ''); + if (!to) { + throw new Error(`VALIDATION_FAILED: '${maskPhoneNumber(String(input?.to ?? ''))}' is not a valid phone number`); + } + const body = String(input?.body ?? '').trim(); + if (!body && !input?.templateId && !input?.templateParams) { + throw new Error('VALIDATION_FAILED: body (or templateId/templateParams) is required'); + } + + const normalized: NormalizedSmsMessage = { + to, + body, + ...(input.templateId ? { templateId: input.templateId } : {}), + ...(input.templateParams ? { templateParams: input.templateParams } : {}), + }; + + const id = `sms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const maxAttempts = (this.options.retries ?? 0) + 1; + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const result = await this.options.transport.send(normalized); + this.options.logger?.info( + `[SmsService] sent to ${maskPhoneNumber(to)} (messageId=${result.messageId})`, + ); + return { id, status: 'sent', messageId: result.messageId }; + } catch (err) { + lastError = err; + if (attempt < maxAttempts) { + await new Promise((r) => setTimeout(r, Math.min(2000, 100 * 2 ** (attempt - 1)))); + } + } + } + const errMessage = String((lastError as Error)?.message ?? lastError ?? 'send failed').slice(0, 500); + this.options.logger?.warn( + `[SmsService] send to ${maskPhoneNumber(to)} failed: ${errMessage}`, + ); + return { id, status: 'failed', error: errMessage }; + } +} diff --git a/packages/plugins/plugin-sms/src/transports/aliyun.ts b/packages/plugins/plugin-sms/src/transports/aliyun.ts new file mode 100644 index 0000000000..448acbf4a4 --- /dev/null +++ b/packages/plugins/plugin-sms/src/transports/aliyun.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { createHash, createHmac, randomUUID } from 'node:crypto'; +import type { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult } from '@objectstack/spec/contracts'; + +export interface AliyunSmsTransportOptions { + accessKeyId: string; + accessKeySecret: string; + /** 短信签名 SignName — the registered sender signature, e.g. `阿里云短信测试`. */ + signName: string; + /** + * Default 模板 TemplateCode used when the input carries no `templateId` + * (Aliyun only delivers pre-registered templates — free-form bodies are + * refused by the API). A catch-all template with a single `${content}` + * variable makes generic notification sends possible. + */ + defaultTemplateCode?: string; + /** API endpoint host. Default `dysmsapi.aliyuncs.com`. */ + endpoint?: string; + /** Injectable fetch for tests. */ + fetchImpl?: typeof fetch; +} + +const API_VERSION = '2017-05-25'; +const ALGORITHM = 'ACS3-HMAC-SHA256'; + +const sha256Hex = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex'); +const hmac256Hex = (key: string, s: string): string => createHmac('sha256', key).update(s, 'utf8').digest('hex'); + +/** RFC 3986 percent-encoding (Aliyun requires `%20`, `%2A`, `%7E` handling). */ +const encode = (s: string): string => + encodeURIComponent(s) + .replace(/\+/g, '%20') + .replace(/\*/g, '%2A') + .replace(/%7E/g, '~'); + +/** + * Aliyun SMS (dysmsapi `SendSms`) transport, signed with the current + * ACS3-HMAC-SHA256 scheme — plain `fetch` + `node:crypto`, no vendor SDK. + * + * Aliyun is template-only: the transport sends `templateId` (falling back to + * the configured default TemplateCode) with `templateParams` (falling back to + * `{ content: body }` for the catch-all-template pattern). The rendered + * `body` itself is never transmitted outside `TemplateParam`. + */ +export class AliyunSmsTransport implements ISmsTransport { + private readonly endpoint: string; + private readonly fetchImpl: typeof fetch; + + constructor(private readonly options: AliyunSmsTransportOptions) { + if (!options.accessKeyId || !options.accessKeySecret) { + throw new Error('AliyunSmsTransport: accessKeyId and accessKeySecret are required'); + } + if (!options.signName) { + throw new Error('AliyunSmsTransport: signName is required'); + } + this.endpoint = options.endpoint ?? 'dysmsapi.aliyuncs.com'; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async send(message: NormalizedSmsMessage): Promise { + const templateCode = message.templateId ?? this.options.defaultTemplateCode; + if (!templateCode) { + throw new Error( + 'AliyunSmsTransport: Aliyun requires a template — pass templateId or configure a default template code', + ); + } + const templateParam = JSON.stringify(message.templateParams ?? { content: message.body }); + + const query: Record = { + PhoneNumbers: message.to, + SignName: this.options.signName, + TemplateCode: templateCode, + TemplateParam: templateParam, + }; + const canonicalQuery = Object.keys(query) + .sort() + .map((k) => `${encode(k)}=${encode(query[k])}`) + .join('&'); + + const bodyHash = sha256Hex(''); // POST with all parameters in the query string + const headers: Record = { + host: this.endpoint, + 'x-acs-action': 'SendSms', + 'x-acs-content-sha256': bodyHash, + 'x-acs-date': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + 'x-acs-signature-nonce': randomUUID(), + 'x-acs-version': API_VERSION, + }; + const signedHeaderNames = Object.keys(headers).sort(); + const canonicalHeaders = signedHeaderNames.map((k) => `${k}:${headers[k].trim()}\n`).join(''); + const signedHeaders = signedHeaderNames.join(';'); + + const canonicalRequest = ['POST', '/', canonicalQuery, canonicalHeaders, signedHeaders, bodyHash].join('\n'); + const stringToSign = `${ALGORITHM}\n${sha256Hex(canonicalRequest)}`; + const signature = hmac256Hex(this.options.accessKeySecret, stringToSign); + + const response = await this.fetchImpl(`https://${this.endpoint}/?${canonicalQuery}`, { + method: 'POST', + headers: { + ...headers, + Authorization: `${ALGORITHM} Credential=${this.options.accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`, + }, + }); + + let payload: any = {}; + try { payload = await response.json(); } catch { /* non-JSON error body */ } + if (!response.ok || payload?.Code !== 'OK') { + const code = payload?.Code ?? `HTTP_${response.status}`; + const detail = payload?.Message ?? response.statusText ?? 'request failed'; + throw new Error(`Aliyun SendSms failed (${code}): ${detail}`); + } + return { messageId: String(payload.BizId ?? payload.RequestId ?? ''), response: payload.RequestId }; + } +} diff --git a/packages/plugins/plugin-sms/src/transports/index.ts b/packages/plugins/plugin-sms/src/transports/index.ts new file mode 100644 index 0000000000..844a52d26e --- /dev/null +++ b/packages/plugins/plugin-sms/src/transports/index.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ISmsTransport } from '@objectstack/spec/contracts'; +import { LogSmsTransport } from '../sms-service.js'; +import { AliyunSmsTransport } from './aliyun.js'; +import { TwilioSmsTransport } from './twilio.js'; + +export { AliyunSmsTransport, type AliyunSmsTransportOptions } from './aliyun.js'; +export { TwilioSmsTransport, type TwilioSmsTransportOptions } from './twilio.js'; + +export type SmsProviderTag = 'log' | 'aliyun' | 'twilio'; + +export interface MakeSmsTransportOptions { + provider: SmsProviderTag; + /** Provider-specific credentials/options (see the transport option types). */ + options?: Record; + logger?: { info: (msg: string, meta?: any) => void }; +} + +/** + * Build an ISmsTransport from a provider tag + opts. Used by + * SmsServicePlugin to materialise the transport selected by config / + * the `sms` settings namespace. + * + * Throws when a non-`log` provider is missing required credentials. + */ +export function makeSmsTransport(opts: MakeSmsTransportOptions): ISmsTransport { + const { provider, options = {}, logger } = opts; + switch (provider) { + case 'log': + return new LogSmsTransport(logger); + case 'aliyun': + return new AliyunSmsTransport(options as any); + case 'twilio': + return new TwilioSmsTransport(options as any); + default: + throw new Error(`makeSmsTransport: unknown provider '${provider}'`); + } +} diff --git a/packages/plugins/plugin-sms/src/transports/transports.test.ts b/packages/plugins/plugin-sms/src/transports/transports.test.ts new file mode 100644 index 0000000000..d1346a8d69 --- /dev/null +++ b/packages/plugins/plugin-sms/src/transports/transports.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { AliyunSmsTransport } from './aliyun.js'; +import { TwilioSmsTransport } from './twilio.js'; +import { makeSmsTransport } from './index.js'; +import { LogSmsTransport } from '../sms-service.js'; + +const jsonResponse = (body: any, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + statusText: 'x', + json: async () => body, + }) as any; + +describe('AliyunSmsTransport', () => { + const base = { + accessKeyId: 'ak', + accessKeySecret: 'secret', + signName: '测试签名', + defaultTemplateCode: 'SMS_123', + }; + + it('requires credentials + sign name', () => { + expect(() => new AliyunSmsTransport({ ...base, accessKeyId: '' } as any)).toThrow(/accessKeyId/); + expect(() => new AliyunSmsTransport({ ...base, signName: '' } as any)).toThrow(/signName/); + }); + + it('sends a signed SendSms request with template params', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'biz_1', RequestId: 'req_1' })); + const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any }); + const r = await t.send({ to: '+8613800000000', body: 'ignored', templateParams: { code: '123456' } }); + expect(r.messageId).toBe('biz_1'); + + const [url, init] = fetchImpl.mock.calls[0] as any[]; + expect(String(url)).toContain('https://dysmsapi.aliyuncs.com/?'); + expect(String(url)).toContain('PhoneNumbers=%2B8613800000000'); + expect(String(url)).toContain('TemplateCode=SMS_123'); + expect(decodeURIComponent(String(url))).toContain('{"code":"123456"}'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toMatch(/^ACS3-HMAC-SHA256 Credential=ak,SignedHeaders=host;x-acs-action;/); + expect(init.headers['x-acs-action']).toBe('SendSms'); + expect(init.headers['x-acs-version']).toBe('2017-05-25'); + }); + + it('falls back to { content: body } for the default catch-all template', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'OK', BizId: 'b' })); + const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any }); + await t.send({ to: '13800000000', body: 'hello world' }); + expect(decodeURIComponent(String(fetchImpl.mock.calls[0][0]))).toContain('{"content":"hello world"}'); + }); + + it('throws when no template code is available (Aliyun is template-only)', async () => { + const t = new AliyunSmsTransport({ ...base, defaultTemplateCode: undefined, fetchImpl: vi.fn() as any }); + await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/template/); + }); + + it('maps a non-OK Code to a thrown error', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ Code: 'isv.BUSINESS_LIMIT_CONTROL', Message: 'limit' })); + const t = new AliyunSmsTransport({ ...base, fetchImpl: fetchImpl as any }); + await expect(t.send({ to: '13800000000', body: 'x' })).rejects.toThrow(/BUSINESS_LIMIT_CONTROL/); + }); +}); + +describe('TwilioSmsTransport', () => { + const base = { accountSid: 'AC123', authToken: 'tok', from: '+15005550006' }; + + it('requires credentials and a sender', () => { + expect(() => new TwilioSmsTransport({ ...base, authToken: '' } as any)).toThrow(/accountSid and authToken/); + expect(() => new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok' } as any)).toThrow(/from/); + }); + + it('POSTs the Messages resource with Basic auth + form body', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM1', status: 'queued' }, 201)); + const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any }); + const r = await t.send({ to: '+15005550009', body: 'hi there' }); + expect(r.messageId).toBe('SM1'); + + const [url, init] = fetchImpl.mock.calls[0] as any[]; + expect(String(url)).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json'); + expect(init.headers.Authorization).toBe(`Basic ${Buffer.from('AC123:tok').toString('base64')}`); + const form = new URLSearchParams(init.body); + expect(form.get('To')).toBe('+15005550009'); + expect(form.get('Body')).toBe('hi there'); + expect(form.get('From')).toBe('+15005550006'); + }); + + it('prefers MessagingServiceSid over From when configured', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ sid: 'SM2' }, 201)); + const t = new TwilioSmsTransport({ accountSid: 'AC123', authToken: 'tok', messagingServiceSid: 'MG9', fetchImpl: fetchImpl as any }); + await t.send({ to: '+15005550009', body: 'x' }); + const form = new URLSearchParams((fetchImpl.mock.calls[0] as any[])[1].body); + expect(form.get('MessagingServiceSid')).toBe('MG9'); + expect(form.get('From')).toBeNull(); + }); + + it('maps an error response to a thrown error', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ code: 21211, message: 'invalid To' }, 400)); + const t = new TwilioSmsTransport({ ...base, fetchImpl: fetchImpl as any }); + await expect(t.send({ to: '+1', body: 'x' })).rejects.toThrow(/21211.*invalid To/); + }); +}); + +describe('makeSmsTransport', () => { + it('builds by provider tag', () => { + expect(makeSmsTransport({ provider: 'log' })).toBeInstanceOf(LogSmsTransport); + expect(makeSmsTransport({ provider: 'aliyun', options: { accessKeyId: 'a', accessKeySecret: 'b', signName: 'c' } })) + .toBeInstanceOf(AliyunSmsTransport); + expect(makeSmsTransport({ provider: 'twilio', options: { accountSid: 'a', authToken: 'b', from: '+1' } })) + .toBeInstanceOf(TwilioSmsTransport); + expect(() => makeSmsTransport({ provider: 'nope' as any })).toThrow(/unknown provider/); + }); +}); diff --git a/packages/plugins/plugin-sms/src/transports/twilio.ts b/packages/plugins/plugin-sms/src/transports/twilio.ts new file mode 100644 index 0000000000..c14b7c472f --- /dev/null +++ b/packages/plugins/plugin-sms/src/transports/twilio.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ISmsTransport, NormalizedSmsMessage, SmsTransportSendResult } from '@objectstack/spec/contracts'; + +export interface TwilioSmsTransportOptions { + accountSid: string; + authToken: string; + /** Sender number (E.164). One of `from` / `messagingServiceSid` is required. */ + from?: string; + /** Twilio Messaging Service SID (alternative to a fixed `from` number). */ + messagingServiceSid?: string; + /** API base URL override (tests). Default `https://api.twilio.com`. */ + baseUrl?: string; + /** Injectable fetch for tests. */ + fetchImpl?: typeof fetch; +} + +/** + * Twilio Programmable Messaging transport — a single `POST + * /2010-04-01/Accounts/{sid}/Messages.json` with HTTP Basic auth. Plain + * `fetch`, no vendor SDK. Free-form: delivers `body` verbatim. + */ +export class TwilioSmsTransport implements ISmsTransport { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(private readonly options: TwilioSmsTransportOptions) { + if (!options.accountSid || !options.authToken) { + throw new Error('TwilioSmsTransport: accountSid and authToken are required'); + } + if (!options.from && !options.messagingServiceSid) { + throw new Error('TwilioSmsTransport: one of from / messagingServiceSid is required'); + } + this.baseUrl = (options.baseUrl ?? 'https://api.twilio.com').replace(/\/$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async send(message: NormalizedSmsMessage): Promise { + const form = new URLSearchParams({ + To: message.to, + Body: message.body, + ...(this.options.messagingServiceSid + ? { MessagingServiceSid: this.options.messagingServiceSid } + : { From: this.options.from as string }), + }); + const auth = Buffer.from(`${this.options.accountSid}:${this.options.authToken}`).toString('base64'); + const response = await this.fetchImpl( + `${this.baseUrl}/2010-04-01/Accounts/${encodeURIComponent(this.options.accountSid)}/Messages.json`, + { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + }, + ); + + let payload: any = {}; + try { payload = await response.json(); } catch { /* non-JSON error body */ } + if (!response.ok) { + const code = payload?.code ?? `HTTP_${response.status}`; + const detail = payload?.message ?? response.statusText ?? 'request failed'; + throw new Error(`Twilio send failed (${code}): ${detail}`); + } + return { messageId: String(payload.sid ?? ''), response: payload.status }; + } +} diff --git a/packages/plugins/plugin-sms/tsconfig.json b/packages/plugins/plugin-sms/tsconfig.json new file mode 100644 index 0000000000..385be7ea89 --- /dev/null +++ b/packages/plugins/plugin-sms/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index cf5c2a6615..903b32fa7e 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -57,6 +57,8 @@ export { createInboxChannel, INBOX_OBJECT, RECEIPT_OBJECT } from './inbox-channe export type { InboxChannelOptions } from './inbox-channel.js'; export { createEmailChannel, USER_OBJECT as EMAIL_USER_OBJECT } from './email-channel.js'; export type { EmailChannelOptions, EmailSenderSurface } from './email-channel.js'; +export { createSmsChannel } from './sms-channel.js'; +export type { SmsChannelOptions, SmsSenderSurface } from './sms-channel.js'; // Templates + renderer (ADR-0030 P3) export { diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts index 5938ddcc39..d22d540514 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts @@ -130,6 +130,27 @@ function provisionCtx() { return { ctx, engine, synced, fireReady: async () => { for (const fn of readyHooks) await fn(); } }; } +describe('MessagingServicePlugin — email/sms channel registration (kernel:ready)', () => { + it('registers the sms channel when an sms service is present', async () => { + const { ctx, fireReady } = provisionCtx(); + ctx.registerService('sms', { async send() { return { status: 'sent' }; } }); + await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx); + + const messaging: any = ctx.getService('messaging'); + expect(messaging.getRegisteredChannels()).not.toContain('sms'); + await fireReady(); + expect(messaging.getRegisteredChannels()).toContain('sms'); + }); + + it('does NOT register the sms channel when no sms service exists', async () => { + const { ctx, fireReady } = provisionCtx(); + await new MessagingServicePlugin({ reliableDelivery: false, retentionDays: 0 }).init(ctx); + await fireReady(); + const messaging: any = ctx.getService('messaging'); + expect(messaging.getRegisteredChannels()).not.toContain('sms'); + }); +}); + describe('MessagingServicePlugin — system table provisioning', () => { it('creates the inbox + receipt tables on kernel:ready', async () => { const { ctx, engine, synced, fireReady } = provisionCtx(); diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 489d6c36bd..5ff337f012 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -11,6 +11,7 @@ import { NotificationDispatcher, type DispatchCluster } from './dispatcher.js'; import { HttpDispatcher } from './http-dispatcher.js'; import { NotificationRetention, DEFAULT_NOTIFICATION_RETENTION_DAYS } from './retention.js'; import { createEmailChannel } from './email-channel.js'; +import { createSmsChannel } from './sms-channel.js'; import { NotificationTemplateStore } from './template-renderer.js'; import { InboxMessage, @@ -211,6 +212,24 @@ export class MessagingServicePlugin implements Plugin { ctx.logger.info('[messaging] email channel registered (renders sys_notification_template)'); } }); + + // SMS channel (#2780): same pattern as email — register when an + // `sms` service (plugin-sms) is present at kernel:ready; absent + // sms ⇒ no channel, so a notify(channels:['sms']) reports "not + // registered" rather than silently no-opping. + const getSms = () => { + try { + return ctx.getService('sms'); + } catch { + return undefined; + } + }; + ctx.hook('kernel:ready', async () => { + if (getSms()) { + service.registerChannel(createSmsChannel({ getSms, getData, store: templateStore })); + ctx.logger.info('[messaging] sms channel registered (renders sys_notification_template)'); + } + }); } // Reliable delivery (P1): wire the outbox + dispatcher once the engine diff --git a/packages/services/service-messaging/src/sms-channel.test.ts b/packages/services/service-messaging/src/sms-channel.test.ts new file mode 100644 index 0000000000..7b37587106 --- /dev/null +++ b/packages/services/service-messaging/src/sms-channel.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createSmsChannel } from './sms-channel.js'; +import { NotificationTemplateStore } from './template-renderer.js'; +import type { Delivery } from './channel.js'; + +function silentCtx() { + return { logger: { info: () => {}, warn: () => {}, error: () => {} } }; +} + +function delivery(over: Partial = {}, recipient = 'user_1'): Delivery { + return { + channel: 'sms', + recipient, + notification: { + notificationId: 'evt_1', + topic: 'deal.won', + title: 'Deal closed', + body: 'Acme signed', + severity: 'info', + recipients: [recipient], + payload: { title: 'Deal closed', body: 'Acme signed' }, + ...over, + }, + }; +} + +/** Fake data engine: user id → phone_number, and template lookups. */ +function fakeData(opts: { users?: Record; templates?: any[] } = {}) { + const users = opts.users ?? { user_1: '+8613800000000' }; + const templates = opts.templates ?? []; + return { + async findOne(object: string, query: any) { + const w = query?.where ?? {}; + if (object === 'sys_user') { + const phone = users[String(w.id)]; + return phone ? { phone_number: phone } : null; + } + if (object === 'sys_notification_template') { + return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null; + } + return null; + }, + async find() { return []; }, + async insert(_o: string, r: any) { return { id: 'x', ...r }; }, + async update() { return {}; }, + async delete() { return {}; }, + async count() { return 0; }, + async aggregate() { return []; }, + } as any; +} + +function fakeSms() { + const sent: any[] = []; + return { + sent, + service: { + async send(input: any) { + sent.push(input); + return { id: 'sms_1', status: 'sent', messageId: 'prov_1' }; + }, + }, + }; +} + +function channel(getSms: () => any, data: any) { + const store = new NotificationTemplateStore({ getData: () => data }); + return createSmsChannel({ getSms, getData: () => data, store }); +} + +describe('sms channel', () => { + it('has the stable id "sms"', () => { + const ch = channel(() => undefined, fakeData()); + expect(ch.id).toBe('sms'); + }); + + it('no-ops (success) when no sms service is registered', async () => { + const ch = channel(() => undefined, fakeData()); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(r.externalId).toBeUndefined(); + }); + + it('resolves the recipient user id → phone_number and sends the fallback body', async () => { + const sms = fakeSms(); + const ch = channel(() => sms.service, fakeData({ users: { user_1: '+8613800000000' } })); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(true); + expect(r.externalId).toBe('prov_1'); + expect(sms.sent).toHaveLength(1); + expect(sms.sent[0].to).toBe('+8613800000000'); + expect(sms.sent[0].body).toBe('Acme signed'); + expect(sms.sent[0].templateParams).toEqual({ content: 'Acme signed' }); + }); + + it('renders a text template when one exists for (topic, sms, locale)', async () => { + const sms = fakeSms(); + const data = fakeData({ + templates: [{ topic: 'deal.won', channel: 'sms', locale: 'en', is_active: true, subject: 'Won', body: 'Won: {{ payload.title }}', format: 'text' }], + }); + const ch = channel(() => sms.service, data); + await ch.send(silentCtx(), delivery()); + expect(sms.sent[0].body).toBe('Won: Deal closed'); + }); + + it('accepts a phone-shaped recipient verbatim (no user lookup)', async () => { + const sms = fakeSms(); + const ch = channel(() => sms.service, fakeData({ users: {} })); + const r = await ch.send(silentCtx(), delivery({}, '+15005550006')); + expect(r.ok).toBe(true); + expect(sms.sent[0].to).toBe('+15005550006'); + }); + + it('falls back to the title when the notification has no body', async () => { + const sms = fakeSms(); + const ch = channel(() => sms.service, fakeData()); + const r = await ch.send(silentCtx(), delivery({ body: '', payload: { title: 'Deal closed' } })); + expect(r.ok).toBe(true); + expect(sms.sent[0].body).toBe('Deal closed'); + }); + + it('reports a failure when no phone number resolves (observable on the delivery row)', async () => { + const sms = fakeSms(); + const ch = channel(() => sms.service, fakeData({ users: {} })); + const r = await ch.send(silentCtx(), delivery({}, 'ghost')); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/no phone number/); + expect(sms.sent).toHaveLength(0); + }); + + it('surfaces a failed send result as ok:false (dispatcher will retry)', async () => { + const data = fakeData(); + const ch = createSmsChannel({ + getSms: () => ({ async send() { return { status: 'failed', error: 'provider down' }; } }), + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(false); + expect(r.error).toContain('provider down'); + }); + + it('surfaces a transport throw as ok:false', async () => { + const data = fakeData(); + const ch = createSmsChannel({ + getSms: () => ({ async send() { throw new Error('gateway timeout'); } }), + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), delivery()); + expect(r.ok).toBe(false); + expect(r.error).toContain('gateway timeout'); + expect(ch.classifyError?.(new Error('x'))).toBe('retryable'); + }); +}); diff --git a/packages/services/service-messaging/src/sms-channel.ts b/packages/services/service-messaging/src/sms-channel.ts new file mode 100644 index 0000000000..fd0742b97e --- /dev/null +++ b/packages/services/service-messaging/src/sms-channel.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { + Delivery, + ErrorClass, + MessagingChannel, + MessagingChannelContext, + SendResult, +} from './channel.js'; +import { + NotificationTemplateStore, + renderNotification, + DEFAULT_LOCALE, +} from './template-renderer.js'; +import { USER_OBJECT } from './email-channel.js'; + +/** + * Structural view of the SMS service (`@objectstack/plugin-sms`'s + * `SmsService`), declared locally so service-messaging takes no runtime + * dependency on it — the channel resolves whatever is registered under the + * `sms` service and sends through this shape (mirrors `EmailSenderSurface` + * in email-channel.ts). + */ +export interface SmsSenderSurface { + send(input: { + to: string; + body: string; + templateId?: string; + templateParams?: Record; + }): Promise<{ id?: string; status?: string; messageId?: string; error?: string } | unknown>; +} + +export interface SmsChannelOptions { + /** Resolve the SMS service; `undefined` ⇒ the channel no-ops (not installed). */ + getSms(): SmsSenderSurface | undefined; + /** Resolve the data engine (recipient phone-number lookup). */ + getData(): IDataEngine | undefined; + /** Template store for `(topic, 'sms', locale)` rendering. */ + store: NotificationTemplateStore; + /** User identity object override (default `sys_user`). */ + userObject?: string; + /** Locale used when the delivery carries none (default {@link DEFAULT_LOCALE}). */ + defaultLocale?: string; +} + +// Same shape rule as plugin-auth's `normalizePhoneNumber` (kept local; the +// packages must not depend on each other): 6-15 digits, optional leading `+`, +// after stripping common human separators. +const PHONE_SHAPE = (s: string): string | undefined => { + const stripped = String(s ?? '').replace(/[\s\-().]/g, ''); + return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined; +}; + +/** + * The `sms` channel (#2780) — delivers a notification by SMS. + * + * Mirrors the email channel (ADR-0022 "channel delegates transport to a + * sub-system"): resolve the recipient's phone number (a literal number is + * used as-is; otherwise `sys_user.phone_number`), render + * `(topic, 'sms', locale)` from `sys_notification_template` (fallback to + * `payload.title`/`body`), and hand the text to the `sms` service. + * Retry/backoff/dead-letter come for free from the P1 outbox dispatcher. + * + * Degrades like the email channel: no sms service ⇒ logged no-op success + * (capability not installed); a recipient with no resolvable phone number ⇒ + * a reported failure (so the delivery row shows why). + */ +export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel { + const userObject = opts.userObject ?? USER_OBJECT; + const defaultLocale = opts.defaultLocale ?? DEFAULT_LOCALE; + + async function resolvePhone( + ctx: MessagingChannelContext, + data: IDataEngine | undefined, + recipient: string, + ): Promise { + const literal = PHONE_SHAPE(recipient); + if (literal) return literal; // already a phone number + if (!data) return undefined; + try { + const user = await data.findOne(userObject, { where: { id: recipient }, fields: ['phone_number'] }); + const phone = user?.phone_number; + return typeof phone === 'string' ? PHONE_SHAPE(phone) : undefined; + } catch (err) { + ctx.logger.warn(`[sms] phone lookup for '${recipient}' failed (${(err as Error).message})`); + return undefined; + } + } + + return { + id: 'sms', + + async send(ctx: MessagingChannelContext, delivery: Delivery): Promise { + const sms = opts.getSms(); + if (!sms) { + ctx.logger.warn(`[sms] no sms service registered; '${delivery.recipient}' not messaged`); + return { ok: true }; // capability not installed — no-op, like email w/o service + } + + const n = delivery.notification; + const phone = await resolvePhone(ctx, opts.getData(), delivery.recipient); + if (!phone) { + return { ok: false, error: `no phone number for recipient '${delivery.recipient}'` }; + } + + const payload = (n.payload ?? {}) as Record; + const locale = typeof payload.locale === 'string' ? payload.locale : defaultLocale; + const template = await opts.store.load(n.topic ?? '', 'sms', locale); + const rendered = renderNotification(template, { + topic: n.topic ?? '', + payload, + title: n.title, + body: n.body, + }); + + // SMS is a single short text: the rendered body wins; a + // body-less notification falls back to its title/subject. + const body = rendered.text?.trim() || rendered.subject?.trim() || ''; + if (!body) { + return { ok: false, error: 'notification rendered to an empty SMS body' }; + } + + try { + const result: any = await sms.send({ + to: phone, + body, + // Template-only providers (Aliyun) substitute the whole text + // into a catch-all `${content}` template by default. + templateParams: { content: body }, + }); + if (result?.status === 'failed') { + return { ok: false, error: `sms send failed: ${result?.error ?? 'unknown error'}` }; + } + const id = result?.messageId ?? result?.id; + return { ok: true, externalId: id != null ? String(id) : undefined }; + } catch (err) { + return { ok: false, error: `sms send failed: ${(err as Error).message}` }; + } + }, + + classifyError(_err: unknown): ErrorClass { + return 'retryable'; + }, + }; +} diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 9a54ea4a25..7ae63e73aa 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -65,6 +65,8 @@ export { featureFlagsSettingsManifest, mailSettingsManifest, mailTestActionHandler, + smsSettingsManifest, + smsTestActionHandler, storageSettingsManifest, storageTestActionHandler, } from './manifests/index.js'; diff --git a/packages/services/service-settings/src/manifests/index.ts b/packages/services/service-settings/src/manifests/index.ts index afec6e3751..780c799903 100644 --- a/packages/services/service-settings/src/manifests/index.ts +++ b/packages/services/service-settings/src/manifests/index.ts @@ -3,6 +3,7 @@ /** Reference manifests bundled with service-settings. */ export { authSettingsManifest } from './auth.manifest.js'; export { mailSettingsManifest, mailTestActionHandler } from './mail.manifest.js'; +export { smsSettingsManifest, smsTestActionHandler } from './sms.manifest.js'; export { brandingSettingsManifest } from './branding.manifest.js'; export { featureFlagsSettingsManifest } from './feature-flags.manifest.js'; export { storageSettingsManifest, storageTestActionHandler } from './storage.manifest.js'; @@ -17,6 +18,7 @@ export { companySettingsManifest } from './company.manifest.js'; import { authSettingsManifest } from './auth.manifest.js'; import { mailSettingsManifest } from './mail.manifest.js'; +import { smsSettingsManifest } from './sms.manifest.js'; import { brandingSettingsManifest } from './branding.manifest.js'; import { featureFlagsSettingsManifest } from './feature-flags.manifest.js'; import { storageSettingsManifest } from './storage.manifest.js'; @@ -32,6 +34,7 @@ export const builtinSettingsManifests = [ localizationSettingsManifest, authSettingsManifest, mailSettingsManifest, + smsSettingsManifest, storageSettingsManifest, aiSettingsManifest, knowledgeSettingsManifest, diff --git a/packages/services/service-settings/src/manifests/sms.manifest.test.ts b/packages/services/service-settings/src/manifests/sms.manifest.test.ts new file mode 100644 index 0000000000..d85e52d878 --- /dev/null +++ b/packages/services/service-settings/src/manifests/sms.manifest.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SettingsManifestSchema } from '@objectstack/spec/system'; +import { smsSettingsManifest, smsTestActionHandler } from './sms.manifest'; + +describe('sms settings manifest', () => { + it('parses against SettingsManifestSchema', () => { + expect(() => SettingsManifestSchema.parse(smsSettingsManifest)).not.toThrow(); + }); + + it('is a global-scope namespace guarded by manage_platform_settings', () => { + expect(smsSettingsManifest.namespace).toBe('sms'); + expect(smsSettingsManifest.scope).toBe('global'); + expect(smsSettingsManifest.readPermission).toBe('manage_platform_settings'); + expect(smsSettingsManifest.writePermission).toBe('manage_platform_settings'); + }); + + it('marks provider secrets as encrypted password specifiers', () => { + const byKey = new Map( + (smsSettingsManifest.specifiers as any[]).filter((s) => s.key).map((s) => [s.key, s]), + ); + for (const secret of ['aliyun_access_key_secret', 'twilio_auth_token']) { + expect(byKey.get(secret)?.type).toBe('password'); + expect(byKey.get(secret)?.encrypted).toBe(true); + } + }); +}); + +describe('smsTestActionHandler (fallback)', () => { + it('accepts the log provider', async () => { + const r = await smsTestActionHandler({ values: { provider: 'log' }, ctx: {} as any }); + expect(r.ok).toBe(true); + }); + + it('requires aliyun credentials', async () => { + const r = await smsTestActionHandler({ values: { provider: 'aliyun' }, ctx: {} as any }); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/AccessKey/); + }); + + it('requires a twilio sender', async () => { + const r = await smsTestActionHandler({ + values: { provider: 'twilio', twilio_account_sid: 'AC1', twilio_auth_token: 't' }, + ctx: {} as any, + }); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/From number|Messaging Service/); + }); +}); diff --git a/packages/services/service-settings/src/manifests/sms.manifest.ts b/packages/services/service-settings/src/manifests/sms.manifest.ts new file mode 100644 index 0000000000..1cc5aba581 --- /dev/null +++ b/packages/services/service-settings/src/manifests/sms.manifest.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SettingsManifest } from '@objectstack/spec/system'; +import type { SettingsActionHandler } from '../settings-service.types.js'; + +// Visibility expressions are written as inline strings here for +// readability (same pattern as mail.manifest.ts) — build as `unknown`, +// then cast. +const manifest = { + namespace: 'sms', + version: 1, + label: 'SMS Delivery', + icon: 'MessageSquare', + description: 'SMS provider configuration for OTP sign-in, invitations and notifications.', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + category: 'Communication', + order: 11, + specifiers: [ + { type: 'group', id: 'provider', label: 'Provider', required: false, + description: 'Choose how this workspace sends outbound SMS.' }, + + { type: 'select', key: 'provider', label: 'Provider', required: true, default: 'log', + options: [ + { value: 'log', label: 'None (log only — no real delivery)' }, + { value: 'aliyun', label: 'Aliyun SMS (阿里云短信)' }, + { value: 'twilio', label: 'Twilio' }, + ], + }, + + { type: 'group', id: 'aliyun', label: 'Aliyun SMS', required: false, visible: "${data.provider === 'aliyun'}" }, + { type: 'text', key: 'aliyun_access_key_id', label: 'AccessKey ID', required: true, + visible: "${data.provider === 'aliyun'}" }, + { type: 'password', key: 'aliyun_access_key_secret', label: 'AccessKey Secret', required: true, encrypted: true, + visible: "${data.provider === 'aliyun'}" }, + { type: 'text', key: 'aliyun_sign_name', label: 'Sign name (短信签名)', required: true, + visible: "${data.provider === 'aliyun'}" }, + { type: 'text', key: 'aliyun_template_code', label: 'Default template code (短信模板)', required: false, + description: 'Used when a send carries no explicit template. A catch-all template with a single ${content} variable enables generic notification SMS.', + visible: "${data.provider === 'aliyun'}" }, + + { type: 'group', id: 'twilio', label: 'Twilio', required: false, visible: "${data.provider === 'twilio'}" }, + { type: 'text', key: 'twilio_account_sid', label: 'Account SID', required: true, + visible: "${data.provider === 'twilio'}" }, + { type: 'password', key: 'twilio_auth_token', label: 'Auth token', required: true, encrypted: true, + visible: "${data.provider === 'twilio'}" }, + { type: 'text', key: 'twilio_from_number', label: 'From number', required: false, + description: 'E.164 sender, e.g. +15005550006. Either this or a Messaging Service SID.', + visible: "${data.provider === 'twilio'}" }, + { type: 'text', key: 'twilio_messaging_service_sid', label: 'Messaging Service SID', required: false, + visible: "${data.provider === 'twilio'}" }, + + { type: 'action_button', id: 'test', label: 'Send test SMS', required: false, icon: 'Send', + handler: { kind: 'http', method: 'POST', url: '/api/settings/sms/test' } }, + ], +}; + +/** SMS Delivery — provider configuration (#2780). */ +export const smsSettingsManifest = manifest as unknown as SettingsManifest; + +/** + * Built-in action handler stub for `sms/test` — configuration shape check + * only. SmsServicePlugin overrides it with a real send when installed + * (mirrors mail/test). + */ +export const smsTestActionHandler: SettingsActionHandler = async ({ values }) => { + const provider = String(values.provider ?? 'log'); + if (provider === 'aliyun') { + if (!values.aliyun_access_key_id || !values.aliyun_access_key_secret || !values.aliyun_sign_name) { + return { ok: false, severity: 'error', message: 'Aliyun SMS requires AccessKey ID, AccessKey Secret and a sign name.' }; + } + } else if (provider === 'twilio') { + if (!values.twilio_account_sid || !values.twilio_auth_token) { + return { ok: false, severity: 'error', message: 'Twilio requires an Account SID and auth token.' }; + } + if (!values.twilio_from_number && !values.twilio_messaging_service_sid) { + return { ok: false, severity: 'error', message: 'Twilio requires a From number or a Messaging Service SID.' }; + } + } + return { + ok: true, + severity: 'info', + message: `Configuration looks valid (provider=${provider}). Wire @objectstack/plugin-sms for actual delivery.`, + }; +}; diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 792390fe80..5d35885115 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -18,6 +18,7 @@ import { import { builtinSettingsManifests, mailTestActionHandler, + smsTestActionHandler, storageTestActionHandler, aiTestActionHandler, } from './manifests/index.js'; @@ -82,6 +83,7 @@ export class SettingsServicePlugin implements Plugin { manifests: opts.manifests ?? builtinSettingsManifests, actionHandlers: opts.actionHandlers ?? { mail: { test: mailTestActionHandler }, + sms: { test: smsTestActionHandler }, storage: { test: storageTestActionHandler }, ai: { test: aiTestActionHandler }, }, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 7ae83ed875..831dcd8995 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3564,6 +3564,8 @@ "IShareLinkService (interface)", "ISharingRuleService (interface)", "ISharingService (interface)", + "ISmsService (interface)", + "ISmsTransport (interface)", "IStartupOrchestrator (interface)", "IStorageService (interface)", "ITeamGraphService (interface)", @@ -3605,6 +3607,7 @@ "MigrationApplyResult (interface)", "ModelMessage (type)", "NormalizedEmailMessage (interface)", + "NormalizedSmsMessage (interface)", "NotificationChannel (type)", "NotificationMessage (interface)", "NotificationResult (interface)", @@ -3655,6 +3658,8 @@ "SearchResult (interface)", "SendEmailInput (interface)", "SendEmailResult (interface)", + "SendSmsInput (interface)", + "SendSmsResult (interface)", "SendTemplateInput (interface)", "ShareAccessLevel (type)", "ShareLink (interface)", @@ -3667,6 +3672,8 @@ "SharingRuleEvaluationResult (interface)", "SharingRuleRecipientType (type)", "SharingRuleRow (interface)", + "SmsDeliveryStatus (type)", + "SmsTransportSendResult (interface)", "StartupOptions (interface)", "StorageFileInfo (interface)", "StorageUploadOptions (interface)", diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index fbab4378e3..d7256f6365 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -201,6 +201,12 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ ssoEnforced: z.boolean().optional().describe( 'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)', ), + phoneNumber: z.boolean().optional().describe( + 'Phone-number sign-in enabled (phone + password, #2766 V1.5)', + ), + phoneNumberOtp: z.boolean().optional().describe( + 'Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service (#2780)', + ), })); /** diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index eaffbe30c9..8a1a4306c1 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -35,6 +35,7 @@ export * from './workflow-service.js'; export * from './feed-service.js'; export * from './export-service.js'; export * from './email-service.js'; +export * from './sms-service.js'; export * from './sharing-service.js'; export * from './share-link-service.js'; export * from './report-service.js'; diff --git a/packages/spec/src/contracts/sms-service.ts b/packages/spec/src/contracts/sms-service.ts new file mode 100644 index 0000000000..a2865a2e6f --- /dev/null +++ b/packages/spec/src/contracts/sms-service.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ISmsService — Outbound SMS Service Contract (#2780) + * + * Sends short text messages through a pluggable transport (Aliyun SMS, + * Twilio, etc.). Concrete implementations live in `@objectstack/plugin-sms`; + * provider integrations plug in as an `ISmsTransport`. + * + * Mirrors the shape of `IEmailService`/`IEmailTransport` (email-service.ts) + * with two deliberate differences: + * + * 1. **No persistence surface.** SMS bodies routinely carry one-time + * passwords; persisting them (the `sys_email` analog) would turn the + * message log into a credential store. Implementations MUST NOT write + * message bodies to durable storage or logs. + * 2. **Template-based providers.** Some providers (notably Aliyun) refuse + * free-form bodies and only deliver pre-registered templates. The input + * therefore carries an optional provider `templateId` + `templateParams` + * alongside the rendered `body`; a transport uses whichever it needs. + * + * Follows Dependency Inversion Principle — consumers (auth OTP, the + * messaging `sms` channel) depend on this interface, never on a concrete + * provider implementation. + */ + +/** + * Input for ISmsService.send(). + */ +export interface SendSmsInput { + /** Recipient phone number, E.164 recommended (`+8613800000000`). */ + to: string; + /** + * Rendered message text. Free-form transports (Twilio, log) deliver it + * verbatim; template-only transports (Aliyun) ignore it in favour of + * `templateId`/`templateParams`. + */ + body: string; + /** + * Provider-side template identifier (e.g. an Aliyun `TemplateCode`). + * Optional — template-only transports fall back to their configured + * default template when omitted. + */ + templateId?: string; + /** Variables for a provider-side template (e.g. `{ code: '123456' }`). */ + templateParams?: Record; + /** Optional related record for audit linkage (ids only — never bodies). */ + relatedObject?: string; + relatedId?: string; +} + +/** + * Normalized message handed to an ISmsTransport. The service validates the + * recipient shape before invoking the transport. + */ +export interface NormalizedSmsMessage { + to: string; + body: string; + templateId?: string; + templateParams?: Record; +} + +/** + * Transport-level result. + */ +export interface SmsTransportSendResult { + /** Provider message id (Aliyun BizId, Twilio SID, …). */ + messageId: string; + /** Optional raw response detail from the underlying provider. */ + response?: string; +} + +/** + * Pluggable SMS transport. plugin-sms ships a `LogSmsTransport` for + * development; production deployments configure a concrete provider + * (Aliyun / Twilio) or inject their own implementation of this shape. + * + * Transports MUST NOT mutate the message and MUST NOT log the body + * (OTP codes travel in it). + */ +export interface ISmsTransport { + send(message: NormalizedSmsMessage): Promise; +} + +/** Delivery status surfaced to callers. */ +export type SmsDeliveryStatus = 'sent' | 'failed'; + +/** + * Outcome of ISmsService.send(). + */ +export interface SendSmsResult { + /** Correlation id for this attempt (not persisted — see contract header). */ + id: string; + status: SmsDeliveryStatus; + /** Provider message id, set when status='sent'. */ + messageId?: string; + /** Failure detail, set when status='failed'. Never contains the body. */ + error?: string; +} + +/** + * SMS service contract. + */ +export interface ISmsService { + /** + * Send (or attempt to send) an SMS through the configured transport. + * Resolves with `status:'failed'` on transport errors rather than + * throwing; throws only on invalid input (bad recipient / empty body). + */ + send(input: SendSmsInput): Promise; + + /** + * Whether a real provider transport is active. `false` means the service + * is running on the development log fallback — messages are not actually + * delivered anywhere. Consumers that advertise SMS-dependent features + * (e.g. phone OTP sign-in) SHOULD gate on this in production. + */ + isConfigured(): boolean; +} diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index d4dee3904a..5af3f9f22c 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -102,20 +102,22 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({ ), /** * Enable better-auth's `phone-number` plugin so a phone number is a - * first-class sign-in identifier (#2766 V1.5). Scope note: only the - * phone+password surface is wired — `POST /sign-in/phone-number` — because - * the OTP flows (`/phone-number/send-otp`, `/phone-number/verify`, - * OTP-based reset) require an SMS delivery infrastructure that is tracked - * separately. Employees without an email address are created with a + * first-class sign-in identifier (#2766 V1.5). The phone+password surface + * (`POST /sign-in/phone-number`) always works; the OTP flows + * (`/phone-number/send-otp` + `/verify` for sign-in/verification, + * `/phone-number/request-password-reset` + `/reset-password` for + * self-service reset) additionally require a configured SMS delivery + * service (`@objectstack/plugin-sms`, #2780) and stay loudly NOT_SUPPORTED + * without one. Employees without an email address are created with a * generated placeholder address (never a real recipient — see - * placeholder-email.ts) and sign in with phone + password. + * placeholder-email.ts) and sign in with phone + password or phone OTP. * * The plugin augments `sys_user` with `phone_number` (unique) and * `phone_number_verified`, mapped to snake_case by * `buildPhoneNumberPluginSchema()`. */ phoneNumber: z.boolean().default(false).describe( - 'Enable phone-number sign-in (phone + password; OTP flows require separate SMS infrastructure)', + 'Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured)', ), })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 660cff6d6b..02672cf767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,6 +394,9 @@ importers: '@objectstack/plugin-sharing': specifier: workspace:* version: link:../plugins/plugin-sharing + '@objectstack/plugin-sms': + specifier: workspace:* + version: link:../plugins/plugin-sms '@objectstack/plugin-webhooks': specifier: workspace:* version: link:../plugins/plugin-webhooks @@ -1519,6 +1522,25 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/plugins/plugin-sms: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/plugins/plugin-webhooks: dependencies: '@objectstack/core':