Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -93,6 +93,7 @@ Edit `functions/<name>/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

Expand All @@ -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
Expand All @@ -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
```

Expand Down
41 changes: 35 additions & 6 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) |
Expand All @@ -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:

Expand All @@ -100,6 +102,7 @@ You should see:
- `db-setup` — exited (0)
- `graphql-server` — running
- `mailpit` — running
- `devsms` — running

### 3. Start Functions Locally

Expand All @@ -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) |

Expand All @@ -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
Expand Down Expand Up @@ -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 |

Expand All @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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**
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
3 changes: 2 additions & 1 deletion functions/python-example/handler.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"name": "python-example",
"version": "0.1.0",
"description": "Example Python function",
"type": "python"
"type": "python",
"port": 8084
}
26 changes: 26 additions & 0 deletions functions/send-sms/README.md
Original file line number Diff line number Diff line change
@@ -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.
149 changes: 149 additions & 0 deletions functions/send-sms/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) =>
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.',
}),
})
);
});
});
Loading
Loading