diff --git a/.env.example b/.env.example index 3d7b8f3..504baaa 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 264c37d..5856d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6ebe06c..59adf07 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/package.json b/package.json index dbdaedf..ad9f962 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smokeTarget.ts b/scripts/smokeTarget.ts new file mode 100644 index 0000000..1b3b15d --- /dev/null +++ b/scripts/smokeTarget.ts @@ -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