diff --git a/AGENTS.md b/AGENTS.md index a2cf4af1f..2438f0d2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ pnpm build # Recompile ## Key Details -- Each function declares its port in `handler.json` (`send-email` 8081, `send-verification-link` 8082, `knative-job-example` 8083, `python-example` 8084); the job service uses 8080 +- Each function declares its port in `handler.json` (`send-email` 8081, `send-verification-link` 8082, `knative-job-example` 8083, `python-example` 8084, `sql-example` 8085, `send-sms` 8086); the job service uses 8080 - Email functions support dry-run via `SEND_EMAIL_DRY_RUN` / `SEND_VERIFICATION_LINK_DRY_RUN` (legacy `SIMPLE_EMAIL_DRY_RUN` / `SEND_EMAIL_LINK_DRY_RUN` still honored as fallback) - `loadFunctionApp()` in job/service resolves modules by name (e.g. `@constructive-io/send-email-fn`) - GraphQL clients require `GRAPHQL_URL` env var and `X-Database-Id` header diff --git a/CLAUDE.md b/CLAUDE.md index 8ab94dc9d..d04d52e4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Constructive Functions -Serverless function workloads (send-email, send-verification-link) with a job queue system deployed via Kubernetes. +Serverless function workloads (send-email, send-verification-link, send-sms) with a job queue system deployed via Kubernetes. ## Project Structure @@ -93,6 +93,7 @@ Edit `functions//handler.ts` → Skaffold syncs the file into the containe | Job Service | 8080 | | send-email | 8081 | | send-verification-link | 8082 | +| send-sms | 8086 | ## Debugging K8s Pods @@ -113,6 +114,7 @@ kubectl logs -n constructive-functions -l app=knative-job-service -f # Function logs kubectl logs -n constructive-functions -l app=send-email -f kubectl logs -n constructive-functions -l app=send-verification-link -f +kubectl logs -n constructive-functions -l app=send-sms -f # Constructive server logs kubectl logs -n constructive-functions -l app=constructive-server -f @@ -135,6 +137,7 @@ kubectl port-forward -n constructive-functions svc/postgres 5432:5432 kubectl port-forward -n constructive-functions svc/knative-job-service 8080:8080 kubectl port-forward -n constructive-functions svc/send-email 8081:80 kubectl port-forward -n constructive-functions svc/send-verification-link 8082:80 +kubectl port-forward -n constructive-functions svc/send-sms 8086:80 kubectl port-forward -n constructive-functions svc/constructive-server 3002:3000 ``` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 90db15ba1..f8ebdfc72 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Development Guide -Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit). +Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit, DevSms). ## Prerequisites @@ -32,7 +32,7 @@ pnpm install # 3. Build everything (packages, job service, generated functions) pnpm build -# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit) +# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit, DevSms) make dev # 5. Wait for db-setup to finish (watch logs) @@ -60,6 +60,7 @@ After this you should have built artifacts in: |---------|--------| | `generated/send-verification-link/dist/` | Send-verification-link function server | | `generated/send-email/dist/` | Send-email function server | +| `generated/send-sms/dist/` | Send SMS verification code function server | | `generated/example/dist/` | knative-job-example function server | | `generated/python-example/dist/` | Python example function server | | `job/service/dist/` | Knative job service (worker + scheduler) | @@ -80,6 +81,7 @@ This runs `docker compose up -d` which starts: | **db-setup** | One-shot: creates DB, bootstraps roles, deploys pgpm packages | (exits on completion) | | **graphql-server** | Constructive admin GraphQL API (header-based routing) | 3002 | | **mailpit** | SMTP capture server with web UI | 1025 (SMTP), 8025 (UI) | +| **devsms** | Local SMS inbox/API for development verification codes | 4000 (API), 5153 (UI) | The `db-setup` container must finish before `graphql-server` starts (enforced by `service_completed_successfully`). Watch progress: @@ -100,6 +102,7 @@ You should see: - `db-setup` — exited (0) - `graphql-server` — running - `mailpit` — running +- `devsms` — running ### 3. Start Functions Locally @@ -114,6 +117,7 @@ This runs `scripts/dev.ts` which spawns local Node processes with env vars point | **job-service** | 8080 | `job/service/dist/run.js` | | **send-email** | 8081 | `generated/send-email/dist/index.js` | | **send-verification-link** | 8082 | `generated/send-verification-link/dist/index.js` | +| **send-sms** | 8086 | `generated/send-sms/dist/index.js` | | **knative-job-example** | 8083 | `generated/example/dist/index.js` | | **python-example** | 8084 | `generated/python-example/...` (python entrypoint) | @@ -136,6 +140,21 @@ curl -X POST http://localhost:8082 \ Check captured emails at http://localhost:8025 (Mailpit UI). +Send a request to `send-sms` and check captured SMS at http://localhost:5153 (DevSms UI): + +```bash +curl -X POST http://localhost:8086 \ + -H 'Content-Type: application/json' \ + -H 'X-Database-Id: constructive' \ + -d '{"sms_type":"sms_otp_code","phone":"+14155550123","code":"012345"}' +``` + +Query DevSms messages through its API: + +```bash +curl "http://localhost:4000/api/sms?limit=10" +``` + Query the GraphQL API directly: ```bash @@ -175,9 +194,12 @@ make dev-down # Stop Docker infrastructure | GraphQL API | 3002 | | Mailpit SMTP | 1025 | | Mailpit UI | 8025 | +| DevSms API | 4000 | +| DevSms UI | 5153 | | Job Service | 8080 | | send-email | 8081 | | send-verification-link | 8082 | +| send-sms | 8086 | | knative-job-example | 8083 | | python-example | 8084 | @@ -187,11 +209,13 @@ make dev-down # Stop Docker infrastructure Docker Compose (infrastructure): postgres -> db-setup (migrations) -> graphql-server mailpit + devsms Local Node processes (functions): job/service/dist/run.js (port 8080) - generated/send-email/dist/index.js (port 8081) - generated/send-verification-link/dist/index.js (port 8082) + generated/send-email/dist/index.js (port 8081) + generated/send-verification-link/dist/index.js (port 8082) + generated/send-sms/dist/index.js (port 8086) ``` Infrastructure runs in Docker. Functions run as local Node processes from `generated/` — no Docker rebuild needed when function code changes. Edit `functions/*/handler.ts`, rebuild (`pnpm build`), restart `make dev-fn`. @@ -252,11 +276,15 @@ make skaffold-dev This runs `skaffold dev -p local-simple` which: 1. Builds the `constructive-functions` Docker image from `Dockerfile.dev` 2. Deploys infrastructure (postgres, minio, constructive-server, constructive-server-admin, db-setup, job-service) via kustomize -3. Deploys functions (send-email, send-verification-link) via generated rawYaml manifests +3. Deploys functions (send-email, send-sms, send-verification-link) via generated rawYaml manifests 4. Sets up port-forwarding automatically 5. Watches `functions/**/*.ts` — edits are synced into running containers 6. `tsx --watch` inside each function container detects changes and restarts +Kubernetes profiles run `send-sms` with `SEND_SMS_DRY_RUN=true`; they verify +job routing and handler completion without deploying DevSms. The Hub +cross-repository E2E owns DevSms delivery and message-content assertions. + ### Option B: Knative Uses Knative Serving for functions (production parity). Requires Knative + Kourier. @@ -285,6 +313,7 @@ Changes to runtime packages (`packages/fn-runtime`, `packages/fn-app`) or `packa |---------|------------| | send-email | 8081 | | send-verification-link | 8082 | +| send-sms | 8086 | | knative-job-example | 8083 | | python-example | 8084 | | Job Service | 8080 | @@ -359,7 +388,7 @@ Stop any existing services using the ports: ```bash make dev-down -lsof -ti:5432,3002,1025,8025,8080,8081,8082 | xargs kill -9 +lsof -ti:5432,3002,4000,5153,1025,8025,8080,8081,8082,8086 | xargs kill -9 ``` **Functions can't connect to GraphQL** diff --git a/docker-compose.yml b/docker-compose.yml index 0717362c4..ef72718dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,5 +93,11 @@ services: - "1025:1025" # SMTP - "8025:8025" # Web UI + devsms: + image: ghcr.io/mrmeaow/devsms:latest + ports: + - "4000:4000" # API + - "5153:5153" # Web UI + volumes: pgdata: diff --git a/functions/python-example/handler.json b/functions/python-example/handler.json index 66b984102..46a23deb5 100644 --- a/functions/python-example/handler.json +++ b/functions/python-example/handler.json @@ -2,5 +2,6 @@ "name": "python-example", "version": "0.1.0", "description": "Example Python function", - "type": "python" + "type": "python", + "port": 8084 } diff --git a/functions/send-sms/README.md b/functions/send-sms/README.md new file mode 100644 index 000000000..c78692417 --- /dev/null +++ b/functions/send-sms/README.md @@ -0,0 +1,26 @@ +# send-sms + +Handles `sms:send_verification_code` jobs by validating the job payload, normalizing the recipient phone number to E.164, rendering the verification SMS body, and sending it through the configured SMS provider. + +Current provider support is intentionally local-only: + +- `SMS_PROVIDER=devsms` +- `DEVSMS_BASE_URL=http://localhost:4000` for Docker Compose local development +- DevSms endpoint: `POST /api/sms/send/twilio` + +All SMS configuration is loaded through `@constructive-io/graphql-env` via `getEnvOptions({}, process.cwd(), context.env)`. The handler must not read `SMS_*` or `DEVSMS_*` values directly. + +The send-sms consumer resolves defaults only after `graphql-env` has merged config files and environment overrides: + +- `SMS_REQUEST_TIMEOUT_MS` has an all-environment default of `5000`. +- `SEND_SMS_DRY_RUN` defaults to `false` in development and tests. +- Production must explicitly configure `SEND_SMS_DRY_RUN=true` or `false`. +- The provider and provider-specific settings have no global defaults. + +## Retry and idempotency + +The job worker may retry a job after a timeout or provider error. DevSms does not currently expose an idempotency key, so a retried job can create duplicate local SMS messages. The handler intentionally does not implement its own retry loop; timeout and transient failures are left to the existing job retry mechanism. + +## Logging + +Logs include job metadata, SMS type, provider, provider message ID, status, and a masked phone number only. They must not include the OTP code, full SMS body, full phone number, or provider secrets. diff --git a/functions/send-sms/__tests__/config.test.ts b/functions/send-sms/__tests__/config.test.ts new file mode 100644 index 000000000..a9f04d197 --- /dev/null +++ b/functions/send-sms/__tests__/config.test.ts @@ -0,0 +1,149 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createMockContext } from '../../../tests/helpers/mock-context'; +import handler from '../handler'; + +const payload = { + sms_type: 'sms_otp_code' as const, + phone: '+14155550123', + code: '123456', +}; + +const successfulResponse = (): Response => + new Response( + JSON.stringify({ + provider_message_id: 'SM123', + status: 'queued', + }), + { + status: 201, + headers: { 'content-type': 'application/json' }, + } + ); + +const invoke = (env: Record) => + handler(payload, createMockContext({ env }) as any); + +describe('send-sms published configuration contract', () => { + const originalCwd = process.cwd(); + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'send-sms-config-')); + process.chdir(tempDir); + global.fetch = jest + .fn() + .mockResolvedValue(successfulResponse()) as unknown as typeof fetch; + jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it.each(['development', 'test'])( + 'loads published graphql-env values and applies consumer defaults in %s', + async (nodeEnv) => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await expect( + invoke({ + NODE_ENV: nodeEnv, + SMS_PROVIDER: 'devsms', + DEVSMS_BASE_URL: 'http://devsms:4000', + }) + ).resolves.toEqual({ + complete: true, + provider: 'devsms', + messageId: 'SM123', + status: 'queued', + }); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + } + ); + + it('requires an explicit dry-run choice in production', async () => { + await expect( + invoke({ + NODE_ENV: 'production', + SMS_PROVIDER: 'twilio', + }) + ).rejects.toThrow('SEND_SMS_DRY_RUN'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('uses graphql-env boolean coercion as the configuration source of truth', async () => { + await expect( + invoke({ + NODE_ENV: 'production', + SMS_PROVIDER: 'twilio', + SEND_SMS_DRY_RUN: 'treu', + }) + ).rejects.toThrow('Unsupported SMS provider: twilio'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it.each(['not-a-number', '5s'])( + 'lets graphql-env omit malformed timeout %s before applying the fallback', + async (timeout) => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await invoke({ + NODE_ENV: 'development', + SMS_PROVIDER: 'devsms', + DEVSMS_BASE_URL: 'http://devsms:4000', + SMS_REQUEST_TIMEOUT_MS: timeout, + }); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + } + ); + + it('preserves an explicit false dry-run value in production', async () => { + await expect( + invoke({ + NODE_ENV: 'production', + SMS_PROVIDER: 'twilio', + SEND_SMS_DRY_RUN: 'false', + }) + ).rejects.toThrow('Unsupported SMS provider: twilio'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('preserves final values merged by graphql-env', async () => { + fs.writeFileSync( + path.join(tempDir, 'pgpm.json'), + JSON.stringify({ + sms: { + provider: 'devsms', + senderId: 'ConfiguredSender', + requestTimeoutMs: 3200, + dryRun: false, + devsms: { + baseUrl: 'http://configured-devsms:4000', + }, + }, + }) + ); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await invoke({ NODE_ENV: 'test' }); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3200); + expect(global.fetch).toHaveBeenCalledWith( + 'http://configured-devsms:4000/api/sms/send/twilio', + expect.objectContaining({ + body: JSON.stringify({ + From: 'ConfiguredSender', + To: '+14155550123', + Body: 'Your sign-in code is 123456. Do not share this code.', + }), + }) + ); + }); +}); diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts new file mode 100644 index 000000000..c747f4d4c --- /dev/null +++ b/functions/send-sms/__tests__/handler.test.ts @@ -0,0 +1,494 @@ +import { createMockContext } from '../../../tests/helpers/mock-context'; + +let handler: any; +const mockGetEnvOptions = jest.fn(); + +jest.mock('@constructive-io/graphql-env', () => ({ + getEnvOptions: mockGetEnvOptions, +})); + +const smsConfig = (overrides: Record = {}) => ({ + provider: 'devsms', + senderId: 'TestSender', + requestTimeoutMs: 5000, + dryRun: false, + devsms: { + baseUrl: 'http://devsms:4000', + }, + ...overrides, +}); + +const jsonResponse = (body: unknown, status = 201): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + +const mockSuccessfulFetch = ( + body: Record = { + id: 'row_123', + provider: 'twilio', + provider_message_id: 'SM123', + status: 'queued', + } +) => { + const fetchMock = jest.fn().mockResolvedValue(jsonResponse(body)); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +}; + +const otpPayload = (overrides: Record = {}) => ({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + code: '123456', + ...overrides, +}); + +describe('send-sms handler', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + mockGetEnvOptions.mockReturnValue({ sms: smsConfig() }); + mockSuccessfulFetch(); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- reload after jest.resetModules() so the env mock is applied per test. + handler = require('../handler').default; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('sends sms_otp_code payloads through DevSms', async () => { + const ctx = createMockContext({ + env: { + SMS_PROVIDER: 'devsms', + DEVSMS_BASE_URL: 'http://devsms:4000', + }, + }); + + const result = await handler( + { + sms_type: 'sms_otp_code', + phone: '+1 (415) 555-0123', + code: '123456', + }, + ctx as any + ); + + expect(result).toEqual({ + complete: true, + provider: 'devsms', + messageId: 'SM123', + status: 'queued', + }); + expect(mockGetEnvOptions).toHaveBeenCalledWith( + {}, + expect.any(String), + ctx.env + ); + expect(global.fetch).toHaveBeenCalledWith( + 'http://devsms:4000/api/sms/send/twilio', + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + From: 'TestSender', + To: '+14155550123', + Body: 'Your sign-in code is 123456. Do not share this code.', + }), + }) + ); + }); + + it('sends mfa_verification_code payloads through DevSms', async () => { + const result = await handler( + { + sms_type: 'mfa_verification_code', + user_id: 'user-1', + phone_cc: '44', + phone_number: '020 7946 0018', + code: '654321', + }, + createMockContext() as any + ); + + expect(result).toEqual({ + complete: true, + provider: 'devsms', + messageId: 'SM123', + status: 'queued', + }); + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + From: 'TestSender', + To: '+442079460018', + Body: 'Your verification code is 654321. Do not share this code.', + }), + }) + ); + }); + + it('normalizes another country code with a leading national zero', async () => { + await handler( + { + sms_type: 'mfa_verification_code', + user_id: 'user-1', + phone_cc: '81', + phone_number: '03-1234-5678', + code: '654321', + }, + createMockContext() as any + ); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"To":"+81312345678"'), + }) + ); + }); + + it('preserves a six-digit code with leading zero', async () => { + await handler(otpPayload({ code: '012345' }), createMockContext() as any); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('012345'), + }) + ); + }); + + it('throws for unknown sms_type', async () => { + await expect( + handler( + { + sms_type: 'unknown', + phone: '+14155550123', + code: '123456', + }, + createMockContext() as any + ) + ).rejects.toThrow('Unsupported sms_type: unknown'); + }); + + it.each([ + [undefined, 'missing'], + ['12345', 'too short'], + ['1234567', 'too long'], + ['12345a', 'non-numeric'], + [123456, 'not a string'], + ])('rejects an invalid verification code (%s: %s)', async (code) => { + await expect( + handler(otpPayload({ code }), createMockContext() as any) + ).rejects.toThrow('code must be a six-digit string'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it.each([ + [otpPayload({ phone: '' }), 'Missing required field: phone'], + [otpPayload({ phone: 'not-a-phone' }), 'Invalid phone number'], + [ + { + sms_type: 'mfa_verification_code', + user_id: 'user-1', + phone_cc: '1', + phone_number: '', + code: '123456', + }, + 'Missing required field: phone_number', + ], + [ + { + sms_type: 'mfa_verification_code', + user_id: 'user-1', + phone_cc: '1', + phone_number: '415-555-ABCD', + code: '123456', + }, + 'Invalid phone number', + ], + ])('rejects invalid phone input', async (payload, expectedError) => { + await expect(handler(payload, createMockContext() as any)).rejects.toThrow( + expectedError + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it.each([ + ['no sms configuration', {}], + ['an empty sms configuration', { sms: {} }], + ])( + 'defaults dry-run to false and requires a provider with %s', + async (_case, config) => { + mockGetEnvOptions.mockReturnValue(config); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow('Missing SMS provider configuration'); + expect(global.fetch).not.toHaveBeenCalled(); + } + ); + + it('applies consumer defaults when timeout and dry-run are not configured', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: { + provider: 'devsms', + senderId: 'TestSender', + devsms: { + baseUrl: 'http://devsms:4000', + }, + }, + }); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await handler(otpPayload(), createMockContext() as any); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + }); + + it('preserves a configured request timeout after graphql-env merging', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ requestTimeoutMs: 1234 }), + }); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await handler(otpPayload(), createMockContext() as any); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); + }); + + it('rejects non-positive request timeouts', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ requestTimeoutMs: 0 }), + }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow( + 'options.sms.requestTimeoutMs must be a positive integer' + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('requires an explicit dry-run choice in production', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: { + provider: 'twilio', + requestTimeoutMs: 5000, + }, + }); + const ctx = createMockContext({ env: { NODE_ENV: 'production' } }); + + await expect(handler(otpPayload(), ctx as any)).rejects.toThrow( + 'SEND_SMS_DRY_RUN' + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('accepts an explicit production dry-run without provider configuration', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: { + requestTimeoutMs: 5000, + dryRun: true, + }, + }); + const ctx = createMockContext({ env: { NODE_ENV: 'production' } }); + + await expect(handler(otpPayload(), ctx as any)).resolves.toEqual({ + complete: true, + dryRun: true, + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('rejects DevSms in production', async () => { + const ctx = createMockContext({ env: { NODE_ENV: 'production' } }); + + await expect(handler(otpPayload(), ctx as any)).rejects.toThrow( + 'SMS_PROVIDER=devsms is not allowed' + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('preserves production DevSms validation before the dry-run branch', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ dryRun: true }), + }); + const ctx = createMockContext({ env: { NODE_ENV: 'production' } }); + + await expect(handler(otpPayload(), ctx as any)).rejects.toThrow( + 'SMS_PROVIDER=devsms is not allowed' + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('dry-run validates and renders without sending', async () => { + mockGetEnvOptions.mockReturnValue({ sms: { dryRun: true } }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).resolves.toEqual({ + complete: true, + dryRun: true, + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it.each(['twilio', 'sns', 'custom'])( + 'rejects unsupported provider %s', + async (provider) => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ provider }), + }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow(`Unsupported SMS provider: ${provider}`); + expect(global.fetch).not.toHaveBeenCalled(); + } + ); + + it('requires a DevSms base URL', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ devsms: undefined }), + }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow('Missing DevSms base URL configuration'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('omits From when no sender ID is configured', async () => { + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ senderId: undefined }), + }); + + await handler(otpPayload(), createMockContext() as any); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + To: '+14155550123', + Body: 'Your sign-in code is 123456. Do not share this code.', + }), + }) + ); + }); + + it.each([ + ['provider_message_id', { provider_message_id: 'provider-message-id' }], + ['providerMessageId', { providerMessageId: 'provider-message-id' }], + ['sid', { sid: 'provider-message-id' }], + ['id', { id: 'provider-message-id' }], + ])('accepts the %s message ID response field', async (_field, response) => { + mockSuccessfulFetch(response); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).resolves.toEqual({ + complete: true, + provider: 'devsms', + messageId: 'provider-message-id', + status: 'unknown', + }); + }); + + it.each([ + ['queued', 'queued'], + ['sent', 'sent'], + ['delivered', 'delivered'], + ['failed', 'unknown'], + [undefined, 'unknown'], + ])('normalizes provider status %s to %s', async (status, expected) => { + mockSuccessfulFetch({ + provider_message_id: 'SM123', + status, + }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).resolves.toEqual(expect.objectContaining({ status: expected })); + }); + + it('times out DevSms requests', async () => { + jest.useFakeTimers(); + mockGetEnvOptions.mockReturnValue({ + sms: smsConfig({ requestTimeoutMs: 10 }), + }); + global.fetch = jest.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }); + }) + ) as unknown as typeof fetch; + + const promise = handler(otpPayload(), createMockContext() as any); + jest.advanceTimersByTime(10); + + await expect(promise).rejects.toThrow( + 'DevSmsProvider timed out after 10ms' + ); + }); + + it('rejects non-2xx responses without exposing the response body', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + jsonResponse({ error: 'OTP 123456 failed for +14155550123' }, 400) + ) as unknown as typeof fetch; + + const error = await handler(otpPayload(), createMockContext() as any).then( + () => undefined, + (cause: unknown) => cause as Error + ); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe('DevSmsProvider request failed with 400'); + expect(error?.message).not.toContain('123456'); + expect(error?.message).not.toContain('+14155550123'); + }); + + it('rejects invalid JSON responses', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + new Response('not-json', { status: 201 }) + ) as unknown as typeof fetch; + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow('DevSmsProvider returned invalid JSON'); + }); + + it('rejects responses without a message ID', async () => { + mockSuccessfulFetch({ status: 'queued' }); + + await expect( + handler(otpPayload(), createMockContext() as any) + ).rejects.toThrow('DevSmsProvider response missing message ID'); + }); + + it('does not log code, body, or full phone number', async () => { + mockGetEnvOptions.mockReturnValue({ sms: { dryRun: true } }); + const ctx = createMockContext(); + + await handler(otpPayload({ code: '012345' }), ctx as any); + + const logOutput = JSON.stringify((ctx.log.info as jest.Mock).mock.calls); + expect(logOutput).not.toContain('012345'); + expect(logOutput).not.toContain('Your sign-in code'); + expect(logOutput).not.toContain('+14155550123'); + expect(logOutput).toContain('+1415***0123'); + }); +}); diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json new file mode 100644 index 000000000..e22517473 --- /dev/null +++ b/functions/send-sms/handler.json @@ -0,0 +1,13 @@ +{ + "name": "send-sms", + "version": "1.0.0", + "type": "node-graphql", + "port": 8086, + "taskIdentifier": "sms:send_verification_code", + "description": "Sends SMS verification codes from job payloads", + "dependencies": { + "@constructive-io/graphql-env": "^3.20.0", + "12factor-env": "^1.18.2", + "libphonenumber-js": "^1.13.8" + } +} diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts new file mode 100644 index 000000000..d0f07db12 --- /dev/null +++ b/functions/send-sms/handler.ts @@ -0,0 +1,427 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; +import { getEnvOptions, type SmsOptions } from '@constructive-io/graphql-env'; +import { + bool, + devDefault, + env as validateEnv, + makeValidator, + withDefault, +} from '12factor-env'; +import type { CountryCode } from 'libphonenumber-js'; +import { + getCountries, + getCountryCallingCode, + parsePhoneNumberFromString, +} from 'libphonenumber-js'; + +type SendSmsParams = + | { + sms_type: 'sms_otp_code'; + phone: string; + code: string; + } + | { + sms_type: 'mfa_verification_code'; + user_id: string; + phone_cc: string; + phone_number: string; + code: string; + }; + +type SmsType = SendSmsParams['sms_type']; + +type NormalizedSmsJob = { + smsType: SmsType; + to: string; + code: string; +}; + +type ResolvedSmsOptions = Omit & { + requestTimeoutMs: number; + dryRun: boolean; +}; + +type SmsSendStatus = 'queued' | 'sent' | 'delivered' | 'unknown'; + +type DevSmsResponse = { + id?: string; + provider_message_id?: string; + providerMessageId?: string; + sid?: string; + status?: string; +}; + +type DevSmsResult = { + provider: 'devsms'; + messageId: string; + status: SmsSendStatus; +}; + +const DEFAULT_SMS_REQUEST_TIMEOUT_MS = 5000; +const DEVSMS_TWILIO_SEND_PATH = '/api/sms/send/twilio'; + +const SMS_TEMPLATES: Record = { + sms_otp_code: 'Your sign-in code is {code}. Do not share this code.', + mfa_verification_code: + 'Your verification code is {code}. Do not share this code.', +}; + +const positiveInteger = makeValidator((value: string) => { + const raw = value as unknown; + if (typeof raw === 'number' && Number.isSafeInteger(raw) && raw > 0) { + return raw; + } + + const normalized = String(raw).trim(); + if (!/^[1-9]\d*$/.test(normalized)) { + throw new Error('options.sms.requestTimeoutMs must be a positive integer'); + } + + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed)) { + throw new Error('options.sms.requestTimeoutMs must be a positive integer'); + } + return parsed; +}); + +const toEnvString = ( + value: number | boolean | undefined +): string | undefined => (value === undefined ? undefined : String(value)); + +const resolveSmsOptions = ( + options: SmsOptions, + inputEnv: Record +): ResolvedSmsOptions => { + const resolved = validateEnv( + { + NODE_ENV: inputEnv.NODE_ENV, + GITHUB_ACTIONS: inputEnv.GITHUB_ACTIONS, + SMS_REQUEST_TIMEOUT_MS: toEnvString(options.requestTimeoutMs), + SEND_SMS_DRY_RUN: toEnvString(options.dryRun), + }, + {}, + { + SMS_REQUEST_TIMEOUT_MS: withDefault( + positiveInteger, + DEFAULT_SMS_REQUEST_TIMEOUT_MS + ), + // Local and test environments send to explicitly configured development + // providers by default. Production must explicitly choose true or false. + SEND_SMS_DRY_RUN: devDefault(bool, false), + } + ); + + return { + ...options, + requestTimeoutMs: resolved.SMS_REQUEST_TIMEOUT_MS, + dryRun: resolved.SEND_SMS_DRY_RUN, + }; +}; + +const loadSmsOptions = ( + inputEnv: Record +): ResolvedSmsOptions => { + // Apply consumer defaults only after graphql-env has merged pgpm.json, + // environment variables, and runtime overrides. + const options = getEnvOptions({}, process.cwd(), inputEnv); + return resolveSmsOptions(options.sms ?? {}, inputEnv); +}; + +const hasInvalidPhoneCharacters = (value: string): boolean => + /[^\d+().\s-]/.test(value); + +const normalizeCountryCallingCode = (phoneCc: unknown): string => { + if (typeof phoneCc !== 'string' || phoneCc.trim().length === 0) { + throw new Error('Missing required field: phone_cc'); + } + + const digits = phoneCc.replace(/\D/g, ''); + if (!/^[1-9]\d{0,2}$/.test(digits)) { + throw new Error('Invalid phone country code'); + } + return digits; +}; + +const countryForCallingCode = (callingCode: string): CountryCode | undefined => + getCountries().find( + (country) => getCountryCallingCode(country) === callingCode + ); + +const ensureValidE164 = (value: string): string => { + if (!/^\+[1-9]\d{7,14}$/.test(value)) { + throw new Error('Invalid phone number'); + } + return value; +}; + +const normalizePhone = (phone: unknown): string => { + if (typeof phone !== 'string' || phone.trim().length === 0) { + throw new Error('Missing required field: phone'); + } + if (hasInvalidPhoneCharacters(phone)) { + throw new Error('Invalid phone number'); + } + + const parsed = parsePhoneNumberFromString(phone); + if (parsed?.isValid()) { + return parsed.number; + } + + const compact = phone.replace(/[().\s-]/g, ''); + return ensureValidE164(compact); +}; + +const normalizePhoneParts = ( + phoneCc: unknown, + phoneNumber: unknown +): string => { + const callingCode = normalizeCountryCallingCode(phoneCc); + if (typeof phoneNumber !== 'string' || phoneNumber.trim().length === 0) { + throw new Error('Missing required field: phone_number'); + } + if (hasInvalidPhoneCharacters(phoneNumber) || phoneNumber.includes('+')) { + throw new Error('Invalid phone number'); + } + + const country = countryForCallingCode(callingCode); + if (country) { + const parsedNational = parsePhoneNumberFromString(phoneNumber, country); + if ( + parsedNational?.isValid() && + parsedNational.countryCallingCode === callingCode + ) { + return parsedNational.number; + } + } + + const nationalDigits = phoneNumber.replace(/\D/g, ''); + const candidates = [nationalDigits, nationalDigits.replace(/^0+/, '')].filter( + (candidate, index, all) => candidate && all.indexOf(candidate) === index + ); + + for (const candidate of candidates) { + const parsed = parsePhoneNumberFromString(`+${callingCode}${candidate}`); + if (parsed?.isValid()) { + return parsed.number; + } + } + + return ensureValidE164(`+${callingCode}${candidates[0] ?? ''}`); +}; + +const maskPhone = (phone: string): string => { + if (phone.length <= 8) { + return `${phone.slice(0, 2)}***${phone.slice(-2)}`; + } + return `${phone.slice(0, 5)}***${phone.slice(-4)}`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const requireSixDigitCode = (value: unknown): string => { + if (typeof value !== 'string' || !/^\d{6}$/.test(value)) { + throw new Error( + 'Invalid SMS verification code: code must be a six-digit string' + ); + } + return value; +}; + +const normalizePayload = (params: unknown): NormalizedSmsJob => { + if (!isRecord(params)) { + throw new Error('Invalid send-sms payload'); + } + + switch (params.sms_type) { + case 'sms_otp_code': + return { + smsType: 'sms_otp_code', + to: normalizePhone(params.phone), + code: requireSixDigitCode(params.code), + }; + case 'mfa_verification_code': + return { + smsType: 'mfa_verification_code', + to: normalizePhoneParts(params.phone_cc, params.phone_number), + code: requireSixDigitCode(params.code), + }; + default: + throw new Error(`Unsupported sms_type: ${String(params.sms_type)}`); + } +}; + +const renderSmsBody = (smsType: SmsType, code: string): string => + SMS_TEMPLATES[smsType].replace('{code}', code); + +const normalizeBaseUrl = (baseUrl: string): string => { + const trimmed = baseUrl.trim(); + if (!trimmed) { + throw new Error('DevSmsProvider requires sms.devsms.baseUrl'); + } + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; +}; + +const normalizeStatus = (status?: string): SmsSendStatus => { + if (status === 'queued' || status === 'sent' || status === 'delivered') { + return status; + } + return 'unknown'; +}; + +const getMessageId = (body: DevSmsResponse): string | undefined => + body.provider_message_id ?? body.providerMessageId ?? body.sid ?? body.id; + +const sendDevSms = async (options: { + baseUrl: string; + requestTimeoutMs: number; + to: string; + body: string; + senderId?: string; +}): Promise => { + const baseUrl = normalizeBaseUrl(options.baseUrl); + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.requestTimeoutMs + ); + + let response: Response; + try { + response = await fetch(`${baseUrl}${DEVSMS_TWILIO_SEND_PATH}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + ...(options.senderId && { From: options.senderId }), + To: options.to, + Body: options.body, + }), + signal: controller.signal, + }); + } catch (err) { + const error = err as Error; + if (error.name === 'AbortError') { + throw new Error( + `DevSmsProvider timed out after ${options.requestTimeoutMs}ms` + ); + } + throw new Error(`DevSmsProvider request failed: ${error.message}`); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + // Provider responses may echo the request body, OTP, or recipient. + // Keep failures safe for job and CI logs by reporting status only. + throw new Error(`DevSmsProvider request failed with ${response.status}`); + } + + let responseBody: DevSmsResponse; + try { + responseBody = (await response.json()) as DevSmsResponse; + } catch { + throw new Error('DevSmsProvider returned invalid JSON'); + } + + const messageId = getMessageId(responseBody); + if (!messageId) { + throw new Error('DevSmsProvider response missing message ID'); + } + + return { + provider: 'devsms', + messageId, + status: normalizeStatus(responseBody.status), + }; +}; + +const handler: FunctionHandler = async (params, context) => { + const normalized = normalizePayload(params); + const options = loadSmsOptions(context.env); + const jobId = context.job?.jobId; + const databaseId = context.job?.databaseId; + const maskedPhone = maskPhone(normalized.to); + const providerName = options.provider; + + // Preserve the existing policy order: production DevSms is rejected even + // when SEND_SMS_DRY_RUN=true. + if (context.env.NODE_ENV === 'production' && providerName === 'devsms') { + throw new Error( + 'SMS_PROVIDER=devsms is not allowed when NODE_ENV=production' + ); + } + + context.log.info('[send-sms] Processing request', { + jobId, + databaseId, + smsType: normalized.smsType, + maskedPhone, + provider: providerName, + }); + + const body = renderSmsBody(normalized.smsType, normalized.code); + + if (options.dryRun) { + context.log.info('[send-sms] Dry run complete; provider send skipped', { + jobId, + databaseId, + smsType: normalized.smsType, + maskedPhone, + provider: providerName, + dryRun: true, + }); + return { + complete: true, + dryRun: true, + }; + } + + if (!providerName) { + throw new Error('Missing SMS provider configuration: options.sms.provider'); + } + + let result: DevSmsResult; + switch (providerName) { + case 'devsms': { + const baseUrl = options.devsms?.baseUrl; + if (!baseUrl) { + throw new Error( + 'Missing DevSms base URL configuration: options.sms.devsms.baseUrl' + ); + } + result = await sendDevSms({ + baseUrl, + requestTimeoutMs: options.requestTimeoutMs, + to: normalized.to, + body, + senderId: options.senderId, + }); + break; + } + case 'twilio': + case 'sns': + default: + throw new Error(`Unsupported SMS provider: ${String(providerName)}`); + } + + context.log.info('[send-sms] Provider accepted message', { + jobId, + databaseId, + smsType: normalized.smsType, + maskedPhone, + provider: result.provider, + providerMessageId: result.messageId, + status: result.status, + }); + + return { + complete: true, + provider: result.provider, + messageId: result.messageId, + status: result.status, + }; +}; + +export default handler; diff --git a/functions/sql-example/handler.json b/functions/sql-example/handler.json index 1717196fa..d7c7788aa 100644 --- a/functions/sql-example/handler.json +++ b/functions/sql-example/handler.json @@ -2,5 +2,6 @@ "name": "sql-example", "version": "1.0.0", "type": "node-sql", + "port": 8085, "description": "Example function using node-sql template for direct PostgreSQL access" } diff --git a/jest.config.ts b/jest.config.ts index da557974d..c3a8ea324 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,6 @@ const config: Config = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], modulePathIgnorePatterns: ['dist/', 'generated/'], moduleNameMapper: { - '^@pgpmjs/env$': '/tests/__mocks__/@pgpmjs/env', '^@pgpmjs/logger$': '/tests/__mocks__/@pgpmjs/logger', '^@constructive-io/postmaster$': '/tests/__mocks__/@constructive-io/postmaster', diff --git a/job/service/package.json b/job/service/package.json index 888935b44..ee93f1368 100644 --- a/job/service/package.json +++ b/job/service/package.json @@ -23,6 +23,7 @@ "@constructive-io/knative-job-server": "workspace:^", "@constructive-io/knative-job-worker": "workspace:^", "@constructive-io/send-email-fn": "workspace:^", + "@constructive-io/send-sms-fn": "workspace:^", "@constructive-io/send-verification-link-fn": "workspace:^", "@pgpmjs/env": "^2.15.3", "@pgpmjs/logger": "^2.4.3", diff --git a/k8s/base/functions/send-sms.yaml b/k8s/base/functions/send-sms.yaml new file mode 100644 index 000000000..94df8282d --- /dev/null +++ b/k8s/base/functions/send-sms.yaml @@ -0,0 +1,70 @@ +# This manifest is intentionally consumed only by the local Knative overlay, +# which patches the function into development dry-run mode. Do not add it to +# k8s/base/kustomization.yaml until a production SMS provider is available. +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: send-sms + labels: + app.kubernetes.io/name: send-sms + app.kubernetes.io/component: function + app.kubernetes.io/part-of: constructive-jobs + networking.knative.dev/visibility: cluster-local +spec: + template: + metadata: + labels: + app.kubernetes.io/name: send-sms + app.kubernetes.io/component: function + app.kubernetes.io/part-of: constructive-jobs + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "10" + autoscaling.knative.dev/target: "50" + serving.knative.dev/timeout: "300s" + run.googleapis.com/cpu-throttling: "false" + spec: + containerConcurrency: 10 + timeoutSeconds: 300 + + containers: + - name: function + image: ghcr.io/constructive-io/constructive:e0b55cc + imagePullPolicy: Always + + command: ["node"] + args: ["functions/send-sms/dist/index.js"] + + ports: + - containerPort: 8080 + protocol: TCP + + env: + - name: NODE_ENV + value: "production" + - name: LOG_LEVEL + value: "debug" + - name: LOG_TIMESTAMP + value: "true" + - name: SMS_REQUEST_TIMEOUT_MS + value: "5000" + + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + + volumeMounts: + - name: tmp + mountPath: /tmp + + volumes: + - name: tmp + emptyDir: {} + + traffic: + - percent: 100 + latestRevision: true diff --git a/k8s/overlays/local-simple/config.yaml b/k8s/overlays/local-simple/config.yaml index c54f2c04c..58bb0b057 100644 --- a/k8s/overlays/local-simple/config.yaml +++ b/k8s/overlays/local-simple/config.yaml @@ -23,4 +23,5 @@ data: LOG_TIMESTAMP: "true" SEND_EMAIL_DRY_RUN: "true" + SEND_SMS_DRY_RUN: "true" SEND_VERIFICATION_LINK_DRY_RUN: "true" diff --git a/k8s/overlays/local/constructive/knative-job-service.yaml b/k8s/overlays/local/constructive/knative-job-service.yaml index 7dcbc4c20..ae82d6f6a 100644 --- a/k8s/overlays/local/constructive/knative-job-service.yaml +++ b/k8s/overlays/local/constructive/knative-job-service.yaml @@ -10,6 +10,8 @@ spec: env: - name: NODE_ENV value: "development" + - name: JOBS_SUPPORTED + value: "email:send_email,email:send_verification_link,sms:send_verification_code" - name: KNATIVE_SERVICE_URL value: "constructive-functions.svc.cluster.local" - name: INTERNAL_JOBS_CALLBACK_URL @@ -21,7 +23,7 @@ spec: - name: INTERNAL_GATEWAY_URL value: "http://send-verification-link.constructive-functions.svc.cluster.local" - name: INTERNAL_GATEWAY_DEVELOPMENT_MAP - value: '{"email:send_email":"http://send-email.constructive-functions.svc.cluster.local","email:send_verification_link":"http://send-verification-link.constructive-functions.svc.cluster.local"}' + value: '{"email:send_email":"http://send-email.constructive-functions.svc.cluster.local","email:send_verification_link":"http://send-verification-link.constructive-functions.svc.cluster.local","sms:send_verification_code":"http://send-sms.constructive-functions.svc.cluster.local"}' resources: requests: cpu: "50m" diff --git a/k8s/overlays/local/kustomization.yaml b/k8s/overlays/local/kustomization.yaml index 35f58afee..3a132fc06 100644 --- a/k8s/overlays/local/kustomization.yaml +++ b/k8s/overlays/local/kustomization.yaml @@ -26,6 +26,7 @@ resources: # Functions - ../../base/functions/send-email.yaml - ../../base/functions/send-verification-link.yaml + - ../../base/functions/send-sms.yaml patches: # Skaffold image replacement: only rewrite function images, not server/dashboard/db-job @@ -47,6 +48,15 @@ patches: version: v1 kind: Service name: send-verification-link + - patch: |- + - op: replace + path: /spec/template/spec/containers/0/image + value: constructive-functions:local + target: + group: serving.knative.dev + version: v1 + kind: Service + name: send-sms - patch: |- - op: replace path: /spec/template/spec/containers/0/image @@ -86,3 +96,17 @@ patches: version: v1 kind: Service name: send-verification-link + - patch: |- + - op: replace + path: /spec/template/spec/containers/0/env/0/value + value: "development" + - op: add + path: /spec/template/spec/containers/0/env/- + value: + name: SEND_SMS_DRY_RUN + value: "true" + target: + group: serving.knative.dev + version: v1 + kind: Service + name: send-sms diff --git a/package.json b/package.json index 9d3b522a3..d8310be27 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "eslint-plugin-unused-imports": "^4.0.0", "globals": "^16.5.0", "jest": "^30.2.0", + "libphonenumber-js": "1.13.8", "prettier": "^3.7.4", "ts-jest": "^29.4.0", "tsx": "^4.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dcd03e50..f5f473ea6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: jest: specifier: ^30.2.0 version: 30.2.0(@types/node@22.19.3) + libphonenumber-js: + specifier: 1.13.8 + version: 1.13.8 pg: specifier: ^8.20.0 version: 8.20.0 @@ -101,6 +104,31 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-sms: + dependencies: + 12factor-env: + specifier: ^1.18.2 + version: 1.18.2 + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/graphql-env': + specifier: ^3.20.0 + version: 3.20.0 + libphonenumber-js: + specifier: ^1.13.8 + version: 1.13.8 + 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': @@ -230,6 +258,9 @@ importers: '@constructive-io/send-email-fn': specifier: workspace:^ version: link:../../generated/send-email + '@constructive-io/send-sms-fn': + specifier: workspace:^ + version: link:../../generated/send-sms '@constructive-io/send-verification-link-fn': specifier: workspace:^ version: link:../../generated/send-verification-link @@ -439,8 +470,11 @@ importers: packages: - 12factor-env@1.6.2: - resolution: {integrity: sha512-U4EO6sy9Cc6h1ST3hhLD2rc2s4LERxProove3XZ52rMq2rTo5uTKWNKwD2OYDUwqNij+p5SgjmpPO6L/Gqtizw==} + 12factor-env@1.17.1: + resolution: {integrity: sha512-IBDbA96dDGAkfzsSvZuveDhKIKQfFG+4lZ6KH77zd3GmqIJo+5lyXeoXL3MsRUSNKZ0ZqBr0M4oSoPrgfjIHJA==} + + 12factor-env@1.18.2: + resolution: {integrity: sha512-lW+ytI7TxOW2c7n6U2xr2yEENtj/npHffM8nPUpdxmORhs69asLTb9X469AY9h41HOdulZbKL1cDTxeHUkEX6w==} '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} @@ -619,6 +653,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@constructive-io/graphql-env@3.20.0': + resolution: {integrity: sha512-gUQbLligxQEFxusUk/yiSkm6M06KYsiHKIdQ4cYgPwGkWoug2U+X7UL+JuqZSUordl7xFINY4pyWPwlOVEM47Q==} + + '@constructive-io/graphql-types@3.19.0': + resolution: {integrity: sha512-Gt47e8AHlBdx2r3xbBUUtBtpXqPl9KAWHX+9npQZR+4m/5fIR29k+w7zz4ZqAF6tSpmZjM1umyrN1La2foJMyA==} + '@constructive-io/job-pg@2.5.4': resolution: {integrity: sha512-cjJxL/P1g4s07PiXiw31jb7c56aQmdPur+WyQXC0lAig1DIFSeJqbQjY6PXaFQepxgivwIBf9P9v+EalQaOtPQ==} @@ -1076,6 +1116,9 @@ packages: '@pgpmjs/env@2.17.0': resolution: {integrity: sha512-3WPwJ4prFWGGIRzyR52/JG84hM+Qe6lVtQ+bcCpGnGuhukFowALpaegRZxi3LT/pO6D8wW1Y3nW9LugfJLO6KQ==} + '@pgpmjs/env@2.30.2': + resolution: {integrity: sha512-ZxQH/Ck4zYt5sIK+qRbmlv++6J38wRcFgw927kFuf01UZwzYa7Cb7KMM0nDNNRaLQ/l/tVNLsAZXD3FNCFfN5g==} + '@pgpmjs/logger@1.5.0': resolution: {integrity: sha512-R27o5MiOsezI5rAWdJyuOkWUK6zxr8Mg61hPs7uCu//sECoprR4/7CVeFIHwn7+gyrjUk0wBz0dQcJhjYzVDpw==} @@ -1091,6 +1134,9 @@ packages: '@pgpmjs/types@2.28.0': resolution: {integrity: sha512-XYCcWnxkIrZEHF2oxxtU1yMeMa4bfw5za5CsDnMx0uasdtG0Y5YwDqruuv0uzYdz0id927LMb6svE38vrmPTIg==} + '@pgpmjs/types@2.37.2': + resolution: {integrity: sha512-QscdUwDcqG4HfeTTDBWUPIyvTQSIdhDL36IPQDDyJJ+5N95ytga1nfBmAPfyhvTZOdXHOpno5WR6cirBjt7/0Q==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1345,41 +1391,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2164,7 +2218,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -2181,6 +2235,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphile-config@1.0.1: + resolution: {integrity: sha512-sVdSWNmetW/WZKVQ0Dii2kCu0Le6X6qwuBRecWg575iOMjbZgxo+b4oVeSOTtR6NTKuLsMYQBkzSaeoAKOPB+A==} + engines: {node: '>=22'} + graphql-request@7.4.0: resolution: {integrity: sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==} peerDependencies: @@ -2295,6 +2353,10 @@ packages: inquirerer@4.8.1: resolution: {integrity: sha512-X8cPy91JMH6EmUPUqgnxc+oYssHdQlitWR23youH2208F2enxElCKc6Mt/5H8KAupYDgOuRuyBO+SRaRXStj8A==} + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2685,6 +2747,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.8: + resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3093,6 +3158,9 @@ packages: pg-env@1.15.0: resolution: {integrity: sha512-1bs3pcNOOrA0om3TNJGwbZu7JXKc/tenyVW4KX6ljARxvKtRSUcGl6sbpKVCXJo+Y98W0nRvPgfa/SlqlumRsg==} + pg-env@1.19.2: + resolution: {integrity: sha512-gNjwaKoNCS1zk0qF1Nb6EwhuW6R7kpQQo6IBqw/gz5rSPy7/vpYYz5HfdW4/vwEwd+/yRTkizBToI46wo5aweA==} + pg-env@1.8.2: resolution: {integrity: sha512-YzxNQKZmFRRJKX5t149Ys2JoAsc6OCHcaoYH/82si7gwVC9ODaFTFtQn7gv3VpoGsNkH90t6iEPWvmLIgv2rDg==} @@ -3721,7 +3789,11 @@ packages: snapshots: - 12factor-env@1.6.2: + 12factor-env@1.17.1: + dependencies: + envalid: 8.1.1 + + 12factor-env@1.18.2: dependencies: envalid: 8.1.1 @@ -3922,6 +3994,24 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@constructive-io/graphql-env@3.20.0': + dependencies: + 12factor-env: 1.18.2 + '@constructive-io/graphql-types': 3.19.0 + '@pgpmjs/env': 2.30.2 + deepmerge: 4.3.1 + transitivePeerDependencies: + - supports-color + + '@constructive-io/graphql-types@3.19.0': + dependencies: + '@pgpmjs/types': 2.37.2 + deepmerge: 4.3.1 + graphile-config: 1.0.1 + pg-env: 1.19.2 + transitivePeerDependencies: + - supports-color + '@constructive-io/job-pg@2.5.4': dependencies: '@constructive-io/job-utils': 2.5.4 @@ -3951,7 +4041,7 @@ snapshots: '@constructive-io/postmaster@1.6.2': dependencies: - 12factor-env: 1.6.2 + 12factor-env: 1.17.1 form-data: 4.0.5 mailgun.js: 10.4.0 transitivePeerDependencies: @@ -4550,6 +4640,12 @@ snapshots: '@pgpmjs/types': 2.21.0 deepmerge: 4.3.1 + '@pgpmjs/env@2.30.2': + dependencies: + 12factor-env: 1.18.2 + '@pgpmjs/types': 2.37.2 + deepmerge: 4.3.1 + '@pgpmjs/logger@1.5.0': dependencies: yanse: 0.2.1 @@ -4570,6 +4666,10 @@ snapshots: dependencies: pg-env: 1.15.0 + '@pgpmjs/types@2.37.2': + dependencies: + pg-env: 1.19.2 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5825,6 +5925,17 @@ snapshots: graceful-fs@4.2.11: {} + graphile-config@1.0.1: + dependencies: + chalk: 4.1.2 + debug: 4.4.3(supports-color@5.5.0) + interpret: 3.1.1 + semver: 7.7.3 + tslib: 2.8.1 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + graphql-request@7.4.0(graphql@16.13.0): dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.0) @@ -5951,6 +6062,8 @@ snapshots: minimist: 1.2.8 yanse: 0.2.1 + interpret@3.1.1: {} + ipaddr.js@1.9.1: {} is-arrayish@0.2.1: {} @@ -6715,6 +6828,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.8: {} + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -7280,6 +7395,10 @@ snapshots: pg-env@1.15.0: {} + pg-env@1.19.2: + dependencies: + 12factor-env: 1.18.2 + pg-env@1.8.2: {} pg-int8@1.0.1: {} diff --git a/scripts/dev.ts b/scripts/dev.ts index f7f6177e5..b74e7b057 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -61,6 +61,9 @@ const sharedEnv: Record = { SMTP_HOST: 'localhost', SMTP_PORT: '1025', LOCAL_APP_PORT: '3000', + SMS_PROVIDER: 'devsms', + DEVSMS_BASE_URL: 'http://localhost:4000', + SMS_SENDER_ID: 'Constructive', SEND_VERIFICATION_LINK_DRY_RUN: 'true', SEND_EMAIL_DRY_RUN: 'true', }; diff --git a/skaffold.yaml b/skaffold.yaml index bf010088d..350a55bdd 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -149,6 +149,50 @@ profiles: namespace: constructive-functions port: 3000 localPort: 3002 + - name: send-sms + build: + artifacts: + - image: constructive-functions + context: . + docker: + dockerfile: Dockerfile.dev + sync: + manual: + - src: 'functions/**/*.ts' + dest: /usr/src/app + local: + push: false + manifests: + kustomize: + paths: + - k8s/overlays/local-simple + rawYaml: + - generated/send-sms/k8s/local-deployment.yaml + - generated/send-sms/k8s/functions-configmap.yaml + deploy: + kubectl: + defaultNamespace: constructive-functions + portForward: + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8086 + - resourceType: service + resourceName: knative-job-service + namespace: constructive-functions + port: 8080 + localPort: 8080 + - resourceType: service + resourceName: postgres + namespace: constructive-functions + port: 5432 + localPort: 5432 + - resourceType: service + resourceName: constructive-server + namespace: constructive-functions + port: 3000 + localPort: 3002 - name: send-verification-link build: artifacts: @@ -270,6 +314,7 @@ profiles: - generated/example/k8s/local-deployment.yaml - generated/python-example/k8s/local-deployment.yaml - generated/send-email/k8s/local-deployment.yaml + - generated/send-sms/k8s/local-deployment.yaml - generated/send-verification-link/k8s/local-deployment.yaml - generated/sql-example/k8s/local-deployment.yaml - generated/functions-configmap.yaml @@ -292,6 +337,11 @@ profiles: namespace: constructive-functions port: 80 localPort: 8081 + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8086 - resourceType: service resourceName: send-verification-link namespace: constructive-functions @@ -352,6 +402,11 @@ profiles: namespace: constructive-functions port: 80 localPort: 8081 + - resourceType: service + resourceName: send-sms + namespace: constructive-functions + port: 80 + localPort: 8086 - resourceType: service resourceName: send-verification-link namespace: constructive-functions diff --git a/tests/__mocks__/@pgpmjs/env.ts b/tests/__mocks__/@pgpmjs/env.ts deleted file mode 100644 index 4d10ce8f6..000000000 --- a/tests/__mocks__/@pgpmjs/env.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const parseEnvBoolean = ( - value: string | undefined -): boolean | undefined => { - if (value === undefined || value === '') return undefined; - const lower = value.toLowerCase(); - if (['true', '1', 'yes'].includes(lower)) return true; - if (['false', '0', 'no'].includes(lower)) return false; - return undefined; -}; - -export const parseEnvNumber = ( - value: string | undefined -): number | undefined => { - if (value === undefined || value === '') return undefined; - const num = Number(value); - return isNaN(num) ? undefined : num; -}; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 13d643d2b..b35b60d9b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -9,7 +9,7 @@ All of the following must be running and accessible: - **PostgreSQL** — port-forwarded to `localhost:5432` - **constructive-server** — the GraphQL API server - **knative-job-service** — the job worker that picks up and dispatches jobs -- **send-email** / **send-verification-link** — the function deployments +- **send-email** / **send-sms** / **send-verification-link** — the function deployments - **Database seeded** — `constructive-db` job must have completed (schemas + pgpm packages deployed) The simplest way to get everything running: @@ -40,3 +40,4 @@ PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD="$POSTGRES_PASSWORD" PGD - **job-queue** — SQL-level tests: schema verification, `app_jobs.add_job`, job retrieval - **job-processing** — Full pipeline: insert a job → job-service picks it up → function processes it → job completes/fails +- **send-sms** — dry-run routing smoke test; Hub owns DevSms delivery assertions diff --git a/tests/e2e/__tests__/job-processing.test.ts b/tests/e2e/__tests__/job-processing.test.ts index 6f69f8fa4..b3ec96af8 100644 --- a/tests/e2e/__tests__/job-processing.test.ts +++ b/tests/e2e/__tests__/job-processing.test.ts @@ -2,7 +2,8 @@ * Job Queue Inspection (end-to-end via k8s) * * Shared utility test for inspecting queue state. Individual function e2e tests - * are in per-function files: send-email.e2e.test.ts, send-verification-link.e2e.test.ts + * are in per-function files such as send-email.e2e.test.ts, + * send-sms.e2e.test.ts, and send-verification-link.e2e.test.ts. */ import { getTestConnections, diff --git a/tests/e2e/__tests__/send-sms.e2e.test.ts b/tests/e2e/__tests__/send-sms.e2e.test.ts new file mode 100644 index 000000000..32be217e6 --- /dev/null +++ b/tests/e2e/__tests__/send-sms.e2e.test.ts @@ -0,0 +1,53 @@ +/** + * E2E: send-sms function + * + * Assumes Skaffold is running with send-sms deployed in dry-run mode: + * skaffold dev -p send-sms + * make skaffold-dev + * + * The cross-repository Hub E2E owns DevSms delivery assertions. This test only + * verifies the Functions-owned path: job-service routing and handler completion. + */ +import { + closeConnections, + getDatabaseId, + getTestConnections, + TestClient, +} from '../utils/db'; +import { addJob, waitForJobComplete } from '../utils/jobs'; + +const TEST_PHONE = '+12025550123'; + +describe('E2E: send-sms', () => { + let pg: TestClient; + let databaseId: string; + let jobId: string | undefined; + + beforeAll(async () => { + const connections = await getTestConnections(); + pg = connections.pg; + databaseId = await getDatabaseId(pg); + }); + + afterAll(async () => { + if (pg && jobId) { + await pg.result(`DELETE FROM app_jobs.jobs WHERE id = $1`, [jobId]); + } + await closeConnections(); + }); + + it('should process an sms:send_verification_code job in dry-run mode', async () => { + const job = await addJob(pg, databaseId, 'sms:send_verification_code', { + sms_type: 'sms_otp_code', + phone: TEST_PHONE, + code: '246810', + }); + jobId = job.id; + + expect(job.id).toBeDefined(); + + const result = await waitForJobComplete(pg, job.id, { timeout: 30000 }); + + expect(result.status).toBe('completed'); + }); +}); 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 || '');