From ab46bd268b258bedab9eae9ced6e491660531018 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Fri, 17 Jul 2026 17:03:40 +0800 Subject: [PATCH 1/2] feat: add SMS verification code flow --- CLAUDE.md | 9 +- DEVELOPMENT.md | 32 ++- docker-compose.yml | 6 + functions/send-sms/README.md | 19 ++ functions/send-sms/__tests__/devsms.test.ts | 134 ++++++++++++ functions/send-sms/__tests__/handler.test.ts | 196 ++++++++++++++++++ functions/send-sms/__tests__/phone.test.ts | 25 +++ functions/send-sms/config.ts | 25 +++ functions/send-sms/handler.json | 12 ++ functions/send-sms/handler.ts | 141 +++++++++++++ functions/send-sms/phone.ts | 86 ++++++++ functions/send-sms/providers/devsms.ts | 103 +++++++++ functions/send-sms/providers/factory.ts | 50 +++++ functions/send-sms/providers/types.ts | 22 ++ functions/send-sms/templates.ts | 9 + job/service/package.json | 1 + .../constructive/knative-job-service.yaml | 4 +- k8s/base/functions/send-sms.yaml | 67 ++++++ k8s/base/kustomization.yaml | 1 + k8s/overlays/local-simple/config.yaml | 4 + k8s/overlays/local-simple/devsms-local.yaml | 42 ++++ k8s/overlays/local-simple/kustomization.yaml | 1 + k8s/overlays/local/constructive/config.yaml | 3 + k8s/overlays/local/devsms-local.yaml | 42 ++++ k8s/overlays/local/kustomization.yaml | 35 ++++ package.json | 1 + pnpm-lock.yaml | 97 +++++++++ scripts/dev.ts | 3 + scripts/generate.ts | 48 ++++- skaffold.yaml | 55 +++++ 30 files changed, 1255 insertions(+), 18 deletions(-) create mode 100644 functions/send-sms/README.md create mode 100644 functions/send-sms/__tests__/devsms.test.ts create mode 100644 functions/send-sms/__tests__/handler.test.ts create mode 100644 functions/send-sms/__tests__/phone.test.ts create mode 100644 functions/send-sms/config.ts create mode 100644 functions/send-sms/handler.json create mode 100644 functions/send-sms/handler.ts create mode 100644 functions/send-sms/phone.ts create mode 100644 functions/send-sms/providers/devsms.ts create mode 100644 functions/send-sms/providers/factory.ts create mode 100644 functions/send-sms/providers/types.ts create mode 100644 functions/send-sms/templates.ts create mode 100644 k8s/base/functions/send-sms.yaml create mode 100644 k8s/overlays/local-simple/devsms-local.yaml create mode 100644 k8s/overlays/local/devsms-local.yaml diff --git a/CLAUDE.md b/CLAUDE.md index 8ab94dc9d..21974314f 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,9 @@ Edit `functions//handler.ts` → Skaffold syncs the file into the containe | Job Service | 8080 | | send-email | 8081 | | send-verification-link | 8082 | +| send-sms | 8086 | +| DevSms API | 4000 | +| DevSms UI | 5153 | ## Debugging K8s Pods @@ -113,6 +116,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 +139,9 @@ 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/devsms 4000:4000 +kubectl port-forward -n constructive-functions svc/devsms 5153:5153 kubectl port-forward -n constructive-functions svc/constructive-server 3002:3000 ``` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 90db15ba1..149684e85 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`. 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/send-sms/README.md b/functions/send-sms/README.md new file mode 100644 index 000000000..71392e47d --- /dev/null +++ b/functions/send-sms/README.md @@ -0,0 +1,19 @@ +# 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. + +## Retry and idempotency + +The job worker may retry a job after a timeout or provider error. The `SmsSendRequest.metadata` includes `jobId` and `databaseId` for future idempotency support, but DevSms does not currently expose an idempotency key. A retried job can therefore 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__/devsms.test.ts b/functions/send-sms/__tests__/devsms.test.ts new file mode 100644 index 000000000..62005f1d0 --- /dev/null +++ b/functions/send-sms/__tests__/devsms.test.ts @@ -0,0 +1,134 @@ +import { DevSmsProvider } from '../providers/devsms'; +import type { SmsSendRequest } from '../providers/types'; + +const request: SmsSendRequest = { + to: '+14155550123', + body: 'Your sign-in code is 123456. Do not share this code.', + senderId: 'TestSender', + metadata: { + jobId: 'job-1', + databaseId: 'db-1', + purpose: 'sign_in_otp' + } +}; + +const jsonResponse = (body: unknown, status = 201): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); + +describe('DevSmsProvider', () => { + it('sends the correct URL, method, headers, and request body', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ + id: 'row_1', + provider_message_id: 'SM123', + status: 'queued' + })); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000', + requestTimeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + await provider.send(request); + + expect(fetchImpl).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('maps provider responses to SmsSendResult', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ + id: 'row_1', + provider_message_id: 'SM123', + status: 'sent' + })); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000/', + requestTimeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + await expect(provider.send(request)).resolves.toEqual({ + provider: 'devsms', + messageId: 'SM123', + status: 'sent' + }); + }); + + it('throws for non-2xx responses without exposing the response body', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ + error: 'OTP 123456 failed for +14155550123' + }, 400)); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000', + requestTimeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + const error = await provider.send(request).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('throws on timeout', async () => { + jest.useFakeTimers(); + const fetchImpl = jest.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }); + }) + ); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000', + requestTimeoutMs: 10, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + const promise = provider.send(request); + jest.advanceTimersByTime(10); + await expect(promise).rejects.toThrow('DevSmsProvider timed out after 10ms'); + jest.useRealTimers(); + }); + + it('throws for invalid JSON responses', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response('not-json', { status: 201 })); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000', + requestTimeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + await expect(provider.send(request)).rejects.toThrow('DevSmsProvider returned invalid JSON'); + }); + + it('throws when the response is missing a message ID', async () => { + const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ status: 'queued' })); + const provider = new DevSmsProvider({ + baseUrl: 'http://devsms:4000', + requestTimeoutMs: 5000, + fetchImpl: fetchImpl as unknown as typeof fetch + }); + + await expect(provider.send(request)).rejects.toThrow('DevSmsProvider response missing message ID'); + }); +}); diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts new file mode 100644 index 000000000..b79b9a527 --- /dev/null +++ b/functions/send-sms/__tests__/handler.test.ts @@ -0,0 +1,196 @@ +let handler: any; +import { createMockContext } from '../../../tests/helpers/mock-context'; + +const mockGetEnvOptions = jest.fn(); + +jest.mock('@constructive-io/graphql-env', () => ({ + getEnvOptions: mockGetEnvOptions +}), { virtual: true }); + +const smsConfig = (overrides: Record = {}) => ({ + provider: 'devsms', + senderId: 'TestSender', + requestTimeoutMs: 5000, + dryRun: false, + devsms: { + baseUrl: 'http://devsms:4000' + }, + ...overrides +}); + +const mockSuccessfulFetch = () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ + id: 'row_123', + provider: 'twilio', + provider_message_id: 'SM123', + status: 'queued' + }), { + status: 201, + headers: { 'content-type': 'application/json' } + }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +}; + +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; + }); + + it('sends sms_otp_code payloads through DevSmsProvider', 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 DevSmsProvider', 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(expect.objectContaining({ + complete: true, + provider: 'devsms', + messageId: 'SM123' + })); + 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('preserves a six-digit code with leading zero', async () => { + await handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + 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' + } as any, createMockContext() as any)).rejects.toThrow('Unsupported sms_type: unknown'); + }); + + it('throws for missing code', async () => { + await expect(handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123' + } as any, createMockContext() as any)).rejects.toThrow('code must be a six-digit string'); + }); + + it('throws for invalid phone numbers', async () => { + await expect(handler({ + sms_type: 'sms_otp_code', + phone: 'not-a-phone', + code: '123456' + }, createMockContext() as any)).rejects.toThrow('Invalid phone number'); + }); + + it('throws when provider is missing outside dry-run', async () => { + mockGetEnvOptions.mockReturnValue({ sms: { requestTimeoutMs: 5000, dryRun: false } }); + + await expect(handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + code: '123456' + }, createMockContext() as any)).rejects.toThrow('Missing SMS provider configuration'); + }); + + it('rejects devsms in production', async () => { + const ctx = createMockContext({ env: { NODE_ENV: 'production' } }); + + await expect(handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + code: '123456' + }, 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 } }); + + const result = await handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + code: '123456' + }, createMockContext() as any); + + expect(result).toEqual({ complete: true, dryRun: true }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('does not log code, body, or full phone number', async () => { + mockGetEnvOptions.mockReturnValue({ sms: { dryRun: true } }); + const ctx = createMockContext(); + + await handler({ + sms_type: 'sms_otp_code', + phone: '+14155550123', + 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/__tests__/phone.test.ts b/functions/send-sms/__tests__/phone.test.ts new file mode 100644 index 000000000..c0d9f390e --- /dev/null +++ b/functions/send-sms/__tests__/phone.test.ts @@ -0,0 +1,25 @@ +import { normalizePhone, normalizePhoneParts } from '../phone'; + +describe('phone normalization', () => { + it('normalizes international phone strings to E.164', () => { + expect(normalizePhone('+1 (415) 555-0123')).toBe('+14155550123'); + }); + + it('normalizes phone_cc + national phone_number with trunk prefix', () => { + expect(normalizePhoneParts('+44', '020 7946 0018')).toBe('+442079460018'); + }); + + it('normalizes another country code with a leading national zero', () => { + expect(normalizePhoneParts('81', '03-1234-5678')).toBe('+81312345678'); + }); + + it('rejects empty phone values', () => { + expect(() => normalizePhone('')).toThrow('Missing required field: phone'); + expect(() => normalizePhoneParts('1', '')).toThrow('Missing required field: phone_number'); + }); + + it('rejects invalid phone characters', () => { + expect(() => normalizePhone('+1 415 CALL NOW')).toThrow('Invalid phone number'); + expect(() => normalizePhoneParts('1', '415-555-ABCD')).toThrow('Invalid phone number'); + }); +}); diff --git a/functions/send-sms/config.ts b/functions/send-sms/config.ts new file mode 100644 index 000000000..3201f283a --- /dev/null +++ b/functions/send-sms/config.ts @@ -0,0 +1,25 @@ +import { getEnvOptions } from '@constructive-io/graphql-env'; + +export type SmsProviderName = 'devsms' | 'twilio' | 'sns'; + +export type SmsOptions = { + provider?: SmsProviderName; + senderId?: string; + requestTimeoutMs?: number; + dryRun?: boolean; + devsms?: { + baseUrl?: string; + }; +}; + +type ConstructiveOptionsWithSms = { + sms?: SmsOptions; +}; + +export const loadSmsOptions = ( + env: Record, + cwd: string = process.cwd() +): SmsOptions => { + const options = getEnvOptions({}, cwd, env) as ConstructiveOptionsWithSms; + return options.sms ?? {}; +}; diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json new file mode 100644 index 000000000..20c1f2ce4 --- /dev/null +++ b/functions/send-sms/handler.json @@ -0,0 +1,12 @@ +{ + "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.13.0", + "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..b16b5da0f --- /dev/null +++ b/functions/send-sms/handler.ts @@ -0,0 +1,141 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; + +import { loadSmsOptions } from './config'; +import { maskPhone, normalizePhone, normalizePhoneParts } from './phone'; +import { assertProviderAllowed, createSmsProvider } from './providers/factory'; +import type { SmsPurpose, SmsSendRequest } from './providers/types'; +import { renderSmsBody, type SmsType } from './templates'; + +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 NormalizedSmsJob = { + smsType: SmsType; + to: string; + code: string; + purpose: SmsPurpose; +}; + +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), + purpose: 'sign_in_otp' + }; + case 'mfa_verification_code': + return { + smsType: 'mfa_verification_code', + to: normalizePhoneParts(params.phone_cc, params.phone_number), + code: requireSixDigitCode(params.code), + purpose: 'mfa_verification' + }; + default: + throw new Error(`Unsupported sms_type: ${String(params.sms_type)}`); + } +}; + +const buildSmsRequest = ( + normalized: NormalizedSmsJob, + senderId: string | undefined, + jobId: string | undefined, + databaseId: string | undefined +): SmsSendRequest => ({ + to: normalized.to, + body: renderSmsBody(normalized.smsType, normalized.code), + ...(senderId && { senderId }), + metadata: { + jobId, + databaseId, + purpose: normalized.purpose + } +}); + +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; + + assertProviderAllowed(options, { nodeEnv: context.env.NODE_ENV }); + + context.log.info('[send-sms] Processing request', { + jobId, + databaseId, + smsType: normalized.smsType, + maskedPhone, + provider: providerName + }); + + const request = buildSmsRequest(normalized, options.senderId, jobId, databaseId); + + 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 + }; + } + + const provider = createSmsProvider(options, { + nodeEnv: context.env.NODE_ENV + }); + const result = await provider.send(request); + + 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 { buildSmsRequest, normalizePayload }; +export type { SendSmsParams }; +export default handler; diff --git a/functions/send-sms/phone.ts b/functions/send-sms/phone.ts new file mode 100644 index 000000000..83c6e1b16 --- /dev/null +++ b/functions/send-sms/phone.ts @@ -0,0 +1,86 @@ +import type { CountryCode } from 'libphonenumber-js'; +import { + getCountries, + getCountryCallingCode, + parsePhoneNumberFromString +} from 'libphonenumber-js'; + +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; +}; + +export 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); +}; + +export 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] ?? ''}`); +}; + +export 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)}`; +}; diff --git a/functions/send-sms/providers/devsms.ts b/functions/send-sms/providers/devsms.ts new file mode 100644 index 000000000..b98e38bb6 --- /dev/null +++ b/functions/send-sms/providers/devsms.ts @@ -0,0 +1,103 @@ +import type { SmsProvider, SmsSendRequest, SmsSendResult } from './types'; + +export type DevSmsProviderOptions = { + baseUrl: string; + requestTimeoutMs: number; + fetchImpl?: typeof fetch; +}; + +type DevSmsResponse = { + id?: string; + provider?: string; + provider_message_id?: string; + providerMessageId?: string; + sid?: string; + status?: string; +}; + +const DEVSMS_TWILIO_SEND_PATH = '/api/sms/send/twilio'; + +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): SmsSendResult['status'] => { + 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; + +export class DevSmsProvider implements SmsProvider { + private readonly baseUrl: string; + private readonly requestTimeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: DevSmsProviderOptions) { + this.baseUrl = normalizeBaseUrl(options.baseUrl); + this.requestTimeoutMs = options.requestTimeoutMs; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + async send(request: SmsSendRequest): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + + let response: Response; + try { + response = await this.fetchImpl(`${this.baseUrl}${DEVSMS_TWILIO_SEND_PATH}`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + ...(request.senderId && { From: request.senderId }), + To: request.to, + Body: request.body + }), + signal: controller.signal + }); + } catch (err) { + const error = err as Error; + if (error.name === 'AbortError') { + throw new Error(`DevSmsProvider timed out after ${this.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 body: DevSmsResponse; + try { + body = await response.json() as DevSmsResponse; + } catch { + throw new Error('DevSmsProvider returned invalid JSON'); + } + + const messageId = getMessageId(body); + if (!messageId) { + throw new Error('DevSmsProvider response missing message ID'); + } + + return { + provider: 'devsms', + messageId, + status: normalizeStatus(body.status) + }; + } +} + +export { DEVSMS_TWILIO_SEND_PATH }; diff --git a/functions/send-sms/providers/factory.ts b/functions/send-sms/providers/factory.ts new file mode 100644 index 000000000..23bfb9531 --- /dev/null +++ b/functions/send-sms/providers/factory.ts @@ -0,0 +1,50 @@ +import type { SmsOptions } from '../config'; +import { DevSmsProvider } from './devsms'; +import type { SmsProvider } from './types'; + +export type SmsProviderFactoryOptions = { + nodeEnv?: string; + fetchImpl?: typeof fetch; +}; + +const getRequestTimeoutMs = (options: SmsOptions): number => + options.requestTimeoutMs ?? 5000; + +export const assertProviderAllowed = ( + options: SmsOptions, + factoryOptions: SmsProviderFactoryOptions = {} +): void => { + if (factoryOptions.nodeEnv === 'production' && options.provider === 'devsms') { + throw new Error('SMS_PROVIDER=devsms is not allowed when NODE_ENV=production'); + } +}; + +export const createSmsProvider = ( + options: SmsOptions, + factoryOptions: SmsProviderFactoryOptions = {} +): SmsProvider => { + assertProviderAllowed(options, factoryOptions); + + if (!options.provider) { + throw new Error('Missing SMS provider configuration: options.sms.provider'); + } + + switch (options.provider) { + case 'devsms': { + const baseUrl = options.devsms?.baseUrl; + if (!baseUrl) { + throw new Error('Missing DevSms base URL configuration: options.sms.devsms.baseUrl'); + } + return new DevSmsProvider({ + baseUrl, + requestTimeoutMs: getRequestTimeoutMs(options), + fetchImpl: factoryOptions.fetchImpl + }); + } + case 'twilio': + case 'sns': + throw new Error(`Unsupported SMS provider: ${options.provider}`); + default: + throw new Error(`Unsupported SMS provider: ${String(options.provider)}`); + } +}; diff --git a/functions/send-sms/providers/types.ts b/functions/send-sms/providers/types.ts new file mode 100644 index 000000000..81c3047e4 --- /dev/null +++ b/functions/send-sms/providers/types.ts @@ -0,0 +1,22 @@ +export type SmsPurpose = 'sign_in_otp' | 'mfa_verification'; + +export type SmsSendRequest = { + to: string; + body: string; + senderId?: string; + metadata?: { + jobId?: string; + databaseId?: string; + purpose: SmsPurpose; + }; +}; + +export type SmsSendResult = { + provider: string; + messageId: string; + status: 'queued' | 'sent' | 'delivered' | 'unknown'; +}; + +export interface SmsProvider { + send(request: SmsSendRequest): Promise; +} diff --git a/functions/send-sms/templates.ts b/functions/send-sms/templates.ts new file mode 100644 index 000000000..7f485ee12 --- /dev/null +++ b/functions/send-sms/templates.ts @@ -0,0 +1,9 @@ +export type SmsType = 'sms_otp_code' | 'mfa_verification_code'; + +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.' +}; + +export const renderSmsBody = (smsType: SmsType, code: string): string => + SMS_TEMPLATES[smsType].replace('{code}', code); 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/constructive/knative-job-service.yaml b/k8s/base/constructive/knative-job-service.yaml index cf0fcbeba..031ef1f6d 100644 --- a/k8s/base/constructive/knative-job-service.yaml +++ b/k8s/base/constructive/knative-job-service.yaml @@ -49,7 +49,7 @@ spec: - name: JOBS_SUPPORT_ANY value: "false" - name: JOBS_SUPPORTED - value: "email:send_email,email:send_verification_link" + value: "email:send_email,email:send_verification_link,sms:send_verification_code" - name: JOBS_CALLBACK_HOST value: "knative-job-service.interweb.svc.cluster.local" - name: JOBS_CALLBACK_BASE_URL @@ -65,7 +65,7 @@ spec: value: "development" - name: INTERNAL_GATEWAY_DEVELOPMENT_MAP - value: '{"email:send_email":"http://send-email.interweb.svc.cluster.local","email:send_verification_link":"http://send-verification-link.interweb.svc.cluster.local"}' + value: '{"email:send_email":"http://send-email.interweb.svc.cluster.local","email:send_verification_link":"http://send-verification-link.interweb.svc.cluster.local","sms:send_verification_code":"http://send-sms.interweb.svc.cluster.local"}' # Optional: stable hostname for logging/coordination - name: HOSTNAME diff --git a/k8s/base/functions/send-sms.yaml b/k8s/base/functions/send-sms.yaml new file mode 100644 index 000000000..a413ebbf1 --- /dev/null +++ b/k8s/base/functions/send-sms.yaml @@ -0,0 +1,67 @@ +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/base/kustomization.yaml b/k8s/base/kustomization.yaml index d7c2eec61..fcb0f633a 100644 --- a/k8s/base/kustomization.yaml +++ b/k8s/base/kustomization.yaml @@ -23,6 +23,7 @@ resources: # Function workloads - ./functions/send-email.yaml - ./functions/send-verification-link.yaml + - ./functions/send-sms.yaml # Required Secrets are intentionally not committed in the shared base. Create # pg-credentials, postgres-superuser, pgadmin-credentials, mailgun-credentials, diff --git a/k8s/overlays/local-simple/config.yaml b/k8s/overlays/local-simple/config.yaml index c54f2c04c..77ac5fe9b 100644 --- a/k8s/overlays/local-simple/config.yaml +++ b/k8s/overlays/local-simple/config.yaml @@ -22,5 +22,9 @@ data: LOG_TIMESTAMP: "true" + SMS_PROVIDER: "devsms" + SMS_SENDER_ID: "Constructive" + DEVSMS_BASE_URL: "http://devsms.constructive-functions.svc.cluster.local:4000" + SEND_EMAIL_DRY_RUN: "true" SEND_VERIFICATION_LINK_DRY_RUN: "true" diff --git a/k8s/overlays/local-simple/devsms-local.yaml b/k8s/overlays/local-simple/devsms-local.yaml new file mode 100644 index 000000000..c9d9a0462 --- /dev/null +++ b/k8s/overlays/local-simple/devsms-local.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devsms + labels: + app: devsms +spec: + replicas: 1 + selector: + matchLabels: + app: devsms + template: + metadata: + labels: + app: devsms + spec: + containers: + - name: devsms + image: ghcr.io/mrmeaow/devsms:latest + ports: + - containerPort: 4000 + name: api + - containerPort: 5153 + name: web +--- +apiVersion: v1 +kind: Service +metadata: + name: devsms + labels: + app: devsms +spec: + type: ClusterIP + selector: + app: devsms + ports: + - name: api + port: 4000 + targetPort: api + - name: web + port: 5153 + targetPort: web diff --git a/k8s/overlays/local-simple/kustomization.yaml b/k8s/overlays/local-simple/kustomization.yaml index 12a50871f..4cf188c0f 100644 --- a/k8s/overlays/local-simple/kustomization.yaml +++ b/k8s/overlays/local-simple/kustomization.yaml @@ -7,6 +7,7 @@ resources: # Infrastructure - ./postgres-local.yaml - ./minio-local.yaml + - ./devsms-local.yaml # Secrets & config - ./config.yaml - ./pg-secret.yaml diff --git a/k8s/overlays/local/constructive/config.yaml b/k8s/overlays/local/constructive/config.yaml index c3fc0f43e..6c20560f4 100644 --- a/k8s/overlays/local/constructive/config.yaml +++ b/k8s/overlays/local/constructive/config.yaml @@ -4,3 +4,6 @@ metadata: name: constructive data: PGHOST: "postgres.constructive-functions.svc.cluster.local" + SMS_PROVIDER: "devsms" + SMS_SENDER_ID: "Constructive" + DEVSMS_BASE_URL: "http://devsms.constructive-functions.svc.cluster.local:4000" diff --git a/k8s/overlays/local/devsms-local.yaml b/k8s/overlays/local/devsms-local.yaml new file mode 100644 index 000000000..c9d9a0462 --- /dev/null +++ b/k8s/overlays/local/devsms-local.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devsms + labels: + app: devsms +spec: + replicas: 1 + selector: + matchLabels: + app: devsms + template: + metadata: + labels: + app: devsms + spec: + containers: + - name: devsms + image: ghcr.io/mrmeaow/devsms:latest + ports: + - containerPort: 4000 + name: api + - containerPort: 5153 + name: web +--- +apiVersion: v1 +kind: Service +metadata: + name: devsms + labels: + app: devsms +spec: + type: ClusterIP + selector: + app: devsms + ports: + - name: api + port: 4000 + targetPort: api + - name: web + port: 5153 + targetPort: web diff --git a/k8s/overlays/local/kustomization.yaml b/k8s/overlays/local/kustomization.yaml index 35f58afee..b9f202158 100644 --- a/k8s/overlays/local/kustomization.yaml +++ b/k8s/overlays/local/kustomization.yaml @@ -7,6 +7,7 @@ resources: - ./namespace.yaml - ./postgres-local.yaml - ./minio-local.yaml + - ./devsms-local.yaml # Core Constructive config - ../../base/constructive/config.yaml - ../../base/constructive/dashboard-config.yaml @@ -26,6 +27,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 +49,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 +97,27 @@ 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: SMS_PROVIDER + value: "devsms" + - op: add + path: /spec/template/spec/containers/0/env/- + value: + name: SMS_SENDER_ID + value: "Constructive" + - op: add + path: /spec/template/spec/containers/0/env/- + value: + name: DEVSMS_BASE_URL + value: "http://devsms.constructive-functions.svc.cluster.local:4000" + 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..ed2ba31d3 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,28 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-sms: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@constructive-io/graphql-env': + specifier: ^3.13.0 + version: 3.15.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 +255,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 @@ -619,6 +647,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@constructive-io/graphql-env@3.15.0': + resolution: {integrity: sha512-twK195FV69XUwLCQ8ifYxALiGF157N424YUzZhKP52DNmHjBY2GY1hLj6SM5K2t7+LrOFJPNeo7U/d5I9yK+LQ==} + + '@constructive-io/graphql-types@3.14.0': + resolution: {integrity: sha512-ATT9HIXD4Qa+WuayaAWzwvbgNBlU1ssCjUroo7zbZsSxXX7pgSDK1KSk/jmCldYIYLez19xgXx3QI7rlP6A/iQ==} + '@constructive-io/job-pg@2.5.4': resolution: {integrity: sha512-cjJxL/P1g4s07PiXiw31jb7c56aQmdPur+WyQXC0lAig1DIFSeJqbQjY6PXaFQepxgivwIBf9P9v+EalQaOtPQ==} @@ -1076,6 +1110,9 @@ packages: '@pgpmjs/env@2.17.0': resolution: {integrity: sha512-3WPwJ4prFWGGIRzyR52/JG84hM+Qe6lVtQ+bcCpGnGuhukFowALpaegRZxi3LT/pO6D8wW1Y3nW9LugfJLO6KQ==} + '@pgpmjs/env@2.27.0': + resolution: {integrity: sha512-/JTHMBx7X7bnoy5kPCNDgu2eWNkjrBaeIHqlXErcErldt1RMEMjLYQykCYW5QdOAUP8RKS6pIzszvatoROlkAA==} + '@pgpmjs/logger@1.5.0': resolution: {integrity: sha512-R27o5MiOsezI5rAWdJyuOkWUK6zxr8Mg61hPs7uCu//sECoprR4/7CVeFIHwn7+gyrjUk0wBz0dQcJhjYzVDpw==} @@ -1091,6 +1128,9 @@ packages: '@pgpmjs/types@2.28.0': resolution: {integrity: sha512-XYCcWnxkIrZEHF2oxxtU1yMeMa4bfw5za5CsDnMx0uasdtG0Y5YwDqruuv0uzYdz0id927LMb6svE38vrmPTIg==} + '@pgpmjs/types@2.34.0': + resolution: {integrity: sha512-2Y6o1IyqfuaogcoIGOECuR33sl7XlLYGQzi5qD6CLLPwAgEXzQj3u6dxchuzWxnWaE3Sf6W+SwLStxx688k2nQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2181,6 +2221,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 +2339,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 +2733,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 +3144,9 @@ packages: pg-env@1.15.0: resolution: {integrity: sha512-1bs3pcNOOrA0om3TNJGwbZu7JXKc/tenyVW4KX6ljARxvKtRSUcGl6sbpKVCXJo+Y98W0nRvPgfa/SlqlumRsg==} + pg-env@1.18.0: + resolution: {integrity: sha512-RzZISyA7ZJlnKOurlkqX2k/1iJl5kqn39OlvUbn0wtX1ENYtfClFlbkzye+WFdw/s9VnPY0uSzPJPKcA+8gBxQ==} + pg-env@1.8.2: resolution: {integrity: sha512-YzxNQKZmFRRJKX5t149Ys2JoAsc6OCHcaoYH/82si7gwVC9ODaFTFtQn7gv3VpoGsNkH90t6iEPWvmLIgv2rDg==} @@ -3922,6 +3976,23 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@constructive-io/graphql-env@3.15.0': + dependencies: + '@constructive-io/graphql-types': 3.14.0 + '@pgpmjs/env': 2.27.0 + deepmerge: 4.3.1 + transitivePeerDependencies: + - supports-color + + '@constructive-io/graphql-types@3.14.0': + dependencies: + '@pgpmjs/types': 2.34.0 + deepmerge: 4.3.1 + graphile-config: 1.0.1 + pg-env: 1.18.0 + transitivePeerDependencies: + - supports-color + '@constructive-io/job-pg@2.5.4': dependencies: '@constructive-io/job-utils': 2.5.4 @@ -4550,6 +4621,11 @@ snapshots: '@pgpmjs/types': 2.21.0 deepmerge: 4.3.1 + '@pgpmjs/env@2.27.0': + dependencies: + '@pgpmjs/types': 2.34.0 + deepmerge: 4.3.1 + '@pgpmjs/logger@1.5.0': dependencies: yanse: 0.2.1 @@ -4570,6 +4646,10 @@ snapshots: dependencies: pg-env: 1.15.0 + '@pgpmjs/types@2.34.0': + dependencies: + pg-env: 1.18.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5825,6 +5905,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 +6042,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 +6808,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 +7375,8 @@ snapshots: pg-env@1.15.0: {} + pg-env@1.18.0: {} + 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/scripts/generate.ts b/scripts/generate.ts index c4f038520..1aaa134fa 100644 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -282,6 +282,37 @@ function generateSkaffoldYaml(fns: FunctionInfo[]): void { } } +function symlinkFunctionSupportFiles(fnDir: string, genDir: string, base: string = ''): void { + const entries = fs.readdirSync(path.join(fnDir, base)) as string[]; + for (const entry of entries) { + if (entry === '__tests__') continue; + + const relPath = path.join(base, entry); + const sourcePath = path.join(fnDir, relPath); + const targetPath = path.join(genDir, relPath); + const stat = fs.statSync(sourcePath); + + if (stat.isDirectory()) { + symlinkFunctionSupportFiles(fnDir, genDir, relPath); + continue; + } + + if ( + relPath === 'handler.ts' || + relPath === 'handler.py' || + !(/\.(ts|d\.ts|py)$/.test(entry)) || + entry.endsWith('.test.ts') + ) { + continue; + } + + const targetDir = path.dirname(targetPath); + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + const linked = ensureSymlink(sourcePath, targetPath); + if (linked) console.log(` - ${relPath} -> functions/${path.basename(fnDir)}/${relPath}`); + } +} + // --- Main --- function main(): void { @@ -360,15 +391,8 @@ function main(): void { if (linked) console.log(` - handler.py -> functions/${fnName}/handler.py`); } - // Symlink any .d.ts or .py files (excluding handler.py which is handled above) - const files = fs.readdirSync(fnDir) as string[]; - for (const file of files) { - if (file.endsWith('.d.ts') || (file.endsWith('.py') && file !== 'handler.py')) { - const target = path.join(fnDir, file); - const linked = ensureSymlink(target, path.join(genDir, file)); - if (linked) console.log(` - ${file} -> functions/${fnName}/${file}`); - } - } + // Symlink additional support source files/directories used by richer functions. + symlinkFunctionSupportFiles(fnDir, genDir); } // --packages-only: stop here, only workspace packages were needed @@ -385,9 +409,11 @@ function main(): void { allManifests.push(readManifest(fnDir)); } - // Auto-assign ports for functions that don't have one + // Auto-assign ports for functions that don't have one, filling the lowest + // available local function port first so adding a high explicit port does not + // shift existing unpinned examples. const usedPorts = new Set(allManifests.filter((m) => m.port).map((m) => m.port!)); - let nextPort = usedPorts.size > 0 ? Math.max(...usedPorts) + 1 : 8081; + let nextPort = 8081; for (const m of allManifests) { if (!m.port) { while (usedPorts.has(nextPort)) nextPort++; 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 From 2860ee559bffe5aebc13c8d0fde7e582b9380bbe Mon Sep 17 00:00:00 2001 From: zetazzz Date: Fri, 17 Jul 2026 21:05:02 +0800 Subject: [PATCH 2/2] fix: keep fn generator aligned with SMS function output --- packages/fn-generator/src/builders/package.ts | 39 +++++++++++++++---- packages/fn-generator/src/discovery.ts | 4 +- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/fn-generator/src/builders/package.ts b/packages/fn-generator/src/builders/package.ts index 327f5ddbe..ad9b9009a 100644 --- a/packages/fn-generator/src/builders/package.ts +++ b/packages/fn-generator/src/builders/package.ts @@ -8,7 +8,7 @@ import type { FunctionInfo, Manifest } from '../types'; * Build all manifests for a single function: * - per-template files (placeholders applied, package.json/tsconfig.json processed) * - shared template files (same processing) - * - symlinks for handler.{ts,py}, *.d.ts, and any other *.py + * - symlinks for handler.{ts,py} and supporting TypeScript/Python sources * * `templateDir` is the resolved type-specific dir (`templates/node-graphql/` etc.). */ @@ -56,17 +56,40 @@ export const buildPackageManifests = ( out.push({ kind: 'symlink', path: path.join(args.genDir, 'handler.py'), target: handlerPy }); } - // 4. Auxiliary symlinks: all *.d.ts and *.py (except handler.py already linked above) - const files = fs.readdirSync(args.fnDir); - for (const file of files) { - if (file.endsWith('.d.ts') || (file.endsWith('.py') && file !== 'handler.py')) { + // 4. Supporting source symlinks. Richer functions may split implementation + // across nested TypeScript/Python files; mirror scripts/generate.ts exactly. + const addSupportSymlinks = (base = ''): void => { + const entries = fs.readdirSync(path.join(args.fnDir, base)); + for (const entry of entries) { + if (entry === '__tests__') continue; + + const relPath = path.join(base, entry); + const sourcePath = path.join(args.fnDir, relPath); + const stat = fs.statSync(sourcePath); + + if (stat.isDirectory()) { + addSupportSymlinks(relPath); + continue; + } + + if ( + relPath === 'handler.ts' || + relPath === 'handler.py' || + !(/\.(ts|d\.ts|py)$/.test(entry)) || + entry.endsWith('.test.ts') + ) { + continue; + } + out.push({ kind: 'symlink', - path: path.join(args.genDir, file), - target: path.join(args.fnDir, file), + path: path.join(args.genDir, relPath), + target: sourcePath, }); } - } + }; + + addSupportSymlinks(); return out; }; diff --git a/packages/fn-generator/src/discovery.ts b/packages/fn-generator/src/discovery.ts index 6553c88af..421bc7168 100644 --- a/packages/fn-generator/src/discovery.ts +++ b/packages/fn-generator/src/discovery.ts @@ -52,7 +52,9 @@ export const assignAndValidatePorts = ( const usedPorts = new Set( manifests.filter((m) => m.port).map((m) => m.port as number) ); - let nextPort = usedPorts.size > 0 ? Math.max(...usedPorts) + 1 : 8081; + // Fill the lowest available local function port first. Explicit high ports + // must not shift existing functions that rely on automatic assignment. + let nextPort = 8081; for (const m of manifests) { if (!m.port) { while (usedPorts.has(nextPort)) nextPort++;