add tests -- which have worked on Trent's machine... SMS sent!

This commit is contained in:
2026-09-06 09:22:15 -06:00
parent f967db7373
commit bfac283690
10 changed files with 497 additions and 6 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
# HTTP port (default: 3003)
PORT=3003
# PORT=3003
# Endorser API base URL (default: https://api.endorser.ch)
# ENDORSER_URL=https://api.endorser.ch
@@ -37,8 +37,10 @@ PORT=3003
# one of the two "from" values are all present.
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# TWILIO_FROM_NUMBER=+15550000000
# Prefer the Messaging Service once an A2P 10DLC campaign is approved: the
# campaign lives on it, and it wins when both are set.
# TWILIO_MESSAGING_SERVICE_SID=
# TWILIO_FROM_NUMBER=+15550000000
# The public URL Twilio posts /notify-sms/inbound to. Twilio signs that exact
# string, and behind a proxy or tunnel the request headers do not reproduce it.
+2
View File
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED`
- Every log line is prefixed with an ISO-8601 UTC timestamp (`src/util/log.ts`)
- `pnpm run sms:send` and `pnpm run sms:smoke` exercise the Twilio path and the full `/notify-sms` route flow against a stub Endorser and unsigned test-local JWTs
- `pnpm run twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair
### Changed
- `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table
+108 -1
View File
@@ -242,6 +242,98 @@ fails and the other's succeeds. A shared cursor was rejected: two independent
daily runs against one row means whichever fires first consumes the delta and
the other reports nothing.
### Testing SMS locally
Two scripts cover the two things worth checking separately. Both default to
fake data and neither needs a real handset, a purchased number, or 10DLC
registration.
`pnpm run sms:send [to] [body]` makes one send and prints the result. No server,
no database, no auth — just the Twilio path. The destination can also come from
`SMS_SEND_TO`.
```bash
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \
TWILIO_MESSAGING_SERVICE_SID=MGxxxx \
pnpm run sms:send +15551234567 "test from my Mac"
```
`pnpm run sms:smoke [to]` runs the whole route flow: POST, PUT with the echoed
code, both GET forms, DELETE, then a dump of `sms_phone_log`. The destination
can also come from `SMS_SMOKE_TO`. It stubs the two things
that otherwise need the real world — it starts a throwaway Endorser that answers
`/api/report/rateLimits` with `200`, and mints unsigned `did:ethr` JWTs, which
`decodeAndVerifyJwt` accepts under `NODE_ENV=test-local` without checking a
signature. The middleware chain, the claim check, the throttles and the database
are all real. Each run gets a fresh `NOTIFY_DATA_DIR`, so the three-codes-per-
hour throttle never interferes.
With no Twilio credentials set, sends go to the console adapter and nothing
leaves the machine.
`pnpm run twilio:whoami` answers "whose account am I about to bill?" — it
fetches the Account resource with the configured SID and token, which separates
a mismatched credential pair from a working one before any message is involved.
It sends nothing and costs nothing.
```bash
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx pnpm run twilio:whoami
```
`200` prints the account's friendly name, status and type, and that is the
account a send would bill — note `type: Trial` can only reach verified numbers.
`401` / `20003` means the SID and token are not a matching pair. `403` / `20008`
("Resource not accessible with Test Account Credentials") means the pair is a
valid **test** pair: test credentials may not read the Accounts resource, so
that refusal is a pass, not a fault.
**Twilio test credentials** are the cheapest way to exercise the real API, and
they behave the same whether or not a 10DLC campaign is approved — nothing they
send reaches a carrier. They are a **separate Account SID and Auth Token** from
the live pair, under Console → API keys & tokens → Test credentials; a live SID
with a live token sends real, billable messages. They
are a second Account SID / Auth Token pair in the Twilio console, separate from
the live ones; they need a (free) account but no purchased number, they deliver
no message, they trigger no status callbacks, and they cost nothing.
```bash
TWILIO_ACCOUNT_SID=ACxxxxtest TWILIO_AUTH_TOKEN=xxxx \
TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:smoke +15551234567
```
`+15005550006` is the only `From` that passes validation; every other number
returns `21606`.
The `To` is validated even under test credentials, so a reserved fictional
number such as `+15555550123` is rejected with `21211`. Both scripts refuse to
run with that placeholder once Twilio is configured, rather than spending a
round trip to learn it.
A **real** `To` under test credentials is less predictable: some accounts accept
it and return a synthetic SID, others answer `20404`
(`resource ... Messages.json was not found`) despite the credentials being
valid. Treat the magic `To` numbers below as the dependable path for test
credentials, and use live credentials when a text has to actually arrive.
These magic `To` numbers force specific failures, useful for exercising the
`code-send-failed` path on purpose:
| `To` | Twilio error |
|---|---|
| `+15005550001` | `21211` invalid number |
| `+15005550002` | `21612` cannot route |
| `+15005550003` | `21408` no permission for that region |
| `+15005550004` | `21610` blocklisted |
| `+15005550009` | `21614` not SMS-capable |
Even with deliberately wrong credentials the round trip is worth running once:
Twilio answers `Authentication Error - invalid username`, which proves the URL,
the Basic auth header, the form encoding and the response parsing all work and
only the credentials are missing.
Sending to a real handset needs a real (trial or paid) account, a real `From`
number, and — for a US long code — completed A2P 10DLC registration.
### Provider
Sends go to Twilio over plain `fetch` against
@@ -263,6 +355,21 @@ application-to-person traffic on a long code. Brand and campaign registration
take days and carry per-campaign fees. Unregistered traffic gets filtered by
carriers silently, with a `sent` status from the API.
A registered campaign lives on a Messaging Service, and every number in that
service's sender pool inherits the campaign — including numbers added later.
**Set `TWILIO_MESSAGING_SERVICE_SID` rather than `TWILIO_FROM_NUMBER`** once a
campaign is approved. Both deliver, since the pool carries the registration
either way, but a bare `From` leaves the Messaging Service off the message
record in Twilio's logs and Insights, and it makes it possible to point at a
number outside the pool and quietly send unregistered traffic. The Messaging
Service also picks the sender for each destination. When both variables are set
the Messaging Service wins and `TWILIO_FROM_NUMBER` is ignored.
A `sent` status means Twilio accepted the message, not that a handset received
it. This service records `alert-sent` on that acceptance and does not register a
`StatusCallback`, so `delivered` / `undelivered` / `failed` outcomes are not
tracked. That is a gap to close if delivery receipts matter.
The inbound webhook authenticates by Twilio's `X-Twilio-Signature` over the
exact URL Twilio posted to, not by JWT — it is Twilio calling, not a user. Set
`TWILIO_WEBHOOK_URL` to that public URL; behind a proxy or tunnel the request's
@@ -388,7 +495,7 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
| `SMS_ENABLED` | Optional; `/notify-sms` returns `503 SMS_DISABLED` while off | `false` |
| `SMS_CODE_SECRET` | **Required** when `SMS_ENABLED` (startup fails without it) | None |
| `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` | **Required** to send; absent means `SMS_NOT_CONFIGURED` per send | None |
| `TWILIO_FROM_NUMBER` **or** `TWILIO_MESSAGING_SERVICE_SID` | One of the two required to send | None |
| `TWILIO_MESSAGING_SERVICE_SID` **or** `TWILIO_FROM_NUMBER` | One of the two required to send; prefer the Messaging Service, which wins when both are set | None |
| `TWILIO_WEBHOOK_URL` | The public URL Twilio posts `/notify-sms/inbound` to; it signs that exact string | Derived from request headers |
| `SMS_CODE_TTL_SEC` | Optional | `600` |
| `SMS_CODE_MAX_ATTEMPTS` | Optional | `5` |
+4 -1
View File
@@ -9,7 +9,10 @@
"start": "tsx src/index.ts",
"build": "tsc",
"test": "tsx --test \"test/**/*.test.ts\" \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.test.json"
"typecheck": "tsc --noEmit -p tsconfig.test.json",
"sms:send": "tsx scripts/sms-send.ts",
"sms:smoke": "tsx scripts/sms-smoke.ts",
"twilio:whoami": "tsx scripts/twilio-whoami.ts"
},
"dependencies": {
"@peculiar/asn1-ecc": "^2.7.0",
+47
View File
@@ -0,0 +1,47 @@
/**
* Choosing the destination number for the manual scripts.
*
* 555-01xx numbers are reserved as fictional and do not exist, so they are a
* fine placeholder for the console adapter and a guaranteed failure against the
* real API: Twilio validates the destination even under test credentials and
* answers "The 'To' number ... is not a valid phone number."
*/
import { missingTwilioConfig } from "../src/services/smsService.js";
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
export const FICTIONAL_PLACEHOLDER = "+15555550123";
export function twilioIsConfigured(): boolean {
return missingTwilioConfig().length === 0;
}
/**
* Resolves the destination, or explains why the placeholder cannot be used.
* Exits rather than spending a round trip discovering it at the provider.
*/
export function resolveTarget(explicit: string | undefined, envVar: string): string {
const raw = explicit ?? process.env[envVar];
const normalized = normalizePhoneNumber(raw ?? FICTIONAL_PLACEHOLDER);
if (normalized === undefined) {
console.error(`Not a phone number: ${raw}`);
process.exit(1);
}
if (twilioIsConfigured() && normalized === FICTIONAL_PLACEHOLDER) {
console.error(
`Twilio is configured, so the destination has to be a number Twilio can\n` +
`validate. ${FICTIONAL_PLACEHOLDER} is a reserved fictional number and is\n` +
`rejected with error 21211, even under test credentials.\n\n` +
`Pass a real number instead — your own is the obvious choice:\n\n` +
` ${envVar}=+15551234567 pnpm run <script>\n\n` +
`Under test credentials nothing is delivered and nothing is charged, so\n` +
`using your own number here is safe. Under live credentials it is a real\n` +
`text. Test credentials have their own Account SID: Twilio Console ->\n` +
`API keys & tokens -> Test credentials.`
);
process.exit(1);
}
return normalized;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* One Twilio send, nothing else. No server, no database, no auth.
*
* pkgx npx tsx scripts/sms-send.ts [toNumber] [body]
*
* With Twilio test credentials this is a real API round trip that delivers no
* message and costs nothing: From must be +15005550006, and the To has to be a
* number Twilio can validate — a real one, not a fictional 555-01xx. The
* +1500555000x magic numbers force specific failures.
*/
import { missingTwilioConfig, sendSms } from "../src/services/smsService.js";
import { resolveTarget } from "./smokeTarget.js";
const body = process.argv[3] ?? "Gift Economies: smoke test. Reply STOP to end.";
const missing = missingTwilioConfig();
if (missing.length > 0) {
console.error("Twilio is not configured. Missing: " + missing.join(", "));
console.error(
"\nAn account and a token are not enough; a send needs a sender too.\n" +
"With Twilio test credentials, use the magic From number:\n\n" +
" TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \\\n" +
" TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:send " +
(process.argv[2] ?? "+15555550123") +
"\n\nWith a live 10DLC campaign, set TWILIO_MESSAGING_SERVICE_SID=MGxxxx instead."
);
process.exit(1);
}
const to = resolveTarget(process.argv[2], "SMS_SEND_TO");
console.log(`To: ${to}`);
console.log(`From: ${process.env.TWILIO_FROM_NUMBER ?? process.env.TWILIO_MESSAGING_SERVICE_SID ?? "(unset)"}`);
console.log(`Body: ${body} (${body.length} chars)`);
const result = await sendSms(to, body);
console.log("Result:", JSON.stringify(result));
process.exit(result.status === "sent" ? 0 : 1);
+199
View File
@@ -0,0 +1,199 @@
/**
* End-to-end /notify-sms smoke run against fake everything.
*
* pkgx npx tsx scripts/sms-smoke.ts
*
* pkgx npx tsx scripts/sms-smoke.ts +15551234567
*
* Stubs the two things that normally need the real world — Endorser and the
* signing key — and leaves the SMS path itself completely real. With Twilio
* test credentials in the environment it makes a genuine API round trip that
* sends no message and costs nothing; without them it prints to the console.
*/
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import express from "express";
const USER_DID = "did:ethr:0x0000000000000000000000000000000000c0ffee";
/** Resolved after the environment is set, since the guard reads Twilio config. */
let TO_NUMBER = "";
// A fresh database each run, so the 3-per-hour code throttle never bites.
process.env.NOTIFY_DATA_DIR = mkdtempSync(path.join(tmpdir(), "sms-smoke-"));
// Accepts unsigned did:ethr JWTs and unlocks devCode. Never set in production.
process.env.NODE_ENV = "test-local";
process.env.SMS_ENABLED = "true";
process.env.SMS_CODE_SECRET ??= "sms-smoke-secret";
process.env.SMS_DEV_ECHO_CODE = "true";
/** Answers the one call requireEndorserAuth makes. */
function startStubEndorser(): Promise<string> {
const server = createServer((req, res) => {
console.log(` [stub-endorser] ${req.method} ${req.url} -> 200`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ doneClaimsThisWeek: 0, maxClaimsPerWeek: 100 }));
});
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address() as AddressInfo;
resolve(`http://127.0.0.1:${port}`);
});
});
}
/**
* An unsigned JWT. decodeAndVerifyJwt returns verified:true for a did:ethr
* issuer under NODE_ENV=test-local without checking the signature, so no key
* material is needed to exercise the real middleware chain.
*/
function mintActionJwt(action: string, phoneNumber?: string): string {
const b64 = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
return [
b64({ alg: "ES256K", typ: "JWT" }),
b64({
iss: USER_DID,
iat: now,
exp: now + 300,
claim: {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
action,
...(phoneNumber === undefined ? {} : { phoneNumber }),
},
}),
"unsigned",
].join(".");
}
async function main(): Promise<void> {
// ENDORSER_URL is a module-level const in env.ts, so it has to be set before
// anything that imports env.ts is loaded — including the target guard.
process.env.ENDORSER_URL = await startStubEndorser();
console.log("Stub Endorser at", process.env.ENDORSER_URL);
const { resolveTarget } = await import("./smokeTarget.js");
TO_NUMBER = resolveTarget(process.argv[2], "SMS_SMOKE_TO");
// Imported after the environment is set, since env.ts reads it at load.
const { notifySmsRouter } = await import("../src/routes/notifySms.js");
const { smsConfig } = await import("../src/env.js");
const configured =
smsConfig().twilioAccountSid !== undefined &&
smsConfig().twilioAuthToken !== undefined &&
(smsConfig().twilioFromNumber !== undefined ||
smsConfig().twilioMessagingServiceSid !== undefined);
console.log(
configured
? `Twilio credentials present: real API call, From=${smsConfig().twilioFromNumber ?? smsConfig().twilioMessagingServiceSid}`
: "No Twilio credentials: console adapter, nothing leaves the machine"
);
console.log("Data dir:", process.env.NOTIFY_DATA_DIR, "\n");
const app = express();
app.use(express.json());
app.use("/notify-sms", notifySmsRouter);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
async function call(
label: string,
method: string,
urlPath: string,
action: string,
phoneNumber?: string,
body?: unknown
): Promise<Record<string, unknown>> {
const response = await fetch(base + urlPath, {
method,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + mintActionJwt(action, phoneNumber),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const parsed = (await response.json()) as Record<string, unknown>;
console.log(`${label}: ${response.status}`, JSON.stringify(parsed));
return parsed;
}
const posted = await call(
"POST /phone ",
"POST",
"/notify-sms/phone",
"register-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER }
);
const { smsPhoneLogDb } = await import("../src/db/smsPhoneLogSqlite.js");
async function dumpLog(): Promise<void> {
const rows = await smsPhoneLogDb.listByUserId(USER_DID);
console.log("\nsms_phone_log (newest first):");
for (const row of rows) {
console.log(
` ${row.action.padEnd(26)} ${row.result.padEnd(9)} ` +
`phone=${row.phoneE164 ?? "(scrubbed)"} ` +
`sid=${row.providerMessageId ?? "-"}` +
(row.detail === undefined ? "" : ` detail=${row.detail}`)
);
}
}
const code = posted.devCode;
if (typeof code !== "string") {
await dumpLog();
console.error(
"\nNo devCode in the response, so the code never went out. The " +
"code-send-failed detail above is Twilio's own words.\n" +
" \u2022 'To' rejected: use a real number Twilio can validate, not a " +
"fictional 555-01xx one.\n" +
" \u2022 'From' rejected (21606): under test credentials it must be " +
"+15005550006; under live credentials it must be a number you own, or " +
"a Messaging Service SID.\n" +
" \u2022 'resource ... was not found' (20404): the token authenticated " +
"but that account is not reachable. Run `pnpm run twilio:whoami`."
);
server.close();
process.exit(1);
}
await call(
"PUT /phone ",
"PUT",
"/notify-sms/phone",
"verify-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER, code }
);
await call("GET /phone ", "GET", "/notify-sms/phone", "list-phones");
await call(
"GET /phone? ",
"GET",
`/notify-sms/phone?phoneNumber=${encodeURIComponent(TO_NUMBER)}`,
"list-phones",
TO_NUMBER
);
await call(
"DELETE /phone ",
"DELETE",
"/notify-sms/phone",
"delete-phone",
TO_NUMBER,
{ phoneNumber: TO_NUMBER }
);
await dumpLog();
server.close();
process.exit(0);
}
void main();
+74
View File
@@ -0,0 +1,74 @@
/**
* Who do these credentials say I am?
*
* pkgx pnpm run twilio:whoami
*
* Fetches the Account resource with the configured SID and token, which
* separates "wrong credential pair" from "credentials fine, problem is
* elsewhere" in one call. Sends nothing and costs nothing.
*/
import { smsConfig } from "../src/env.js";
const config = smsConfig();
const sid = config.twilioAccountSid;
const token = config.twilioAuthToken;
if (sid === undefined || token === undefined) {
console.error("Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN first.");
process.exit(1);
}
console.log(`Account SID: ${sid} (${sid.length} chars, prefix ${sid.slice(0, 2)})`);
console.log(`Auth token: ${token.length} chars, ends ...${token.slice(-4)}`);
const response = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(sid)}.json`,
{
headers: {
Authorization: "Basic " + Buffer.from(`${sid}:${token}`).toString("base64"),
},
}
);
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
console.log(`\nHTTP ${response.status}`);
// Test credentials authenticate fine and are then refused most resources.
// That refusal is itself proof the pair is valid, so report it as a pass.
if (response.status === 403 && body.code === 20008) {
console.log(` code: ${String(body.code)}`);
console.log(` message: ${String(body.message)}`);
console.log(
"\nThese are valid Twilio Test Credentials. The pair authenticated; the\n" +
"Accounts resource is simply not one that test credentials may read, so\n" +
"this 403 is the expected answer and not a misconfiguration.\n\n" +
"What they can do: create Messages with From=+15005550006. Nothing is\n" +
"delivered and nothing is billed. The +1500555000x magic To numbers force\n" +
"specific failures; a real To number is accepted by some accounts and\n" +
"answered with 20404 on others, so treat the magic numbers as the reliable\n" +
"path and use live credentials for a text that actually arrives.\n\n" +
"To see account details here, run this with the live pair from the Twilio\n" +
"Console dashboard instead."
);
process.exit(0);
}
if (response.ok) {
console.log(` friendly_name: ${String(body.friendly_name)}`);
console.log(` status: ${String(body.status)}`);
console.log(` type: ${String(body.type)}`);
console.log(
"\nThe pair is valid and this is the account the send would bill. " +
'"type: Trial" can only send to verified numbers.'
);
} else {
console.log(` code: ${String(body.code)}`);
console.log(` message: ${String(body.message)}`);
console.log(
"\n401/20003 means the SID and token are not a matching pair. Test\n" +
"credentials are a matched pair of their own: Twilio Console -> Account ->\n" +
"API keys & tokens -> Test credentials. Take BOTH values from that block;\n" +
"the SID in the dashboard header belongs to the live pair."
);
}
process.exit(response.ok ? 0 : 1);
+19 -1
View File
@@ -19,6 +19,23 @@ type TwilioCredentials = {
from: { From: string } | { MessagingServiceSid: string };
};
/**
* Names the variables a send would need and does not have. An account and a
* token alone cannot produce a message: there has to be something to send from.
*/
export function missingTwilioConfig(config: SmsConfig = smsConfig()): string[] {
const missing: string[] = [];
if (config.twilioAccountSid === undefined) missing.push("TWILIO_ACCOUNT_SID");
if (config.twilioAuthToken === undefined) missing.push("TWILIO_AUTH_TOKEN");
if (
config.twilioMessagingServiceSid === undefined &&
config.twilioFromNumber === undefined
) {
missing.push("TWILIO_MESSAGING_SERVICE_SID or TWILIO_FROM_NUMBER");
}
return missing;
}
/**
* A sender needs an account, a token, and something to send from. Anything less
* cannot produce a message, so it is not a partial configuration but no sender.
@@ -113,7 +130,8 @@ export const sendSms: SmsSender = async (to, body) => {
if (!warnedNotConfigured) {
warnedNotConfigured = true;
log.error(
"[SmsService] Twilio is not configured; SMS sends will fail."
"[SmsService] Twilio is not configured; SMS sends will fail. Missing:",
missingTwilioConfig(config).join(", ")
);
}
return { status: "failed", error: SMS_NOT_CONFIGURED };
+2 -1
View File
@@ -6,7 +6,8 @@
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
"test/**/*.ts",
"scripts/**/*.ts"
],
"exclude": [
"node_modules",