diff --git a/.env.example b/.env.example index ecee740..5bb6b4f 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,10 @@ APP_ENV= # Sender for sign-in codes. Empty: the relay's shared sender. Set to # no-reply@coachatron.com once coachatron.com is authenticated in SendGrid. COACHATRON_EMAIL_FROM= +# Inbound texts (docs/PLATFORM.md section 3). The secret comes from the relay; +# INBOUND_SMS_URL must equal the relay manifest's inbound.sms url. +RELAY_INBOUND_SECRET= +INBOUND_SMS_URL= +# The Coachatron number, shown on the landing page as "Or text it to ...". +COACHATRON_SMS_NUMBER= PORT=3000 diff --git a/README.md b/README.md index 2f815b0..7fea549 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,14 @@ authenticated coachatron.com address. A coach who joined by email adds a mobile on the schedule screen to get texts; until then the overflow question arrives by email and is answered on the schedule screen. +## Texting in + +A coach can text their week to the Coachatron number once it exists. The relay +forwards Twilio's form fields to `/webhooks/sms`, signed with +`x-relay-signature`; the app checks it when `RELAY_INBOUND_SECRET` is set. One +number serves every environment for sending; incoming texts go to uat while +testing and to production at launch (`docs/PLATFORM.md` section 3). + ## Texting ceilings Every outbound text goes through `sendText()` in `src/domain/outbound.ts` diff --git a/ROADMAP.md b/ROADMAP.md index f7b0501..7593252 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1258,3 +1258,27 @@ Notes: - No thirteenth screen: sign-in is screen 1; the overflow card and the add-a-mobile card are POST actions on screen 2. +--- + +## M14: Texting in, app side +Status: [x] done (the number and the relay route are still to come) +Goal: When the Coachatron number exists, a text to it reaches the app in the +format the relay actually sends, and only from the relay. +Acceptance: +- [x] `/webhooks/sms` reads the raw body and accepts Twilio's form fields + (`From`, `Body`) as the relay forwards them, and the JSON shape tests use +- [x] With `RELAY_INBOUND_SECRET` set, an unsigned request, a wrong secret, + or a signature for another URL gets 401 (`test/inbound-sms.test.ts`) +- [x] A stranger's texted week in the real format gets a `/start/` link, + and the model never runs for an unknown number +- [x] The server warns at boot when it runs on https with no inbound secret +- [x] All five quality-bar commands still exit 0 + +Still to do, outside this repo: +1. Buy one US number in Twilio and add it to the messaging service with the + registered A2P 10DLC campaign. +2. Relay manifest: `sms: { numbers: [...] }` and `inbound.sms` to uat; mint + the inbound secret; point the Twilio number at the relay. +3. Railway uat: `RELAY_INBOUND_SECRET`, `INBOUND_SMS_URL`, + `COACHATRON_SMS_NUMBER`. Production follows at launch. + diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index 2ca5848..9cb5ac9 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -119,13 +119,25 @@ product-side, i.e. Coachatron's screen #6 in SPEC.md §8.1. All sends go through the relay. Rules from SPEC.md §10 stand (transactional only, `STOP` honored, quiet hours 9pm–8am local, per-recipient rate limits). -Inbound needs a **dedicated Coachatron Twilio number** registered in -`inbound.js ROUTES.sms`, pointing at `https://coachatron.com/webhooks/sms`. -`verify-products` will flag this as a GAP until the code change lands — that is -expected and documented, not a bug. - -A20XX, TankRoom, Scholarmancy, and the Earl POC already occupy numbers there; -follow the same shape. +**One Coachatron number does both jobs** (decided 2026-10-02): + +- **Outbound:** the manifest's `sms` becomes `{ numbers: ["+1…"] }` (or the + messaging service it joins), so every environment's texts come from it. + Until then texts go from the shared Noctusoft sender. +- **Inbound:** the relay routes a number to one URL. While testing, the + manifest's `inbound.sms` points it at `https://uat.coachatron.com/webhooks/sms`. + At launch, that one `url` moves to `https://coachatron.com/webhooks/sms`, and + production's `INBOUND_SMS_URL` and `RELAY_INBOUND_SECRET` follow. Adding the + route is data in `products/manifest.json`, not an `inbound.js` change. +- **What arrives:** Twilio's own form fields (`From`, `To`, `Body`, …), + urlencoded, with `x-relay-signature` = base64 HMAC-SHA256(secret, url + raw + body). `src/lib/inboundSms.ts` checks it whenever `RELAY_INBOUND_SECRET` is + set (mint it with `node scripts/relay-keys.js inbound-secret --product + coachatron --rotate` in the relay repo). `INBOUND_SMS_URL` must equal the + manifest `url` exactly. The relay ignores a non-XML reply, so Coachatron + answers by sending a text, not TwiML. +- Texts reach only `+1` numbers, by the app's and the relay's policy. A coach + elsewhere joins by email (SPEC.md §7.1). --- diff --git a/src/lib/inboundSms.ts b/src/lib/inboundSms.ts new file mode 100644 index 0000000..5c367b6 --- /dev/null +++ b/src/lib/inboundSms.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** Inbound texts arrive from the Noctusoft relay, which passes Twilio's own + * form fields through unchanged (From, To, Body, ...) and signs the request: + * x-relay-signature = base64 HMAC-SHA256(secret, url + raw body), where url + * is the webhook URL in the relay manifest (relay inbound-secrets.js). */ + +/** The URL the relay signs with. Must equal the manifest's inbound.sms url. */ +export function inboundSmsUrl(): string { + const configured = (process.env.INBOUND_SMS_URL ?? '').trim(); + if (configured) return configured; + const base = (process.env.APP_BASE_URL ?? 'https://coachatron.com').replace(/\/$/, ''); + return `${base}/webhooks/sms`; +} + +export function relaySignature(secret: string, url: string, raw: Buffer): string { + return createHmac('sha256', secret).update(url).update(raw).digest('base64'); +} + +/** With RELAY_INBOUND_SECRET set, only a correctly signed request passes. + * Without it (local, tests, or before the number exists) every request + * passes, and the server warns at boot when that happens on https. */ +export function inboundSignatureOk(raw: Buffer, header: string | undefined): boolean { + const secret = (process.env.RELAY_INBOUND_SECRET ?? '').trim(); + if (!secret) return true; + if (!header) return false; + const expected = Buffer.from(relaySignature(secret, inboundSmsUrl(), raw)); + const given = Buffer.from(header.trim()); + return expected.length === given.length && timingSafeEqual(expected, given); +} + +/** The sender and text, from the relay's form fields (From, Body) or the + * JSON shape ({ from, body }) used by tests and local tools. */ +export function parseInboundSms(raw: Buffer, contentType: string | undefined): { from: string; body: string } { + const text = raw.toString('utf8'); + let fields: Record = {}; + if (/json/i.test(contentType ?? '')) { + try { + const parsed: unknown = JSON.parse(text); + if (parsed && typeof parsed === 'object') fields = parsed as Record; + } catch { + fields = {}; + } + } else { + fields = Object.fromEntries(new URLSearchParams(text)); + } + const pick = (...keys: string[]) => { + for (const key of keys) { + const value = fields[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; + }; + return { from: pick('From', 'from'), body: pick('Body', 'body') }; +} diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 9df0528..3464db5 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -15,14 +15,10 @@ import { getLivePending, logAssistant, takeDailyReplySlot } from '../domain/assi import { createStartLink, phoneHasBooking } from '../domain/startLink.js'; import { APP_BASE_URL } from '../config.js'; import { clipSms } from '../lib/time.js'; +import { inboundSignatureOk, parseInboundSms } from '../lib/inboundSms.js'; export const webhooksRouter = Router(); -function field(body: unknown, key: string): string { - const value = (body as Record | undefined)?.[key]; - return typeof value === 'string' ? value.trim() : ''; -} - async function findSentOfferForPhone( db: DbClient, phone: string, @@ -52,8 +48,14 @@ async function replyOnce(db: DbClient, phone: string, body: string, now: Date): webhooksRouter.post('/webhooks/sms', async (req, res) => { const db = getDb(); const now = new Date(); - const fromRaw = field(req.body, 'from'); - const body = field(req.body, 'body'); + const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body ?? {})); + if (!inboundSignatureOk(rawBody, req.header('x-relay-signature'))) { + res.status(401).send('Bad signature'); + return; + } + const parsed = parseInboundSms(rawBody, req.header('content-type')); + const fromRaw = parsed.from; + const body = parsed.body; const from = normalizePhone(fromRaw) ?? fromRaw; if (!from || !body) { diff --git a/src/server.ts b/src/server.ts index f1cce10..d114a2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,8 @@ const TICK_MS = 60_000; export function createApp() { const app = express(); app.use('/webhooks/store', express.raw({ type: '*/*' }), storeWebhookRouter); + // Inbound texts are signed over the raw body, so keep it raw here. + app.use('/webhooks/sms', express.raw({ type: '*/*' })); app.use(express.urlencoded({ extended: true })); app.use(express.json()); @@ -63,6 +65,10 @@ if (import.meta.url === `file://${process.argv[1]}`) { void tick(); setInterval(() => void tick(), TICK_MS); + if ((process.env.APP_BASE_URL ?? '').startsWith('https://') && !(process.env.RELAY_INBOUND_SECRET ?? '').trim()) { + console.warn('RELAY_INBOUND_SECRET is not set: inbound texts are accepted unsigned.'); + } + createApp().listen(PORT, () => { console.log(`Coachatron listening on ${PORT} (Node ${process.version})`); }); diff --git a/test/inbound-sms.test.ts b/test/inbound-sms.test.ts new file mode 100644 index 0000000..c29767f --- /dev/null +++ b/test/inbound-sms.test.ts @@ -0,0 +1,95 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { freshDb } from './helpers/db.js'; +import { withServer } from './helpers/server.js'; +import { withRelay } from './helpers/relay.js'; +import { fakeComplete } from './fakes/llm.js'; +import { inboundSmsUrl, parseInboundSms, relaySignature } from '../src/lib/inboundSms.js'; +import { resetComplete, setComplete } from '../src/llm/complete.js'; + +const SECRET = 'test-inbound-secret'; + +/** What the relay sends: Twilio's own form fields, urlencoded. */ +function twilioForm(from: string, body: string): string { + return new URLSearchParams({ MessageSid: 'SM123', From: from, To: '+15551230000', Body: body, NumMedia: '0' }).toString(); +} + +function sendInbound(base: string, form: string, signature?: string): Promise { + return fetch(`${base}/webhooks/sms`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(signature ? { 'x-relay-signature': signature } : {}), + }, + body: form, + }); +} + +async function withSecret(fn: () => Promise): Promise { + const prev = process.env.RELAY_INBOUND_SECRET; + process.env.RELAY_INBOUND_SECRET = SECRET; + try { + await fn(); + } finally { + if (prev === undefined) delete process.env.RELAY_INBOUND_SECRET; + else process.env.RELAY_INBOUND_SECRET = prev; + } +} + +test('the relay form fields and the JSON shape read the same', () => { + assert.deepEqual(parseInboundSms(Buffer.from(twilioForm('+15559990000', ' Tuesdays at 6 ')), 'application/x-www-form-urlencoded'), { + from: '+15559990000', + body: 'Tuesdays at 6', + }); + assert.deepEqual(parseInboundSms(Buffer.from(JSON.stringify({ from: '+15559990000', body: 'hi' })), 'application/json'), { + from: '+15559990000', + body: 'hi', + }); + assert.equal(inboundSmsUrl(), 'https://coachatron.com/webhooks/sms'); +}); + +test('with a secret set, only a correctly signed inbound text is handled', async () => { + await withRelay(async (relay) => { + await freshDb(); + await withSecret(async () => { + await withServer(async (base) => { + const form = twilioForm('+15559990000', 'STOP'); + assert.equal((await sendInbound(base, form)).status, 401, 'unsigned'); + assert.equal((await sendInbound(base, form, relaySignature('wrong-secret', inboundSmsUrl(), Buffer.from(form)))).status, 401); + assert.equal( + (await sendInbound(base, form, relaySignature(SECRET, 'https://evil.example/webhooks/sms', Buffer.from(form)))).status, + 401, + 'signed for a different URL', + ); + assert.equal(relay.sms.length, 0); + + const ok = await sendInbound(base, form, relaySignature(SECRET, inboundSmsUrl(), Buffer.from(form))); + assert.equal(ok.status, 200); + assert.equal(relay.sms.filter((m) => m.to === '+15559990000').length, 1, 'the STOP confirmation'); + }); + }); + }); +}); + +test('a stranger texts their week in the relay format and gets a start link', async () => { + await withRelay(async (relay) => { + await freshDb(); + const llm = fakeComplete(new Error('the model must not run for an unknown number')); + setComplete(llm); + try { + await withSecret(async () => { + await withServer(async (base) => { + const form = twilioForm('+15559990000', 'Keeper group Tuesdays at 6, an hour, 8 kids, $35'); + const res = await sendInbound(base, form, relaySignature(SECRET, inboundSmsUrl(), Buffer.from(form))); + assert.equal(res.status, 200); + const reply = relay.sms.find((m) => m.to === '+15559990000'); + assert.ok(reply); + assert.match(reply.body, /\/start\/[a-f0-9]+/); + assert.equal(llm.calls.length, 0); + }); + }); + } finally { + resetComplete(); + } + }); +});