From 66da398efbc59ec0a7c50961ce478c5bc3fb0224 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 9 Jul 2026 10:16:30 +0800 Subject: [PATCH 1/4] add send sms function --- functions/send-sms/__tests__/handler.test.ts | 127 ++++++++++++++++++ functions/send-sms/handler.json | 8 ++ functions/send-sms/handler.ts | 131 +++++++++++++++++++ pnpm-lock.yaml | 16 +++ 4 files changed, 282 insertions(+) create mode 100644 functions/send-sms/__tests__/handler.test.ts create mode 100644 functions/send-sms/handler.json create mode 100644 functions/send-sms/handler.ts diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts new file mode 100644 index 000000000..c6a538b31 --- /dev/null +++ b/functions/send-sms/__tests__/handler.test.ts @@ -0,0 +1,127 @@ +import { createMockContext } from '../../../tests/helpers/mock-context'; + +describe('send-sms handler', () => { + let handler: any; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.resetModules(); + fetchMock = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock; + handler = require('../handler').default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('sends sms_otp_code messages to the configured capture API', async () => { + const result = await handler( + { + sms_type: 'sms_otp_code', + phone: '+15555550101', + code: '123456', + }, + createMockContext({ + env: { + SMS_CAPTURE_API_URL: 'http://localhost:8091', + SMS_FROM: 'Constructive Test', + }, + }) + ); + + expect(result).toEqual({ complete: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8091/messages', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + to: '+15555550101', + from: 'Constructive Test', + text: 'Your verification code is 123456', + type: 'sms_otp_code', + metadata: { + userId: null, + }, + }), + }) + ); + }); + + it('sends mfa_verification_code messages with combined phone fields', async () => { + await handler( + { + sms_type: 'mfa_verification_code', + phone_cc: '+1', + phone_number: '(555) 555-0102', + code: '234567', + user_id: 'user-1', + }, + createMockContext() + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8091/messages', + expect.objectContaining({ + body: JSON.stringify({ + to: '+15555550102', + from: 'Constructive', + text: 'Your verification code is 234567', + type: 'mfa_verification_code', + metadata: { + userId: 'user-1', + }, + }), + }) + ); + }); + + it('skips capture API calls in dry-run mode', async () => { + const result = await handler( + { + sms_type: 'sms_otp_code', + phone: '+15555550103', + code: '345678', + }, + createMockContext({ + env: { + SMS_SEND_DRY_RUN: 'true', + }, + }) + ); + + expect(result).toEqual({ complete: true, dryRun: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws for missing sms_type', async () => { + await expect( + handler({ phone: '+15555550104', code: '456789' }, createMockContext()) + ).rejects.toThrow('Missing required field: sms_type'); + }); + + it('throws for unsupported sms_type', async () => { + await expect( + handler( + { + sms_type: 'sms_invite', + phone: '+15555550104', + code: '456789', + }, + createMockContext() + ) + ).rejects.toThrow('Unsupported sms_type: sms_invite'); + }); + + it('throws for missing code', async () => { + await expect( + handler({ sms_type: 'sms_otp_code', phone: '+15555550104' }, createMockContext()) + ).rejects.toThrow('Missing required field: code'); + }); + + it('throws for missing phone', async () => { + await expect( + handler({ sms_type: 'sms_otp_code', code: '456789' }, createMockContext()) + ).rejects.toThrow('Missing required field: phone'); + }); +}); diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json new file mode 100644 index 000000000..fb1099c78 --- /dev/null +++ b/functions/send-sms/handler.json @@ -0,0 +1,8 @@ +{ + "name": "send-sms", + "version": "0.1.0", + "type": "node-graphql", + "port": 8092, + "taskIdentifier": "sms:send_verification_code", + "description": "Sends SMS verification codes for auth flows" +} diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts new file mode 100644 index 000000000..a96ed0e1e --- /dev/null +++ b/functions/send-sms/handler.ts @@ -0,0 +1,131 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; + +type SmsType = 'sms_otp_code' | 'mfa_verification_code'; + +type SendSmsParams = { + sms_type?: SmsType | string; + phone?: string; + phone_cc?: string; + phone_number?: string; + code?: string; + user_id?: string; +}; + +type SendSmsContext = { + env: Record; + log: { + info: (...args: any[]) => void; + }; +}; + +type SmsCapturePayload = { + to: string; + from: string; + text: string; + type: SmsType; + metadata: { + userId: string | null; + }; +}; + +const DEFAULT_CAPTURE_API_URL = 'http://localhost:8091'; +const DEFAULT_SMS_FROM = 'Constructive'; + +function parseBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + + switch (value.trim().toLowerCase()) { + case '1': + case 'true': + case 'yes': + case 'on': + return true; + case '0': + case 'false': + case 'no': + case 'off': + return false; + default: + return undefined; + } +} + +function normalizePhone(params: SendSmsParams): string | null { + if (params.phone) return params.phone; + if (!params.phone_number) return null; + + return `${params.phone_cc ?? ''}${params.phone_number}`.replace(/[\s()-]/g, ''); +} + +function buildCapturePayload(params: SendSmsParams, env: SendSmsContext['env']): SmsCapturePayload { + if (!params.sms_type) { + throw new Error('Missing required field: sms_type'); + } + + if (params.sms_type !== 'sms_otp_code' && params.sms_type !== 'mfa_verification_code') { + throw new Error(`Unsupported sms_type: ${params.sms_type}`); + } + + if (!params.code) { + throw new Error('Missing required field: code'); + } + + const phone = normalizePhone(params); + if (!phone) { + throw new Error('Missing required field: phone'); + } + + return { + to: phone, + from: env.SMS_FROM || DEFAULT_SMS_FROM, + text: `Your verification code is ${params.code}`, + type: params.sms_type, + metadata: { + userId: params.user_id ?? null, + }, + }; +} + +async function sendSms(params: SendSmsParams, context: SendSmsContext) { + const payload = buildCapturePayload(params, context.env); + const isDryRun = parseBoolean(context.env.SMS_SEND_DRY_RUN) ?? false; + + context.log.info('[send-sms] Processing request', { + sms_type: params.sms_type, + to: payload.to, + dryRun: isDryRun, + }); + + if (isDryRun) { + return { + complete: true, + dryRun: true, + }; + } + + const captureApiUrl = context.env.SMS_CAPTURE_API_URL || DEFAULT_CAPTURE_API_URL; + const response = await fetch(`${captureApiUrl}/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`SMS capture API error: ${response.status}`); + } + + return { + complete: true, + }; +} + +const handler: FunctionHandler = async (params, context) => { + return sendSms(params, { + env: context.env, + log: context.log, + }); +}; + +export default handler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dcd03e50..a90eccec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,22 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-sms: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + generated/send-verification-link: dependencies: '@constructive-io/fn-runtime': From 316200fe12a9cdffefcddcd2c191ee67961ba03a Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 9 Jul 2026 10:44:43 +0800 Subject: [PATCH 2/4] target sms-dev API for send sms --- functions/send-sms/__tests__/handler.test.ts | 20 +++++---------- functions/send-sms/handler.ts | 26 +++++++------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts index c6a538b31..a1065c746 100644 --- a/functions/send-sms/__tests__/handler.test.ts +++ b/functions/send-sms/__tests__/handler.test.ts @@ -15,7 +15,7 @@ describe('send-sms handler', () => { jest.restoreAllMocks(); }); - it('sends sms_otp_code messages to the configured capture API', async () => { + it('sends sms_otp_code messages to the configured sms-dev API', async () => { const result = await handler( { sms_type: 'sms_otp_code', @@ -24,7 +24,7 @@ describe('send-sms handler', () => { }, createMockContext({ env: { - SMS_CAPTURE_API_URL: 'http://localhost:8091', + SMS_DEV_API_URL: 'http://localhost:4001', SMS_FROM: 'Constructive Test', }, }) @@ -32,17 +32,13 @@ describe('send-sms handler', () => { expect(result).toEqual({ complete: true }); expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8091/messages', + 'http://localhost:4001/v1/messages', expect.objectContaining({ method: 'POST', body: JSON.stringify({ to: '+15555550101', from: 'Constructive Test', - text: 'Your verification code is 123456', - type: 'sms_otp_code', - metadata: { - userId: null, - }, + body: 'Your verification code is 123456', }), }) ); @@ -61,16 +57,12 @@ describe('send-sms handler', () => { ); expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:8091/messages', + 'http://localhost:4001/v1/messages', expect.objectContaining({ body: JSON.stringify({ to: '+15555550102', from: 'Constructive', - text: 'Your verification code is 234567', - type: 'mfa_verification_code', - metadata: { - userId: 'user-1', - }, + body: 'Your verification code is 234567', }), }) ); diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts index a96ed0e1e..2bdb7ced9 100644 --- a/functions/send-sms/handler.ts +++ b/functions/send-sms/handler.ts @@ -18,17 +18,13 @@ type SendSmsContext = { }; }; -type SmsCapturePayload = { +type SmsDevMessagePayload = { to: string; from: string; - text: string; - type: SmsType; - metadata: { - userId: string | null; - }; + body: string; }; -const DEFAULT_CAPTURE_API_URL = 'http://localhost:8091'; +const DEFAULT_SMS_DEV_API_URL = 'http://localhost:4001'; const DEFAULT_SMS_FROM = 'Constructive'; function parseBoolean(value: string | undefined): boolean | undefined { @@ -57,7 +53,7 @@ function normalizePhone(params: SendSmsParams): string | null { return `${params.phone_cc ?? ''}${params.phone_number}`.replace(/[\s()-]/g, ''); } -function buildCapturePayload(params: SendSmsParams, env: SendSmsContext['env']): SmsCapturePayload { +function buildSmsDevPayload(params: SendSmsParams, env: SendSmsContext['env']): SmsDevMessagePayload { if (!params.sms_type) { throw new Error('Missing required field: sms_type'); } @@ -78,16 +74,12 @@ function buildCapturePayload(params: SendSmsParams, env: SendSmsContext['env']): return { to: phone, from: env.SMS_FROM || DEFAULT_SMS_FROM, - text: `Your verification code is ${params.code}`, - type: params.sms_type, - metadata: { - userId: params.user_id ?? null, - }, + body: `Your verification code is ${params.code}`, }; } async function sendSms(params: SendSmsParams, context: SendSmsContext) { - const payload = buildCapturePayload(params, context.env); + const payload = buildSmsDevPayload(params, context.env); const isDryRun = parseBoolean(context.env.SMS_SEND_DRY_RUN) ?? false; context.log.info('[send-sms] Processing request', { @@ -103,8 +95,8 @@ async function sendSms(params: SendSmsParams, context: SendSmsContext) { }; } - const captureApiUrl = context.env.SMS_CAPTURE_API_URL || DEFAULT_CAPTURE_API_URL; - const response = await fetch(`${captureApiUrl}/messages`, { + const smsDevApiUrl = context.env.SMS_DEV_API_URL || DEFAULT_SMS_DEV_API_URL; + const response = await fetch(`${smsDevApiUrl}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -113,7 +105,7 @@ async function sendSms(params: SendSmsParams, context: SendSmsContext) { }); if (!response.ok) { - throw new Error(`SMS capture API error: ${response.status}`); + throw new Error(`SMS dev API error: ${response.status}`); } return { From 6531637eb3cb120473715166c21acaa62cec7b45 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 9 Jul 2026 15:35:19 +0800 Subject: [PATCH 3/4] test: extend send verification e2e timeout --- tests/e2e/__tests__/send-verification-link.e2e.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/__tests__/send-verification-link.e2e.test.ts b/tests/e2e/__tests__/send-verification-link.e2e.test.ts index c62d46f41..553f1edd2 100644 --- a/tests/e2e/__tests__/send-verification-link.e2e.test.ts +++ b/tests/e2e/__tests__/send-verification-link.e2e.test.ts @@ -19,6 +19,8 @@ import { addJob, waitForJobComplete, deleteTestJobs } from '../utils/jobs'; const TEST_PREFIX = 'k8s-e2e-send-verification-link'; describe('E2E: send-verification-link', () => { + jest.setTimeout(90000); + let pg: TestClient; let databaseId: string; @@ -44,7 +46,7 @@ describe('E2E: send-verification-link', () => { expect(job.id).toBeDefined(); console.log(`Added email:send_verification_link job: ${job.id}`); - const result = await waitForJobComplete(pg, job.id, { timeout: 30000 }); + const result = await waitForJobComplete(pg, job.id, { timeout: 60000 }); console.log(`Job result: ${result.status}`, result.error || ''); From 5dab06bc73ffab6e169366318ff412297ffc3a51 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sat, 11 Jul 2026 18:20:53 +0800 Subject: [PATCH 4/4] fix: harden SMS transport and phone handling --- functions/send-sms/__tests__/handler.test.ts | 235 ++++++++++++--- functions/send-sms/handler.json | 5 +- functions/send-sms/handler.ts | 269 +++++++++++++----- pnpm-lock.yaml | 3 + .../send-verification-link.e2e.test.ts | 4 +- 5 files changed, 395 insertions(+), 121 deletions(-) diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts index a1065c746..24d56bc9c 100644 --- a/functions/send-sms/__tests__/handler.test.ts +++ b/functions/send-sms/__tests__/handler.test.ts @@ -1,33 +1,43 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; + import { createMockContext } from '../../../tests/helpers/mock-context'; +import handler, { SendSmsParams, SendSmsResult } from '../handler'; + +const configuredEnv = { + SMS_SEND_PROVIDER: 'sms-dev', + SMS_DEV_API_URL: 'http://localhost:4001', +}; describe('send-sms handler', () => { - let handler: any; - let fetchMock: jest.Mock; + const sendSmsHandler: FunctionHandler = handler; + const originalFetch = global.fetch; + let fetchMock: jest.MockedFunction; beforeEach(() => { - jest.resetModules(); - fetchMock = jest.fn().mockResolvedValue({ ok: true }); + fetchMock = jest.fn().mockResolvedValue( + new Response(null, { status: 201 }), + ); global.fetch = fetchMock; - handler = require('../handler').default; }); afterEach(() => { + global.fetch = originalFetch; jest.restoreAllMocks(); }); - it('sends sms_otp_code messages to the configured sms-dev API', async () => { - const result = await handler( + it('sends sms_otp_code messages through the configured sms-dev transport', async () => { + const result = await sendSmsHandler( { sms_type: 'sms_otp_code', - phone: '+15555550101', + phone: '+12025550101', code: '123456', }, createMockContext({ env: { - SMS_DEV_API_URL: 'http://localhost:4001', + ...configuredEnv, SMS_FROM: 'Constructive Test', }, - }) + }), ); expect(result).toEqual({ complete: true }); @@ -36,84 +46,217 @@ describe('send-sms handler', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({ - to: '+15555550101', + to: '+12025550101', from: 'Constructive Test', body: 'Your verification code is 123456', }), - }) + signal: expect.any(AbortSignal), + }), ); }); - it('sends mfa_verification_code messages with combined phone fields', async () => { - await handler( + it('uses a complete E.164 phone_number from the real DB MFA payload', async () => { + await sendSmsHandler( { sms_type: 'mfa_verification_code', - phone_cc: '+1', - phone_number: '(555) 555-0102', + phone_cc: '+', + phone_number: '+12025550102', code: '234567', - user_id: 'user-1', + user_id: '00000000-0000-0000-0000-000000000001', }, - createMockContext() + createMockContext({ env: configuredEnv }), ); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:4001/v1/messages', expect.objectContaining({ body: JSON.stringify({ - to: '+15555550102', + to: '+12025550102', from: 'Constructive', body: 'Your verification code is 234567', }), - }) + }), ); }); - it('skips capture API calls in dry-run mode', async () => { - const result = await handler( + it('combines a country code with a local formatted number', async () => { + await sendSmsHandler( { - sms_type: 'sms_otp_code', - phone: '+15555550103', + sms_type: 'mfa_verification_code', + phone_cc: ' +1 ', + phone_number: ' (202) 555-0103 ', code: '345678', }, - createMockContext({ - env: { - SMS_SEND_DRY_RUN: 'true', - }, - }) + createMockContext({ env: configuredEnv }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + body: expect.stringContaining('"to":"+12025550103"'), + }), + ); + }); + + it('normalizes whitespace, parentheses, and hyphens in a direct phone', async () => { + await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: ' +1 (202) 555-0104 ', + code: '456789', + }, + createMockContext({ env: configuredEnv }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + body: expect.stringContaining('"to":"+12025550104"'), + }), + ); + }); + + it('allows dry-run without configuring a transport', async () => { + const result = await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550105', + code: '567890', + }, + createMockContext({ env: { SMS_SEND_DRY_RUN: 'true' } }), ); expect(result).toEqual({ complete: true, dryRun: true }); expect(fetchMock).not.toHaveBeenCalled(); }); - it('throws for missing sms_type', async () => { + it('fails when the provider is not configured', async () => { await expect( - handler({ phone: '+15555550104', code: '456789' }, createMockContext()) - ).rejects.toThrow('Missing required field: sms_type'); + sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550106', + code: '678901', + }, + createMockContext({ env: { SMS_DEV_API_URL: 'http://localhost:4001' } }), + ), + ).rejects.toThrow('Missing required field: SMS_SEND_PROVIDER'); }); - it('throws for unsupported sms_type', async () => { + it('fails when the sms-dev URL is not configured', async () => { await expect( - handler( + sendSmsHandler( { - sms_type: 'sms_invite', - phone: '+15555550104', - code: '456789', + sms_type: 'sms_otp_code', + phone: '+12025550107', + code: '789012', }, - createMockContext() - ) - ).rejects.toThrow('Unsupported sms_type: sms_invite'); + createMockContext({ env: { SMS_SEND_PROVIDER: 'sms-dev' } }), + ), + ).rejects.toThrow('Missing required field: SMS_DEV_API_URL'); }); - it('throws for missing code', async () => { + it('reports non-2xx status without exposing the response body', async () => { + fetchMock.mockResolvedValueOnce( + new Response('OTP 890123 for +12025550108', { + status: 503, + statusText: 'Service Unavailable', + }), + ); + + const request = sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550108', + code: '890123', + }, + createMockContext({ env: configuredEnv }), + ); + + await expect(request).rejects.toThrow( + 'sms-dev API returned 503 Service Unavailable', + ); + await expect(request).rejects.not.toThrow('890123'); + await expect(request).rejects.not.toThrow('+12025550108'); + }); + + it('aborts a request that exceeds the configured timeout', async () => { + fetchMock.mockImplementationOnce((_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + + await expect( + sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550109', + code: '901234', + }, + createMockContext({ + env: { ...configuredEnv, SMS_SEND_TIMEOUT_MS: '5' }, + }), + ), + ).rejects.toThrow('sms-dev request timed out after 5ms'); + }); + + it.each([ + [{ sms_type: ' ', phone: '+12025550110', code: '012345' }, 'sms_type'], + [{ sms_type: 'sms_otp_code', phone: '+12025550110', code: ' ' }, 'code'], + [{ sms_type: 'sms_otp_code', phone: ' ', code: '012345' }, 'phone'], + ])('rejects whitespace-only required fields', async (params, field) => { + await expect( + sendSmsHandler(params, createMockContext({ env: configuredEnv })), + ).rejects.toThrow(`Missing required field: ${field}`); + }); + + it.each(['12345', '1234567', '12a456'])('rejects malformed OTP %s', async (code) => { await expect( - handler({ sms_type: 'sms_otp_code', phone: '+15555550104' }, createMockContext()) - ).rejects.toThrow('Missing required field: code'); + sendSmsHandler( + { sms_type: 'sms_otp_code', phone: '+12025550111', code }, + createMockContext({ env: configuredEnv }), + ), + ).rejects.toThrow('Invalid code: expected a 6-digit OTP'); }); - it('throws for missing phone', async () => { + it.each([ + { phone: '++12025550112' }, + { phone: '+02025550112' }, + { phone_cc: '+1', phone_number: '+1+2025550112' }, + { phone_cc: '+1', phone_number: 'not-a-phone' }, + ])('rejects malformed or duplicate-plus phone input', async (phoneInput) => { await expect( - handler({ sms_type: 'sms_otp_code', code: '456789' }, createMockContext()) - ).rejects.toThrow('Missing required field: phone'); + sendSmsHandler( + { + sms_type: 'sms_otp_code', + code: '123456', + ...phoneInput, + }, + createMockContext({ env: configuredEnv }), + ), + ).rejects.toThrow('Invalid phone: expected E.164 format'); + }); + + it('does not log the OTP or complete phone number', async () => { + const context = createMockContext({ env: configuredEnv }); + + await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550113', + code: '234567', + }, + context, + ); + + const logOutput = JSON.stringify( + (context.log.info as jest.Mock).mock.calls, + ); + expect(logOutput).not.toContain('234567'); + expect(logOutput).not.toContain('+12025550113'); + expect(logOutput).toContain('***0113'); }); }); diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json index fb1099c78..319ed1170 100644 --- a/functions/send-sms/handler.json +++ b/functions/send-sms/handler.json @@ -4,5 +4,8 @@ "type": "node-graphql", "port": 8092, "taskIdentifier": "sms:send_verification_code", - "description": "Sends SMS verification codes for auth flows" + "description": "Sends SMS verification codes for auth flows", + "dependencies": { + "@pgpmjs/env": "^2.15.3" + } } diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts index 2bdb7ced9..ae89d50ab 100644 --- a/functions/send-sms/handler.ts +++ b/functions/send-sms/handler.ts @@ -1,8 +1,12 @@ -import type { FunctionHandler } from '@constructive-io/fn-runtime'; +import type { + FunctionHandler, + FunctionLogger, +} from '@constructive-io/fn-runtime'; +import { parseEnvBoolean } from '@pgpmjs/env'; -type SmsType = 'sms_otp_code' | 'mfa_verification_code'; +export type SmsType = 'sms_otp_code' | 'mfa_verification_code'; -type SendSmsParams = { +export type SendSmsParams = { sms_type?: SmsType | string; phone?: string; phone_cc?: string; @@ -11,113 +15,236 @@ type SendSmsParams = { user_id?: string; }; -type SendSmsContext = { - env: Record; - log: { - info: (...args: any[]) => void; - }; +export type SendSmsResult = { + complete: true; + dryRun?: true; }; -type SmsDevMessagePayload = { +type SmsMessage = { to: string; from: string; body: string; }; -const DEFAULT_SMS_DEV_API_URL = 'http://localhost:4001'; +interface SmsSender { + send(message: SmsMessage): Promise; +} + +type SmsRuntime = { + env: Record; + log: FunctionLogger; +}; + const DEFAULT_SMS_FROM = 'Constructive'; +const DEFAULT_HTTP_TIMEOUT_MS = 5_000; +const E164_PATTERN = /^\+[1-9]\d{7,14}$/; +const OTP_PATTERN = /^\d{6}$/; -function parseBoolean(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - - switch (value.trim().toLowerCase()) { - case '1': - case 'true': - case 'yes': - case 'on': - return true; - case '0': - case 'false': - case 'no': - case 'off': - return false; - default: - return undefined; +function requiredString(value: string | undefined, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Missing required field: ${field}`); } + + return value.trim(); +} + +function normalizePhonePart(value: string): string { + return value.trim().replace(/[\s()-]/g, ''); +} + +function assertE164(phone: string): string { + if (!E164_PATTERN.test(phone)) { + throw new Error('Invalid phone: expected E.164 format'); + } + + return phone; +} + +function normalizePhone(params: SendSmsParams): string { + if (params.phone !== undefined) { + const phone = normalizePhonePart(requiredString(params.phone, 'phone')); + return assertE164(phone); + } + + const number = normalizePhonePart( + requiredString(params.phone_number, 'phone'), + ); + + // Constructive stores the complete E.164 number in phone_number. In that + // shape phone_cc may still be "+", so the complete number wins. + if (number.startsWith('+')) { + return assertE164(number); + } + + const countryPrefix = normalizePhonePart( + requiredString(params.phone_cc, 'phone_cc'), + ); + return assertE164(`${countryPrefix}${number}`); } -function normalizePhone(params: SendSmsParams): string | null { - if (params.phone) return params.phone; - if (!params.phone_number) return null; +function maskPhone(phone: string): string { + return `***${phone.slice(-4)}`; +} + +function parseTimeout(value: string | undefined): number { + if (value === undefined) return DEFAULT_HTTP_TIMEOUT_MS; + + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new Error('SMS_SEND_TIMEOUT_MS must be a positive integer'); + } - return `${params.phone_cc ?? ''}${params.phone_number}`.replace(/[\s()-]/g, ''); + return timeout; } -function buildSmsDevPayload(params: SendSmsParams, env: SendSmsContext['env']): SmsDevMessagePayload { - if (!params.sms_type) { - throw new Error('Missing required field: sms_type'); +class SmsDevSender implements SmsSender { + constructor( + private readonly apiUrl: string, + private readonly timeoutMs: number, + ) {} + + async send(message: SmsMessage): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(`${this.apiUrl}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(message), + signal: controller.signal, + }); + + if (!response.ok) { + const statusText = response.statusText + ? ` ${response.statusText}` + : ''; + throw new Error( + `sms-dev API returned ${response.status}${statusText}`, + ); + } + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `sms-dev request timed out after ${this.timeoutMs}ms`, + ); + } + + if (error instanceof Error && error.message.startsWith('sms-dev API')) { + throw error; + } + + const errorName = error instanceof Error ? error.name : 'UnknownError'; + throw new Error(`sms-dev request failed (${errorName})`); + } finally { + clearTimeout(timeout); + } } +} - if (params.sms_type !== 'sms_otp_code' && params.sms_type !== 'mfa_verification_code') { - throw new Error(`Unsupported sms_type: ${params.sms_type}`); +function createSmsSender(env: SmsRuntime['env']): { + provider: string; + sender: SmsSender; +} { + const provider = requiredString( + env.SMS_SEND_PROVIDER, + 'SMS_SEND_PROVIDER', + ).toLowerCase(); + + if (provider !== 'sms-dev') { + throw new Error(`Unsupported SMS_SEND_PROVIDER: ${provider}`); } - if (!params.code) { - throw new Error('Missing required field: code'); + const rawApiUrl = requiredString( + env.SMS_DEV_API_URL, + 'SMS_DEV_API_URL', + ); + let apiUrl: URL; + try { + apiUrl = new URL(rawApiUrl); + } catch { + throw new Error('SMS_DEV_API_URL must be a valid URL'); } - const phone = normalizePhone(params); - if (!phone) { - throw new Error('Missing required field: phone'); + if (apiUrl.protocol !== 'http:' && apiUrl.protocol !== 'https:') { + throw new Error('SMS_DEV_API_URL must use http or https'); } return { - to: phone, - from: env.SMS_FROM || DEFAULT_SMS_FROM, - body: `Your verification code is ${params.code}`, + provider, + sender: new SmsDevSender( + apiUrl.toString().replace(/\/$/, ''), + parseTimeout(env.SMS_SEND_TIMEOUT_MS), + ), }; } -async function sendSms(params: SendSmsParams, context: SendSmsContext) { - const payload = buildSmsDevPayload(params, context.env); - const isDryRun = parseBoolean(context.env.SMS_SEND_DRY_RUN) ?? false; +function buildMessage( + params: SendSmsParams, + env: SmsRuntime['env'], +): { message: SmsMessage; smsType: SmsType } { + const smsType = requiredString(params.sms_type, 'sms_type'); + if (smsType !== 'sms_otp_code' && smsType !== 'mfa_verification_code') { + throw new Error(`Unsupported sms_type: ${smsType}`); + } - context.log.info('[send-sms] Processing request', { - sms_type: params.sms_type, - to: payload.to, - dryRun: isDryRun, - }); + const code = requiredString(params.code, 'code'); + if (!OTP_PATTERN.test(code)) { + throw new Error('Invalid code: expected a 6-digit OTP'); + } + + const from = + env.SMS_FROM === undefined + ? DEFAULT_SMS_FROM + : requiredString(env.SMS_FROM, 'SMS_FROM'); + + return { + smsType, + message: { + to: normalizePhone(params), + from, + body: `Your verification code is ${code}`, + }, + }; +} + +async function sendSms( + params: SendSmsParams, + runtime: SmsRuntime, +): Promise { + const { message, smsType } = buildMessage(params, runtime.env); + const isDryRun = parseEnvBoolean(runtime.env.SMS_SEND_DRY_RUN) ?? false; if (isDryRun) { - return { - complete: true, + runtime.log.info('[send-sms] Dry run', { + sms_type: smsType, + recipient: maskPhone(message.to), dryRun: true, - }; + }); + return { complete: true, dryRun: true }; } - const smsDevApiUrl = context.env.SMS_DEV_API_URL || DEFAULT_SMS_DEV_API_URL; - const response = await fetch(`${smsDevApiUrl}/v1/messages`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(payload), + const { provider, sender } = createSmsSender(runtime.env); + runtime.log.info('[send-sms] Sending message', { + sms_type: smsType, + recipient: maskPhone(message.to), + provider, + dryRun: false, }); - if (!response.ok) { - throw new Error(`SMS dev API error: ${response.status}`); - } - - return { - complete: true, - }; + await sender.send(message); + return { complete: true }; } -const handler: FunctionHandler = async (params, context) => { - return sendSms(params, { +const handler: FunctionHandler = async ( + params, + context, +) => + sendSms(params, { env: context.env, log: context.log, }); -}; export default handler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a90eccec8..cd76f981b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@constructive-io/fn-runtime': specifier: workspace:^ version: link:../../packages/fn-runtime + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 devDependencies: '@types/node': specifier: ^22.10.4 diff --git a/tests/e2e/__tests__/send-verification-link.e2e.test.ts b/tests/e2e/__tests__/send-verification-link.e2e.test.ts index 553f1edd2..c62d46f41 100644 --- a/tests/e2e/__tests__/send-verification-link.e2e.test.ts +++ b/tests/e2e/__tests__/send-verification-link.e2e.test.ts @@ -19,8 +19,6 @@ import { addJob, waitForJobComplete, deleteTestJobs } from '../utils/jobs'; const TEST_PREFIX = 'k8s-e2e-send-verification-link'; describe('E2E: send-verification-link', () => { - jest.setTimeout(90000); - let pg: TestClient; let databaseId: string; @@ -46,7 +44,7 @@ describe('E2E: send-verification-link', () => { expect(job.id).toBeDefined(); console.log(`Added email:send_verification_link job: ${job.id}`); - const result = await waitForJobComplete(pg, job.id, { timeout: 60000 }); + const result = await waitForJobComplete(pg, job.id, { timeout: 30000 }); console.log(`Job result: ${result.status}`, result.error || '');