Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
24 changes: 24 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

26 changes: 19 additions & 7 deletions docs/PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
55 changes: 55 additions & 0 deletions src/lib/inboundSms.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
if (/json/i.test(contentType ?? '')) {
try {
const parsed: unknown = JSON.parse(text);
if (parsed && typeof parsed === 'object') fields = parsed as Record<string, unknown>;
} 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') };
}
16 changes: 9 additions & 7 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined)?.[key];
return typeof value === 'string' ? value.trim() : '';
}

async function findSentOfferForPhone(
db: DbClient,
phone: string,
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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})`);
});
Expand Down
95 changes: 95 additions & 0 deletions test/inbound-sms.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<void>): Promise<void> {
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();
}
});
});
Loading