Skip to content

Commit ab46bd2

Browse files
committed
feat: add SMS verification code flow
1 parent c262403 commit ab46bd2

30 files changed

Lines changed: 1255 additions & 18 deletions

CLAUDE.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Constructive Functions
22

3-
Serverless function workloads (send-email, send-verification-link) with a job queue system deployed via Kubernetes.
3+
Serverless function workloads (send-email, send-verification-link, send-sms) with a job queue system deployed via Kubernetes.
44

55
## Project Structure
66

@@ -93,6 +93,9 @@ Edit `functions/<name>/handler.ts` → Skaffold syncs the file into the containe
9393
| Job Service | 8080 |
9494
| send-email | 8081 |
9595
| send-verification-link | 8082 |
96+
| send-sms | 8086 |
97+
| DevSms API | 4000 |
98+
| DevSms UI | 5153 |
9699

97100
## Debugging K8s Pods
98101

@@ -113,6 +116,7 @@ kubectl logs -n constructive-functions -l app=knative-job-service -f
113116
# Function logs
114117
kubectl logs -n constructive-functions -l app=send-email -f
115118
kubectl logs -n constructive-functions -l app=send-verification-link -f
119+
kubectl logs -n constructive-functions -l app=send-sms -f
116120

117121
# Constructive server logs
118122
kubectl logs -n constructive-functions -l app=constructive-server -f
@@ -135,6 +139,9 @@ kubectl port-forward -n constructive-functions svc/postgres 5432:5432
135139
kubectl port-forward -n constructive-functions svc/knative-job-service 8080:8080
136140
kubectl port-forward -n constructive-functions svc/send-email 8081:80
137141
kubectl port-forward -n constructive-functions svc/send-verification-link 8082:80
142+
kubectl port-forward -n constructive-functions svc/send-sms 8086:80
143+
kubectl port-forward -n constructive-functions svc/devsms 4000:4000
144+
kubectl port-forward -n constructive-functions svc/devsms 5153:5153
138145
kubectl port-forward -n constructive-functions svc/constructive-server 3002:3000
139146
```
140147

DEVELOPMENT.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Development Guide
22

3-
Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit).
3+
Local development setup for running functions against real infrastructure (Postgres, GraphQL, Mailpit, DevSms).
44

55
## Prerequisites
66

@@ -32,7 +32,7 @@ pnpm install
3232
# 3. Build everything (packages, job service, generated functions)
3333
pnpm build
3434

35-
# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit)
35+
# 4. Start infrastructure (Postgres, DB migrations, GraphQL server, Mailpit, DevSms)
3636
make dev
3737

3838
# 5. Wait for db-setup to finish (watch logs)
@@ -60,6 +60,7 @@ After this you should have built artifacts in:
6060
|---------|--------|
6161
| `generated/send-verification-link/dist/` | Send-verification-link function server |
6262
| `generated/send-email/dist/` | Send-email function server |
63+
| `generated/send-sms/dist/` | Send SMS verification code function server |
6364
| `generated/example/dist/` | knative-job-example function server |
6465
| `generated/python-example/dist/` | Python example function server |
6566
| `job/service/dist/` | Knative job service (worker + scheduler) |
@@ -80,6 +81,7 @@ This runs `docker compose up -d` which starts:
8081
| **db-setup** | One-shot: creates DB, bootstraps roles, deploys pgpm packages | (exits on completion) |
8182
| **graphql-server** | Constructive admin GraphQL API (header-based routing) | 3002 |
8283
| **mailpit** | SMTP capture server with web UI | 1025 (SMTP), 8025 (UI) |
84+
| **devsms** | Local SMS inbox/API for development verification codes | 4000 (API), 5153 (UI) |
8385

8486
The `db-setup` container must finish before `graphql-server` starts (enforced by `service_completed_successfully`). Watch progress:
8587

@@ -100,6 +102,7 @@ You should see:
100102
- `db-setup` — exited (0)
101103
- `graphql-server` — running
102104
- `mailpit` — running
105+
- `devsms` — running
103106

104107
### 3. Start Functions Locally
105108

@@ -114,6 +117,7 @@ This runs `scripts/dev.ts` which spawns local Node processes with env vars point
114117
| **job-service** | 8080 | `job/service/dist/run.js` |
115118
| **send-email** | 8081 | `generated/send-email/dist/index.js` |
116119
| **send-verification-link** | 8082 | `generated/send-verification-link/dist/index.js` |
120+
| **send-sms** | 8086 | `generated/send-sms/dist/index.js` |
117121
| **knative-job-example** | 8083 | `generated/example/dist/index.js` |
118122
| **python-example** | 8084 | `generated/python-example/...` (python entrypoint) |
119123

@@ -136,6 +140,21 @@ curl -X POST http://localhost:8082 \
136140

137141
Check captured emails at http://localhost:8025 (Mailpit UI).
138142

143+
Send a request to `send-sms` and check captured SMS at http://localhost:5153 (DevSms UI):
144+
145+
```bash
146+
curl -X POST http://localhost:8086 \
147+
-H 'Content-Type: application/json' \
148+
-H 'X-Database-Id: constructive' \
149+
-d '{"sms_type":"sms_otp_code","phone":"+14155550123","code":"012345"}'
150+
```
151+
152+
Query DevSms messages through its API:
153+
154+
```bash
155+
curl "http://localhost:4000/api/sms?limit=10"
156+
```
157+
139158
Query the GraphQL API directly:
140159

141160
```bash
@@ -175,9 +194,12 @@ make dev-down # Stop Docker infrastructure
175194
| GraphQL API | 3002 |
176195
| Mailpit SMTP | 1025 |
177196
| Mailpit UI | 8025 |
197+
| DevSms API | 4000 |
198+
| DevSms UI | 5153 |
178199
| Job Service | 8080 |
179200
| send-email | 8081 |
180201
| send-verification-link | 8082 |
202+
| send-sms | 8086 |
181203
| knative-job-example | 8083 |
182204
| python-example | 8084 |
183205

@@ -187,11 +209,13 @@ make dev-down # Stop Docker infrastructure
187209
Docker Compose (infrastructure):
188210
postgres -> db-setup (migrations) -> graphql-server
189211
mailpit
212+
devsms
190213
191214
Local Node processes (functions):
192215
job/service/dist/run.js (port 8080)
193-
generated/send-email/dist/index.js (port 8081)
194-
generated/send-verification-link/dist/index.js (port 8082)
216+
generated/send-email/dist/index.js (port 8081)
217+
generated/send-verification-link/dist/index.js (port 8082)
218+
generated/send-sms/dist/index.js (port 8086)
195219
```
196220

197221
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`.

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,11 @@ services:
9393
- "1025:1025" # SMTP
9494
- "8025:8025" # Web UI
9595

96+
devsms:
97+
image: ghcr.io/mrmeaow/devsms:latest
98+
ports:
99+
- "4000:4000" # API
100+
- "5153:5153" # Web UI
101+
96102
volumes:
97103
pgdata:

functions/send-sms/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# send-sms
2+
3+
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.
4+
5+
Current provider support is intentionally local-only:
6+
7+
- `SMS_PROVIDER=devsms`
8+
- `DEVSMS_BASE_URL=http://localhost:4000` for Docker Compose local development
9+
- DevSms endpoint: `POST /api/sms/send/twilio`
10+
11+
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.
12+
13+
## Retry and idempotency
14+
15+
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.
16+
17+
## Logging
18+
19+
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.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { DevSmsProvider } from '../providers/devsms';
2+
import type { SmsSendRequest } from '../providers/types';
3+
4+
const request: SmsSendRequest = {
5+
to: '+14155550123',
6+
body: 'Your sign-in code is 123456. Do not share this code.',
7+
senderId: 'TestSender',
8+
metadata: {
9+
jobId: 'job-1',
10+
databaseId: 'db-1',
11+
purpose: 'sign_in_otp'
12+
}
13+
};
14+
15+
const jsonResponse = (body: unknown, status = 201): Response =>
16+
new Response(JSON.stringify(body), {
17+
status,
18+
headers: { 'content-type': 'application/json' }
19+
});
20+
21+
describe('DevSmsProvider', () => {
22+
it('sends the correct URL, method, headers, and request body', async () => {
23+
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
24+
id: 'row_1',
25+
provider_message_id: 'SM123',
26+
status: 'queued'
27+
}));
28+
const provider = new DevSmsProvider({
29+
baseUrl: 'http://devsms:4000',
30+
requestTimeoutMs: 5000,
31+
fetchImpl: fetchImpl as unknown as typeof fetch
32+
});
33+
34+
await provider.send(request);
35+
36+
expect(fetchImpl).toHaveBeenCalledWith(
37+
'http://devsms:4000/api/sms/send/twilio',
38+
expect.objectContaining({
39+
method: 'POST',
40+
headers: { 'content-type': 'application/json' },
41+
body: JSON.stringify({
42+
From: 'TestSender',
43+
To: '+14155550123',
44+
Body: 'Your sign-in code is 123456. Do not share this code.'
45+
})
46+
})
47+
);
48+
});
49+
50+
it('maps provider responses to SmsSendResult', async () => {
51+
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
52+
id: 'row_1',
53+
provider_message_id: 'SM123',
54+
status: 'sent'
55+
}));
56+
const provider = new DevSmsProvider({
57+
baseUrl: 'http://devsms:4000/',
58+
requestTimeoutMs: 5000,
59+
fetchImpl: fetchImpl as unknown as typeof fetch
60+
});
61+
62+
await expect(provider.send(request)).resolves.toEqual({
63+
provider: 'devsms',
64+
messageId: 'SM123',
65+
status: 'sent'
66+
});
67+
});
68+
69+
it('throws for non-2xx responses without exposing the response body', async () => {
70+
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({
71+
error: 'OTP 123456 failed for +14155550123'
72+
}, 400));
73+
const provider = new DevSmsProvider({
74+
baseUrl: 'http://devsms:4000',
75+
requestTimeoutMs: 5000,
76+
fetchImpl: fetchImpl as unknown as typeof fetch
77+
});
78+
79+
const error = await provider.send(request).then(
80+
() => undefined,
81+
(cause: unknown) => cause as Error
82+
);
83+
84+
expect(error).toBeInstanceOf(Error);
85+
expect(error?.message).toBe('DevSmsProvider request failed with 400');
86+
expect(error?.message).not.toContain('123456');
87+
expect(error?.message).not.toContain('+14155550123');
88+
});
89+
90+
it('throws on timeout', async () => {
91+
jest.useFakeTimers();
92+
const fetchImpl = jest.fn((_url: string, init: RequestInit) =>
93+
new Promise((_resolve, reject) => {
94+
init.signal?.addEventListener('abort', () => {
95+
const error = new Error('aborted');
96+
error.name = 'AbortError';
97+
reject(error);
98+
});
99+
})
100+
);
101+
const provider = new DevSmsProvider({
102+
baseUrl: 'http://devsms:4000',
103+
requestTimeoutMs: 10,
104+
fetchImpl: fetchImpl as unknown as typeof fetch
105+
});
106+
107+
const promise = provider.send(request);
108+
jest.advanceTimersByTime(10);
109+
await expect(promise).rejects.toThrow('DevSmsProvider timed out after 10ms');
110+
jest.useRealTimers();
111+
});
112+
113+
it('throws for invalid JSON responses', async () => {
114+
const fetchImpl = jest.fn().mockResolvedValue(new Response('not-json', { status: 201 }));
115+
const provider = new DevSmsProvider({
116+
baseUrl: 'http://devsms:4000',
117+
requestTimeoutMs: 5000,
118+
fetchImpl: fetchImpl as unknown as typeof fetch
119+
});
120+
121+
await expect(provider.send(request)).rejects.toThrow('DevSmsProvider returned invalid JSON');
122+
});
123+
124+
it('throws when the response is missing a message ID', async () => {
125+
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse({ status: 'queued' }));
126+
const provider = new DevSmsProvider({
127+
baseUrl: 'http://devsms:4000',
128+
requestTimeoutMs: 5000,
129+
fetchImpl: fetchImpl as unknown as typeof fetch
130+
});
131+
132+
await expect(provider.send(request)).rejects.toThrow('DevSmsProvider response missing message ID');
133+
});
134+
});

0 commit comments

Comments
 (0)