From 14b5b64984e07dab08caf35508a6a056d0dda362 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Fri, 2 Oct 2026 09:20:26 -0500 Subject: [PATCH 1/3] Let a coach join with an email or a mobile in one box. Text codes reach only +1 numbers, so a coach anywhere else could not get in. An email now gets a 6-digit code by email through the relay, a US or Canadian mobile still gets a text, and a foreign mobile is pointed to email. A coach with no phone gets the overflow question by email and answers it on the schedule screen, and can add a mobile there later. The relay email client is fixed to the relay's real shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 3 + README.md | 10 + ROADMAP.md | 32 +++ SPEC.md | 9 +- src/config.ts | 9 +- src/db/migrations/0011_email_signin.sql | 18 ++ src/domain/auth.ts | 81 ++++++- src/domain/cascade.ts | 83 ++++++- src/domain/outbound.ts | 69 +++++- src/lib/cookies.ts | 11 +- src/relay/email.ts | 18 +- src/routes/coach.ts | 239 ++++++++++++++++---- test/coach-onboarding.test.ts | 2 +- test/email-signin.test.ts | 279 ++++++++++++++++++++++++ test/fakes/relay.ts | 28 ++- test/helpers/fixtures.ts | 6 +- test/journeys.test.ts | 2 +- test/landing.test.ts | 2 +- test/outbound.test.ts | 8 +- 19 files changed, 825 insertions(+), 84 deletions(-) create mode 100644 src/db/migrations/0011_email_signin.sql create mode 100644 test/email-signin.test.ts diff --git a/.env.example b/.env.example index 2956405..ecee740 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,7 @@ SESSION_SECRET= DATABASE_URL= APP_BASE_URL= 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= PORT=3000 diff --git a/README.md b/README.md index 554427e..2f815b0 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,16 @@ Do not commit secrets. `npm test` uses the in-process fake in `test/fakes/relay. coach is recorded on the same session via `assigned_roster_member_id`. See `ROADMAP.md` M4 for why. +## Joining + +A coach signs in with one box: an email or a mobile number. An email gets a +6-digit code by email (it works anywhere); a US or Canadian mobile gets the code +by text. Sign-in emails go through the relay's `/email/send` from its shared +sender with the name "Coachatron" until `COACHATRON_EMAIL_FROM` names an +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 ceilings Every outbound text goes through `sendText()` in `src/domain/outbound.ts` diff --git a/ROADMAP.md b/ROADMAP.md index 484800c..f7b0501 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1226,3 +1226,35 @@ Notes: the shell ran. Wrapping `railway config plan` in `timeout` (or anything else) makes it fail with a misleading "requires Railway CLI 5.42.1". +--- + +## M13: One-box web join +Status: [x] done +Goal: Any coach, anywhere, can join on the web. Text codes reach only +1 +numbers (the app's and the relay's policy), so until now a coach outside the +US and Canada, including our tester, could not get in at all. +Acceptance: +- [x] `/signin` takes an email or a mobile in one box (`parseContact`); an + email gets a 6-digit code by email, a +1 mobile by text, a foreign + mobile is pointed to email (`test/email-signin.test.ts`) +- [x] Email codes go through `sendEmailMessage` (`src/domain/outbound.ts`), + logged in `message_log` with `channel='email'`, capped at 5 per + address a day; the code is in the subject and the body has no link +- [x] `coach.phone` is optional and `coach.email` unique (case-insensitive); + a phone join cannot claim another coach's email +- [x] A coach without a phone gets the overflow question by email and + answers it on the schedule screen; any coach can answer it there +- [x] A coach who joined by email adds a mobile on the schedule screen, + verified by a text code; a number on another account is refused +- [x] Session cookies are `Secure` on https +- [x] All five quality-bar commands still exit 0 + +Notes: +- `src/relay/email.ts` had never been called and sent a SendGrid-shaped body + from an unauthenticated `noreply@coachatron.com`; the relay's + `/email/send` would have answered 422. It now sends the relay's native + shape (`to`, `subject`, `text`, `html`, optional `from`, `fromName`). +- Seeded test coaches now get distinct emails, since email is unique. +- No thirteenth screen: sign-in is screen 1; the overflow card and the + add-a-mobile card are POST actions on screen 2. + diff --git a/SPEC.md b/SPEC.md index a9735a8..e7c7ddf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -137,8 +137,11 @@ the solo coach. They return only when a paying customer is blocked without them. 1. Lands on `coachatron.com` and types or texts their week: *what it's called, how long, how many athletes, how much, which days and times*. -2. Enters name and phone. SMS one-time code verifies the phone (or they tap the - link we texted). No password. The model runs only after the phone is proven. +2. Enters an email or a mobile number in one box, then the one-time code we send + (by email, or by text to a US or Canadian mobile), and their name. No password. + Email works from anywhere; a mobile can be added later for texts. A coach who + texted their week taps the link we text back instead. The model runs only after + the code or link proves who they are. *(Updated 2026-10-02.)* 3. The product shows what it understood, asks for anything missing (never guesses a price), and writes nothing until the coach taps **Publish**. *(Added 2026-09-30. The landing box and the texted week feed this same setup. The one-form session @@ -204,7 +207,7 @@ outstanding package credits (a liability), and next week's projected. Ship nothing else until a real coach has taken real money through this. **Coach-facing (7)** -1. Sign in (phone + OTP) +1. Sign in (email or mobile + one-time code) 2. Schedule — this week's sessions, tap one to see who's in it 3. Session detail — roster of athletes, mark attendance, cancel session 4. Session types — list + create/edit (name, duration, capacity, price) diff --git a/src/config.ts b/src/config.ts index 3090d85..7f0f4bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,7 +30,14 @@ export const SMS_COACH_DAILY_CAP = 300; export const SMS_COACH_MONTHLY_CAP = 2000; export const SMS_PRODUCT_DAILY_PER_COACH = 400; export const SMS_PRODUCT_DAILY_FLOOR = 500; -export const OTP_DAILY_MAX_PER_PHONE = 5; +/** Sign-in codes per phone or email address in 24 hours. */ +export const OTP_DAILY_MAX_PER_CONTACT = 5; +/** Empty: the relay's shared authenticated sender. Set to + * no-reply@coachatron.com once coachatron.com is authenticated in SendGrid. + * Read at call time so tests can set it. */ +export function coachatronEmailFrom(): string { + return (process.env.COACHATRON_EMAIL_FROM ?? '').trim(); +} /** E.164 of the product SMS number. Empty until provisioned; the landing * page hides the "text it" line when unset. Read at request time so tests * can set the env without reloading the module. */ diff --git a/src/db/migrations/0011_email_signin.sql b/src/db/migrations/0011_email_signin.sql new file mode 100644 index 0000000..94f4200 --- /dev/null +++ b/src/db/migrations/0011_email_signin.sql @@ -0,0 +1,18 @@ +-- A coach can join with an email or a mobile number (SPEC.md 7.1). Text +-- codes only reach +1 numbers, so email is the way in everywhere else. + +-- A coach may have no phone yet. UNIQUE still holds for the phones that exist. +alter table coach alter column phone drop not null; + +-- An email identifies one coach. Empty means none was given. +create unique index if not exists coach_email_unique on coach (lower(email)) where email <> ''; + +-- A sign-in code goes to a phone or to an email. +alter table otp_code alter column phone drop not null; +alter table otp_code add column if not exists email text; +alter table otp_code drop constraint if exists otp_code_target; +alter table otp_code add constraint otp_code_target check (phone is not null or email is not null); +create index if not exists otp_code_email on otp_code (email); + +-- Email sends share the outbound log and its ceilings with texts. +alter table message_log add column if not exists channel text not null default 'sms'; diff --git a/src/domain/auth.ts b/src/domain/auth.ts index 71564b1..f83e3bd 100644 --- a/src/domain/auth.ts +++ b/src/domain/auth.ts @@ -12,13 +12,45 @@ export function normalizePhone(raw: string): string | null { return null; } -export async function createOtp(db: DbClient, phone: string): Promise { - const code = Math.floor(100000 + Math.random() * 900000).toString(); +/** Lowercased and trimmed, or null when it does not look like an address. */ +export function normalizeEmail(raw: string): string | null { + const email = raw.trim().toLowerCase(); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254 ? email : null; +} + +/** Where a sign-in code goes. Text codes reach only +1 numbers, so email is + * how a coach anywhere else gets in (SPEC.md §7.1). */ +export type Contact = { kind: 'email'; email: string } | { kind: 'phone'; phone: string }; + +/** One box takes an email or a mobile number. */ +export function parseContact(raw: string): Contact | null { + const value = raw.trim(); + if (value.includes('@')) { + const email = normalizeEmail(value); + return email ? { kind: 'email', email } : null; + } + const phone = normalizePhone(value); + return phone ? { kind: 'phone', phone } : null; +} + +/** The value a Contact is stored and shown as. */ +export function contactValue(contact: Contact): string { + return contact.kind === 'email' ? contact.email : contact.phone; +} + +function asContact(target: string | Contact): Contact { + return typeof target === 'string' ? { kind: 'phone', phone: target } : target; +} + +export async function createOtp(db: DbClient, target: string | Contact): Promise { + const contact = asContact(target); + const code = crypto.randomInt(100000, 1000000).toString(); const codeHash = crypto.createHash('sha256').update(code).digest('hex'); const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000); - await db.query('insert into otp_code (phone, code_hash, expires_at) values ($1, $2, $3)', [ - phone, + await db.query('insert into otp_code (phone, email, code_hash, expires_at) values ($1, $2, $3, $4)', [ + contact.kind === 'phone' ? contact.phone : null, + contact.kind === 'email' ? contact.email : null, codeHash, expiresAt.toISOString(), ]); @@ -29,13 +61,15 @@ export async function createOtp(db: DbClient, phone: string): Promise { /** Read-only check: does not consume the code. Callers must call consumeOtp * once the whole submission (including any first-time profile fields) is * known to be valid, so a profile validation failure does not burn a code. */ -export async function checkOtp(db: DbClient, phone: string, code: string): Promise { +export async function checkOtp(db: DbClient, target: string | Contact, code: string): Promise { + const contact = asContact(target); const codeHash = crypto.createHash('sha256').update(code).digest('hex'); + const column = contact.kind === 'email' ? 'email' : 'phone'; const result = await db.query<{ id: number }>( `select id from otp_code - where phone = $1 and code_hash = $2 and expires_at > now() and consumed_at is null + where ${column} = $1 and code_hash = $2 and expires_at > now() and consumed_at is null order by id desc limit 1`, - [phone, codeHash], + [contactValue(contact), codeHash], ); return result.rows.length > 0 ? result.rows[0].id : null; } @@ -70,7 +104,8 @@ export interface CoachRow { handle: string; name: string; email: string; - phone: string; + /** Null for a coach who joined by email and has not added a mobile. */ + phone: string | null; tz: string; connect_recipient_key: string | null; } @@ -87,6 +122,27 @@ export async function findCoachByPhone(db: DbClient, phone: string): Promise { + const result = await db.query( + `select id, handle, name, email, phone, tz, connect_recipient_key from coach + where email <> '' and lower(email) = lower($1)`, + [email], + ); + return result.rows[0] ?? null; +} + +export async function findCoachByContact(db: DbClient, contact: Contact): Promise { + return contact.kind === 'email' ? findCoachByEmail(db, contact.email) : findCoachByPhone(db, contact.phone); +} + +/** Adds a verified mobile to a coach. 'taken' when another coach has it. */ +export async function setCoachPhone(db: DbClient, coachId: number, phone: string): Promise<'ok' | 'taken'> { + const other = await findCoachByPhone(db, phone); + if (other && other.id !== coachId) return 'taken'; + await db.query('update coach set phone = $1 where id = $2', [phone, coachId]); + return 'ok'; +} + export async function findCoachByHandle(db: DbClient, handle: string): Promise { const result = await db.query( 'select id, handle, name, email, phone, tz, connect_recipient_key from coach where handle = $1', @@ -118,25 +174,28 @@ export async function reserveHandle(db: DbClient, name: string): Promise } export interface NewCoachInput { - phone: string; + /** At least one of phone and email identifies the coach. */ + phone: string | null; name: string; email: string; tz: string; } export async function createCoach(db: DbClient, input: NewCoachInput): Promise { + const email = normalizeEmail(input.email) ?? ''; + if (!input.phone && !email) throw new Error('a coach needs a phone or an email'); const handle = await reserveHandle(db, input.name); const result = await db.query<{ id: number }>( `insert into coach (handle, name, email, phone, tz, fee_bps, connect_recipient_key) values ($1, $2, $3, $4, $5, 500, $1) returning id`, - [handle, input.name, input.email, input.phone, input.tz], + [handle, input.name, email, input.phone, input.tz], ); return { id: result.rows[0].id, handle, name: input.name, - email: input.email, + email, phone: input.phone, tz: input.tz, connect_recipient_key: handle, diff --git a/src/domain/cascade.ts b/src/domain/cascade.ts index 244ac99..035fae4 100644 --- a/src/domain/cascade.ts +++ b/src/domain/cascade.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import type { DbClient } from '../db/client.js'; -import { sendText } from './outbound.js'; +import { sendEmailMessage, sendText } from './outbound.js'; +import { APP_BASE_URL } from '../config.js'; import { expireLivePendingForCoach } from './assistantPending.js'; import { clipSms, formatConfirmWhen } from '../lib/time.js'; @@ -23,9 +24,35 @@ async function sendUnlessOptedOut(db: DbClient, coachId: number, template: strin await sendText(db, { to, body, coachId, template }); } +/** Tells the coach about their session: a text to their mobile, or, for a + * coach who joined by email and has no mobile yet, an email that links to + * the schedule, where the same YES / Not now buttons wait. */ +async function notifyCoach( + db: DbClient, + info: Pick, + template: string, + text: string, + emailSubject: string, +): Promise { + if (info.coachPhone) { + await sendUnlessOptedOut(db, info.coachId, template, info.coachPhone, text); + return; + } + if (!info.coachEmail) return; + const link = `${process.env.APP_BASE_URL ?? APP_BASE_URL}/app/schedule`; + await sendEmailMessage(db, { + to: info.coachEmail, + subject: emailSubject, + text: `${text.replace(/ Reply YES and I'll ask your roster\./, '')}\n\nOpen your schedule to decide: ${link}`, + coachId: info.coachId, + template, + }); +} + interface SessionOverflowInfo { coachId: number; - coachPhone: string; + coachPhone: string | null; + coachEmail: string | null; capacity: number; overflowThreshold: number; /** "Tue Oct 7, 6:00pm Keeper Group", for the texts. */ @@ -36,7 +63,8 @@ interface SessionOverflowInfo { async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise { const result = await db.query<{ coach_id: number; - coach_phone: string; + coach_phone: string | null; + coach_email: string; capacity: number; capacity_override: number | null; overflow_threshold: number; @@ -45,7 +73,7 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise name: string; backup_pay_cents: number | null; }>( - `select c.id as coach_id, c.phone as coach_phone, st.capacity, s.capacity_override, st.overflow_threshold, + `select c.id as coach_id, c.phone as coach_phone, c.email as coach_email, st.capacity, s.capacity_override, st.overflow_threshold, s.starts_at_utc, s.tz, st.name, st.backup_pay_cents from session s join session_type st on st.id = s.session_type_id @@ -59,6 +87,7 @@ async function loadSessionOverflowInfo(db: DbClient, sessionId: number): Promise return { coachId: row.coach_id, coachPhone: row.coach_phone, + coachEmail: row.coach_email || null, capacity, overflowThreshold: row.overflow_threshold >= 0 ? row.overflow_threshold : capacity, label: `${formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz)} ${row.name}`, @@ -114,12 +143,12 @@ export async function checkOverflow(db: DbClient, sessionId: number): Promise { await resolveAsk(db, sessionId); } + +export interface PendingAsk { + sessionId: number; + label: string; + waiting: number; +} + +/** The coach's open overflow question, for the schedule screen. The same + * question a coach answers by texting YES (SPEC.md §7.3). */ +export async function pendingAskForCoach(db: DbClient, coachId: number): Promise { + const sessionId = await findPendingAskSessionForCoach(db, coachId); + if (sessionId === null) return null; + const info = await loadSessionOverflowInfo(db, sessionId); + if (!info) return null; + return { sessionId, label: info.label, waiting: await countWaiting(db, sessionId) }; +} + +/** The web answer to an overflow ask. Only an unresolved ask on the coach's + * own session can be answered, so the roster is still never asked without + * the coach saying yes. Returns false when there was nothing to answer. */ +export async function answerAskFromWeb(db: DbClient, coachId: number, sessionId: number, yes: boolean): Promise { + const open = await db.query<{ id: number }>( + `select oa.id from overflow_ask oa + join session s on s.id = oa.session_id + join session_type st on st.id = s.session_type_id + where oa.session_id = $1 and st.coach_id = $2 and oa.resolved_at is null`, + [sessionId, coachId], + ); + if (open.rows.length === 0) return false; + if (yes) await startCascade(db, sessionId); + else await resolveAskWithoutCascade(db, sessionId); + return true; +} + diff --git a/src/domain/outbound.ts b/src/domain/outbound.ts index ee126b5..8a494b3 100644 --- a/src/domain/outbound.ts +++ b/src/domain/outbound.ts @@ -1,5 +1,6 @@ import type { DbClient } from '../db/client.js'; import { sendSms } from '../relay/sms.js'; +import { sendEmail } from '../relay/email.js'; import { zonedParts, zonedTimeToUtc } from './scheduling.js'; import { SMS_COACH_DAILY_CAP, @@ -93,9 +94,12 @@ export interface OutboundText { perRecipientDailyMax?: number; } -async function countSent(db: DbClient, where: string, params: unknown[]): Promise { +type Channel = 'sms' | 'email'; + +/** Counts sent rows on one channel; SMS ceilings never count emails. */ +async function countSent(db: DbClient, where: string, params: unknown[], channel: Channel = 'sms'): Promise { const result = await db.query<{ n: string }>( - `select count(*)::text as n from message_log where status = 'sent' and ${where}`, + `select count(*)::text as n from message_log where status = 'sent' and channel = '${channel}' and ${where}`, params, ); return Number(result.rows[0]?.n ?? '0'); @@ -136,11 +140,19 @@ async function ceilingHit(db: DbClient, msg: OutboundText, now: Date): Promise, + body: string, + status: string, + providerId: string | null, + now: Date, + channel: Channel = 'sms', +) { await db.query( - `insert into message_log (to_phone, template, body, provider_id, sent_at, status, coach_id) - values ($1, $2, $3, $4, $5, $6, $7)`, - [msg.to, msg.template, body, providerId, now.toISOString(), status, msg.coachId], + `insert into message_log (to_phone, template, body, provider_id, sent_at, status, coach_id, channel) + values ($1, $2, $3, $4, $5, $6, $7, $8)`, + [msg.to, msg.template, body, providerId, now.toISOString(), status, msg.coachId, channel], ); } @@ -165,3 +177,48 @@ export async function sendText(db: DbClient, msg: OutboundText, now: Date = new throw err; } } + +export interface OutboundEmail { + to: string; + subject: string; + text: string; + html?: string; + coachId: number | null; + template: string; + /** Optional ceiling on this template to this address in 24 hours. */ + perRecipientDailyMax?: number; +} + +/** The only way Coachatron sends an email: logged beside texts in + * message_log (channel 'email') and capped per address when asked. */ +export async function sendEmailMessage(db: DbClient, msg: OutboundEmail, now: Date = new Date()): Promise { + if (msg.perRecipientDailyMax !== undefined) { + const since = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const n = await countSent(db, 'to_phone = $1 and template = $2 and sent_at > $3', [msg.to, msg.template, since], 'email'); + if (n >= msg.perRecipientDailyMax) { + await log(db, msg, msg.subject, 'capped-recipient-daily', null, now, 'email'); + return 'capped'; + } + } + try { + const sent = await sendEmail({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html }); + await log(db, msg, msg.subject, 'sent', sent.id || null, now, 'email'); + return 'sent'; + } catch (err) { + await log(db, msg, msg.subject, 'failed', null, now, 'email'); + throw err; + } +} + +/** A sign-in code by email. The code is in the subject so it shows in the + * inbox preview, and the body has no link for click tracking to rewrite. */ +export function signInCodeEmail(code: string): { subject: string; text: string; html: string } { + return { + subject: `Your Coachatron code: ${code}`, + text: `Your Coachatron sign-in code is ${code}. It expires in 10 minutes. If you didn't ask for it, ignore this email.`, + html: `

Your Coachatron sign-in code is

+

${code}

+

It expires in 10 minutes. If you didn't ask for it, ignore this email.

`, + }; +} + diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index e29e43c..d74d181 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -16,11 +16,20 @@ export function parseCookies(header: string | undefined): Record return out; } +/** Secure whenever the site is served over https (every Railway + * environment); plain http only for a local APP_BASE_URL. Browsers accept + * Secure cookies on http://localhost too. */ +function secureFlag(): string[] { + const base = process.env.APP_BASE_URL ?? 'https://coachatron.com'; + return base.startsWith('https://') ? ['Secure'] : []; +} + export function serializeCookie(name: string, value: string, maxAgeSeconds: number): string { const parts = [ `${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', + ...secureFlag(), 'SameSite=Lax', `Max-Age=${maxAgeSeconds}`, ]; @@ -28,5 +37,5 @@ export function serializeCookie(name: string, value: string, maxAgeSeconds: numb } export function clearCookie(name: string): string { - return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; + return [`${name}=`, 'Path=/', 'HttpOnly', ...secureFlag(), 'SameSite=Lax', 'Max-Age=0'].join('; '); } diff --git a/src/relay/email.ts b/src/relay/email.ts index 25dfbcf..d2e1cda 100644 --- a/src/relay/email.ts +++ b/src/relay/email.ts @@ -1,25 +1,35 @@ import { relayFetch } from './http.js'; +import { coachatronEmailFrom } from '../config.js'; export interface SendEmailRequest { to: string; subject: string; text: string; + html?: string; } +/** POST /email/send on the relay, in its native shape: to, subject, text or + * html, and optional from / fromName. With no from, the relay sends from its + * shared, authenticated sender; COACHATRON_EMAIL_FROM switches to + * no-reply@coachatron.com once that domain is authenticated in SendGrid. */ export async function sendEmail(req: SendEmailRequest): Promise<{ id: string }> { + const from = coachatronEmailFrom(); const res = await relayFetch( '/email/send', { method: 'POST', body: JSON.stringify({ - personalizations: [{ to: [{ email: req.to }] }], - from: { email: 'noreply@coachatron.com', name: 'Coachatron' }, + to: req.to, subject: req.subject, - content: [{ type: 'text/plain', value: req.text }], + text: req.text, + ...(req.html ? { html: req.html } : {}), + ...(from ? { from } : {}), + fromName: 'Coachatron', }), }, { appEnv: true }, ); if (!res.ok) throw new Error(`relay email send failed: ${res.status}`); - return (await res.json()) as { id: string }; + const body = (await res.json().catch(() => ({}))) as { email?: { messageId?: string }; id?: string }; + return { id: String(body.email?.messageId ?? body.id ?? '') }; } diff --git a/src/routes/coach.ts b/src/routes/coach.ts index a6aba42..45c7eb7 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -1,20 +1,26 @@ import { Router, type Request, type Response, type NextFunction } from 'express'; import { getDb, type DbClient } from '../db/client.js'; -import { sendText } from '../domain/outbound.js'; +import { isDomesticDestination, sendEmailMessage, sendText, signInCodeEmail } from '../domain/outbound.js'; import { normalizePhone, + normalizeEmail, + parseContact, + contactValue, + findCoachByContact, + findCoachByEmail, + setCoachPhone, + type Contact, createOtp, checkOtp, consumeOtp, createCoachSession, getCoachBySessionToken, - findCoachByPhone, findCoachById, createCoach, getConnectRecipientKey, type CoachRow, } from '../domain/auth.js'; -import { APP_BASE_URL, APP_FEE_BPS, OTP_DAILY_MAX_PER_PHONE, SELLER_AGREEMENT_VERSION } from '../config.js'; +import { APP_BASE_URL, APP_FEE_BPS, OTP_DAILY_MAX_PER_CONTACT, SELLER_AGREEMENT_VERSION } from '../config.js'; import { acceptSellerAgreement, getSellerStatus, startSellerOnboarding } from '../relay/seller.js'; import { createWeeklySlots, deactivateSlot, listWeeklySlots, type WeeklySlot } from '../domain/scheduling.js'; import { coachNav, html, page, raw } from '../lib/html.js'; @@ -23,6 +29,7 @@ import { getPackagesForCoach, getPlansForCoach, createPackage, createPlan } from import { summarizeMoney } from '../domain/money.js'; import { handleCoachMessage, loadScheduleAssistant, resolvePendingForCoach } from '../domain/assistant.js'; import { clearCookie, parseCookies, serializeCookie } from '../lib/cookies.js'; +import { answerAskFromWeb, pendingAskForCoach, type PendingAsk } from '../domain/cascade.js'; import { readStartText, START_COOKIE } from '../lib/startCookie.js'; import { coachHasSchedule, @@ -93,61 +100,87 @@ async function requireAuth(req: Request, res: Response, next: NextFunction): Pro // ---- Screen 1: sign in ---- -function renderPhoneForm(value = '', error?: string, saved = false) { +// One box: an email gets a code by email (works anywhere); a US or Canadian +// mobile gets a text code. Text codes reach only +1 numbers (SPEC.md §10). +function renderContactForm(value = '', error?: string, saved = false) { return page( 'Sign in', html`

Coachatron

-

${saved ? "Saved. Now your phone. We'll text a code, then show your week." : 'Enter your phone number to sign in or sign up.'}

+

${saved ? "Saved. Now your email or mobile. We'll send a code, then show your week." : 'Sign in or sign up with your email or mobile number.'}

- - + +
${error ? html`

${error}

` : raw('')}`, ); } +/** The submitted contact: the new one-box field, or the older phone field. */ +function contactField(body: unknown): string { + return field(body, 'contact') || field(body, 'to') || field(body, 'phone'); +} + coachRouter.get('/signin', (req, res) => { - res.status(200).send(renderPhoneForm('', undefined, Boolean(readStartText(req.headers.cookie)))); + res.status(200).send(renderContactForm('', undefined, Boolean(readStartText(req.headers.cookie)))); }); coachRouter.post('/signin/otp', async (req, res) => { const saved = Boolean(readStartText(req.headers.cookie)); - const raw_phone = field(req.body, 'phone'); - const phone = normalizePhone(raw_phone); - if (!phone) { - res.status(422).send(renderPhoneForm(raw_phone, 'Enter a valid phone number.', saved)); + const rawContact = contactField(req.body); + const contact = parseContact(rawContact); + if (!contact) { + res.status(422).send(renderContactForm(rawContact, 'Enter an email address or a mobile number.', saved)); return; } const db = getDb(); - const code = await createOtp(db, phone); - const outcome = await sendText(db, { - to: phone, - body: `Coachatron: your sign-in code is ${code}. Expires in 10 minutes.`, - coachId: null, - template: 'otp', - perRecipientDailyMax: OTP_DAILY_MAX_PER_PHONE, - }); + if (contact.kind === 'phone' && !isDomesticDestination(contact.phone)) { + res.status(422).send(renderContactForm(rawContact, "We can't text that number yet. Use your email instead.", saved)); + return; + } + const code = await createOtp(db, contact); + const outcome = + contact.kind === 'email' + ? await sendEmailMessage(db, { + to: contact.email, + ...signInCodeEmail(code), + coachId: null, + template: 'otp', + perRecipientDailyMax: OTP_DAILY_MAX_PER_CONTACT, + }) + : await sendText(db, { + to: contact.phone, + body: `Coachatron: your sign-in code is ${code}. Expires in 10 minutes.`, + coachId: null, + template: 'otp', + perRecipientDailyMax: OTP_DAILY_MAX_PER_CONTACT, + }); if (outcome !== 'sent') { + const what = contact.kind === 'email' ? 'this email' : 'this number'; res .status(outcome === 'refused' ? 422 : 429) .send( - renderPhoneForm( - raw_phone, + renderContactForm( + rawContact, outcome === 'refused' - ? 'Use a US or Canadian mobile number.' - : 'Too many codes for this number today. Try again tomorrow.', + ? "We can't text that number yet. Use your email instead." + : `Too many codes for ${what} today. Try again tomorrow.`, saved, ), ); return; } - res.redirect(303, `/signin/verify?phone=${encodeURIComponent(phone)}`); + res.redirect(303, `/signin/verify?to=${encodeURIComponent(contactValue(contact))}`); }); coachRouter.get('/signin/verify', (req, res) => { - const phone = typeof req.query.phone === 'string' ? req.query.phone : ''; - res.status(200).send(renderVerifyForm(phone, {})); + const to = typeof req.query.to === 'string' ? req.query.to : typeof req.query.phone === 'string' ? req.query.phone : ''; + const contact = parseContact(to); + if (!contact) { + res.redirect(303, '/signin'); + return; + } + res.status(200).send(renderVerifyForm(contact, {})); }); // The browser knows the coach's timezone; a typed IANA name was a @@ -157,24 +190,27 @@ const TZ_SCRIPT = raw( ); function renderVerifyForm( - phone: string, + contact: Contact, values: { code?: string; name?: string; email?: string; tz?: string }, error?: string, ) { + const to = contactValue(contact); return page( 'Verify', html`

Enter your code

-

We texted a 6-digit code to ${phone}.

+

We ${contact.kind === 'email' ? 'emailed' : 'texted'} a 6-digit code to ${to}.${contact.kind === 'email' ? ' Check spam if it is not there in a minute.' : ''}

- +

New here? Just your name. Everything else can wait.

- - + ${contact.kind === 'phone' + ? html` + ` + : raw('')} @@ -185,27 +221,44 @@ function renderVerifyForm( } coachRouter.post('/signin/verify', async (req, res) => { - const phone = field(req.body, 'phone'); + const contact = parseContact(contactField(req.body)); const code = field(req.body, 'code'); const name = field(req.body, 'name'); const email = field(req.body, 'email'); const tz = field(req.body, 'tz'); + if (!contact) { + res.redirect(303, '/signin'); + return; + } const db = getDb(); - const otpId = await checkOtp(db, phone, code); + const otpId = await checkOtp(db, contact, code); if (!otpId) { - res.status(401).send(renderVerifyForm(phone, { code, name, email, tz }, 'Invalid or expired code.')); + res.status(401).send(renderVerifyForm(contact, { code, name, email, tz }, 'Invalid or expired code.')); return; } - let coach: CoachRow | null = await findCoachByPhone(db, phone); + let coach: CoachRow | null = await findCoachByContact(db, contact); if (!coach) { if (!name) { - res.status(422).send(renderVerifyForm(phone, { code, name, email, tz }, 'Enter your name.')); + res.status(422).send(renderVerifyForm(contact, { code, name, email, tz }, 'Enter your name.')); + return; + } + // An optional email given on a phone join must not belong to another coach. + const extraEmail = contact.kind === 'phone' ? normalizeEmail(email) : null; + if (extraEmail && (await findCoachByEmail(db, extraEmail))) { + res + .status(422) + .send(renderVerifyForm(contact, { code, name, email, tz }, 'That email already has an account. Sign in with it instead.')); return; } await consumeOtp(db, otpId); - coach = await createCoach(db, { phone, name, email, tz: isValidTimeZone(tz) ? tz : DEFAULT_TZ }); + coach = await createCoach(db, { + phone: contact.kind === 'phone' ? contact.phone : null, + email: contact.kind === 'email' ? contact.email : (extraEmail ?? ''), + name, + tz: isValidTimeZone(tz) ? tz : DEFAULT_TZ, + }); } else { await consumeOtp(db, otpId); } @@ -518,6 +571,15 @@ coachRouter.get('/app/schedule', requireAuth, async (req, res) => { return; } const live = req.query.published === '1' ? renderLiveCard(coach, await paymentsConnected(coach)) : raw(''); + const askCard = renderAskCard(await pendingAskForCoach(db, coachId)); + const phoneCard = coach.phone + ? req.query.phone === 'done' + ? html`

Texts about your sessions will go to ${coach.phone}.

` + : raw('') + : renderAddPhoneCard( + typeof req.query.phone === 'string' ? req.query.phone : '', + typeof req.query.to === 'string' ? req.query.to : '', + ); const sessions = await db.query<{ id: number; starts_at_utc: string; @@ -546,6 +608,8 @@ coachRouter.get('/app/schedule', requireAuth, async (req, res) => { ${coachNav('schedule')}

Coming up

${live} + ${askCard} + ${phoneCard}

Add more by talking

${assistant.reply ? html`
@@ -581,6 +645,105 @@ coachRouter.get('/app/schedule', requireAuth, async (req, res) => { ); }); +// ---- Screen 2 actions: the overflow question and adding a mobile ---- + +function renderAskCard(ask: PendingAsk | null) { + if (!ask) return raw(''); + return html`
+

${ask.label} is full, ${ask.waiting} waiting. Open a second group? We'll ask your roster one at a time.

+ + + + +
`; +} + +const PHONE_NOTICES: Record = { + bad: 'Enter a US or Canadian mobile number.', + capped: 'Too many codes for that number today. Try again tomorrow.', + wrong: 'That code did not match. Try again.', + taken: 'That number is already on another Coachatron account.', +}; + +/** For a coach who joined by email: add a mobile to get texts when a + * session fills, and to run the week by text. Verified with a text code. */ +function renderAddPhoneCard(state: string, to: string) { + const notice = Object.hasOwn(PHONE_NOTICES, state) ? PHONE_NOTICES[state] : null; + const parsed = to ? normalizePhone(to) : null; + if ((state === 'sent' || state === 'wrong') && parsed) { + return html`
+

We texted a code to ${parsed}.

+
+ + + + +
+ ${notice ? html`

${notice}

` : raw('')} +
`; + } + return html`
+

Get a text when a session fills, and run your week by text.

+
+ + + +
+ ${notice ? html`

${notice}

` : raw('')} +
`; +} + +coachRouter.post('/app/overflow/:sessionId', requireAuth, async (req, res) => { + const db = getDb(); + const coachId = res.locals.coachId as number; + await answerAskFromWeb(db, coachId, Number(req.params.sessionId), field(req.body, 'answer') === 'yes'); + res.redirect(303, '/app/schedule'); +}); + +coachRouter.post('/app/phone', requireAuth, async (req, res) => { + const db = getDb(); + const phone = normalizePhone(field(req.body, 'phone')); + if (!phone || !isDomesticDestination(phone)) { + res.redirect(303, '/app/schedule?phone=bad'); + return; + } + const code = await createOtp(db, { kind: 'phone', phone }); + const outcome = await sendText(db, { + to: phone, + body: `Coachatron: your code to add this mobile is ${code}. Expires in 10 minutes.`, + coachId: res.locals.coachId as number, + template: 'otp', + perRecipientDailyMax: OTP_DAILY_MAX_PER_CONTACT, + }); + if (outcome !== 'sent') { + res.redirect(303, `/app/schedule?phone=${outcome === 'capped' ? 'capped' : 'bad'}`); + return; + } + res.redirect(303, `/app/schedule?phone=sent&to=${encodeURIComponent(phone)}`); +}); + +coachRouter.post('/app/phone/verify', requireAuth, async (req, res) => { + const db = getDb(); + const coachId = res.locals.coachId as number; + const phone = normalizePhone(field(req.body, 'phone')); + if (!phone) { + res.redirect(303, '/app/schedule?phone=bad'); + return; + } + const otpId = await checkOtp(db, { kind: 'phone', phone }, field(req.body, 'code')); + if (!otpId) { + res.redirect(303, `/app/schedule?phone=wrong&to=${encodeURIComponent(phone)}`); + return; + } + const result = await setCoachPhone(db, coachId, phone); + if (result === 'taken') { + res.redirect(303, '/app/schedule?phone=taken'); + return; + } + await consumeOtp(db, otpId); + res.redirect(303, '/app/schedule?phone=done'); +}); + coachRouter.post('/app/setup', requireAuth, async (req, res) => { const db = getDb(); const coach = await findCoachById(db, res.locals.coachId as number); diff --git a/test/coach-onboarding.test.ts b/test/coach-onboarding.test.ts index 7e400a6..8d3e79e 100644 --- a/test/coach-onboarding.test.ts +++ b/test/coach-onboarding.test.ts @@ -24,7 +24,7 @@ test('coach onboarding: OTP sign-in, session type, weekly schedule, public page' }); assert.equal(otpRes.status, 303); const location = otpRes.headers.get('location') ?? ''; - const phone = decodeURIComponent(new URL(location, base).searchParams.get('phone') ?? ''); + const phone = decodeURIComponent(new URL(location, base).searchParams.get('to') ?? ''); assert.equal(phone, '+15551234567'); const sentSms = relay.sms.find((m) => m.to === '+15551234567'); diff --git a/test/email-signin.test.ts b/test/email-signin.test.ts new file mode 100644 index 0000000..11f6aa3 --- /dev/null +++ b/test/email-signin.test.ts @@ -0,0 +1,279 @@ +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 { seedBookedSession, seedRosterMember, seedWaitlistEntry } from './helpers/fixtures.js'; +import { createCoach, createCoachSession, parseContact } from '../src/domain/auth.js'; +import { checkOverflow } from '../src/domain/cascade.js'; +import { resetComplete, setComplete } from '../src/llm/complete.js'; +import type { DbClient } from '../src/db/client.js'; + +function cookieFrom(res: Response, name: string): string | null { + const header = (res.headers.getSetCookie?.() ?? []).find((c) => c.startsWith(`${name}=`)); + const match = header ? /^[^=]+=([^;]*)/.exec(header) : null; + return match ? `${name}=${match[1]}` : null; +} + +function post(base: string, path: string, body: Record, cookie = ''): Promise { + return fetch(`${base}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + redirect: 'manual', + }); +} + +function codeIn(subject: string): string { + const m = /(\d{6})/.exec(subject); + assert.ok(m, `no code in ${subject}`); + return m[1]; +} + +async function count(db: DbClient, sql: string, params: unknown[] = []): Promise { + return Number((await db.query<{ n: string }>(`select count(*)::text as n from ${sql}`, params)).rows[0].n); +} + +/** A coach who joined by email: no phone, a session that is full, one waiting. */ +async function seedEmailCoachWithFullSession(db: DbClient) { + const coach = await createCoach(db, { phone: null, email: 'dana@example.com', name: 'Dana Keeper', tz: 'America/Chicago' }); + const type = await db.query<{ id: number }>( + `insert into session_type (coach_id, name, duration_min, capacity, price_cents, active) + values ($1, 'Keeper Group', 60, 1, 3500, true) returning id`, + [coach.id], + ); + const session = await db.query<{ id: number }>( + `insert into session (session_type_id, starts_at_utc, tz, status) values ($1, $2, 'America/Chicago', 'scheduled') returning id`, + [type.rows[0].id, new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString()], + ); + const sessionId = session.rows[0].id; + await seedBookedSession(db, sessionId, 'Sam Reyes', '+15550000001'); + await seedWaitlistEntry(db, sessionId, 'Mia Ortiz', '+15550000002'); + return { coach, sessionId }; +} + +test('parseContact reads an email or a mobile from one box', () => { + assert.deepEqual(parseContact(' Dana@Example.COM '), { kind: 'email', email: 'dana@example.com' }); + assert.deepEqual(parseContact('(555) 555-0100'), { kind: 'phone', phone: '+15555550100' }); + assert.deepEqual(parseContact('+44 7700 900123'), { kind: 'phone', phone: '+447700900123' }); + assert.equal(parseContact('dana@'), null); + assert.equal(parseContact('hello'), null); +}); + +test('a coach joins with just an email: code by email, no phone needed', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + await withServer(async (base) => { + const form = await (await fetch(`${base}/signin`)).text(); + assert.match(form, /Email or mobile number/); + assert.match(form, /name="contact"/); + + const ask = await post(base, '/signin/otp', { contact: ' Dana@Example.com ' }); + assert.equal(ask.status, 303); + assert.equal(ask.headers.get('location'), '/signin/verify?to=dana%40example.com'); + assert.equal(relay.sms.length, 0, 'an email join sends no text'); + assert.equal(relay.emails.length, 1); + const sent = relay.emails[0]; + assert.equal(sent.to, 'dana@example.com'); + assert.equal(sent.fromName, 'Coachatron'); + assert.equal(sent.from, null, 'the relay default sender until COACHATRON_EMAIL_FROM is set'); + assert.match(sent.subject, /^Your Coachatron code: \d{6}$/); + assert.doesNotMatch(sent.text, /https?:\/\//, 'a code, never a link'); + + const verifyPage = await (await fetch(`${base}${ask.headers.get('location')}`)).text(); + assert.match(verifyPage, /We emailed a 6-digit code to dana@example\.com/); + assert.doesNotMatch(verifyPage, /Email \(optional\)/); + + const done = await post(base, '/signin/verify', { + to: 'dana@example.com', + code: codeIn(sent.subject), + name: 'Dana Keeper', + tz: 'America/Denver', + }); + assert.equal(done.status, 303); + const session = cookieFrom(done, 'cx_session'); + assert.ok(session); + assert.match((done.headers.getSetCookie?.() ?? []).join('\n'), /HttpOnly; Secure; SameSite=Lax/); + + const coach = await db.query<{ email: string; phone: string | null; tz: string }>('select email, phone, tz from coach'); + assert.deepEqual(coach.rows, [{ email: 'dana@example.com', phone: null, tz: 'America/Denver' }]); + const logged = await db.query<{ channel: string; status: string }>("select channel, status from message_log where template = 'otp'"); + assert.deepEqual(logged.rows, [{ channel: 'email', status: 'sent' }]); + + // Coming back with different capitals signs in to the same coach. + await post(base, '/signin/otp', { contact: 'DANA@example.com' }); + const again = await post(base, '/signin/verify', { to: 'dana@example.com', code: codeIn(relay.emails[1].subject) }); + assert.equal(again.status, 303); + assert.equal(await count(db, 'coach'), 1); + }); + }); +}); + +test('COACHATRON_EMAIL_FROM switches the sender', async () => { + const prev = process.env.COACHATRON_EMAIL_FROM; + process.env.COACHATRON_EMAIL_FROM = 'no-reply@coachatron.com'; + try { + await withRelay(async (relay) => { + await freshDb(); + await withServer(async (base) => { + await post(base, '/signin/otp', { contact: 'dana@example.com' }); + assert.equal(relay.emails[0].from, 'no-reply@coachatron.com'); + }); + }); + } finally { + if (prev === undefined) delete process.env.COACHATRON_EMAIL_FROM; + else process.env.COACHATRON_EMAIL_FROM = prev; + } +}); + +test('a foreign mobile is pointed to email; codes are capped and single-use', async () => { + await withRelay(async (relay) => { + await freshDb(); + await withServer(async (base) => { + const abroad = await post(base, '/signin/otp', { contact: '+63 917 123 4567' }); + assert.equal(abroad.status, 422); + assert.match(await abroad.text(), /Use your email instead/); + assert.equal(relay.sms.length, 0); + + const bad = await post(base, '/signin/otp', { contact: 'not an address' }); + assert.equal(bad.status, 422); + + for (let i = 0; i < 5; i += 1) assert.equal((await post(base, '/signin/otp', { contact: 'cap@example.com' })).status, 303); + const sixth = await post(base, '/signin/otp', { contact: 'cap@example.com' }); + assert.equal(sixth.status, 429); + assert.match(await sixth.text(), /Too many codes for this email/); + assert.equal(relay.emails.length, 5); + + const wrong = await post(base, '/signin/verify', { to: 'cap@example.com', code: '000000', name: 'Cap' }); + assert.equal(wrong.status, 401); + const code = codeIn(relay.emails[4].subject); + assert.equal((await post(base, '/signin/verify', { to: 'cap@example.com', code, name: 'Cap' })).status, 303); + assert.equal((await post(base, '/signin/verify', { to: 'cap@example.com', code })).status, 401, 'a code works once'); + }); + }); +}); + +test('a phone join cannot claim an email another coach already has', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + await createCoach(db, { phone: null, email: 'dana@example.com', name: 'Dana', tz: 'America/Chicago' }); + await withServer(async (base) => { + await post(base, '/signin/otp', { contact: '5550007777' }); + const code = /code is (\d{6})/.exec(relay.sms.at(-1)!.body)![1]; + const res = await post(base, '/signin/verify', { to: '+15550007777', code, name: 'Other', email: 'DANA@example.com' }); + assert.equal(res.status, 422); + assert.match(await res.text(), /already has an account/); + assert.equal(await count(db, 'coach'), 1); + }); + }); +}); + +test('the landing box plus an email join leads to a draft and Publish', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + setComplete( + fakeComplete({ + coach_timezone: null, + session_types: [{ key: 'g', name: 'Evening group', duration_min: 60, capacity: 8, price_cents: 3500, backup_pay_cents: null }], + weekly: [{ type_key: 'g', weekday: 2, time_local: '18:00', location: null }], + packages: [], + plans: [], + }), + ); + try { + await withServer(async (base) => { + const start = await post(base, '/start', { text: 'Tuesdays at 6, an hour, 8 athletes, $35' }); + const startCookie = cookieFrom(start, 'cx_start')!; + const signin = await (await fetch(`${base}/signin`, { headers: { cookie: startCookie } })).text(); + assert.match(signin, /Saved\. Now your email or mobile/); + + await post(base, '/signin/otp', { contact: 'alex@example.com' }, startCookie); + const verify = await post( + base, + '/signin/verify', + { to: 'alex@example.com', code: codeIn(relay.emails[0].subject), name: 'Alex Coach', tz: 'America/Chicago' }, + startCookie, + ); + assert.equal(verify.headers.get('location'), '/app/schedule?setup=1'); + const session = cookieFrom(verify, 'cx_session')!; + const draftId = String((await db.query<{ id: number }>('select id from setup_draft')).rows[0].id); + const published = await post(base, '/app/setup/publish', { id: draftId }, session); + assert.equal(published.headers.get('location'), '/app/schedule?published=1'); + assert.ok((await count(db, 'session')) >= 8); + }); + } finally { + resetComplete(); + } + }); +}); + +test('a coach with no phone gets the overflow question by email and answers it on the schedule', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + const { coach, sessionId } = await seedEmailCoachWithFullSession(db); + const backup = await seedRosterMember(db, coach.id, 'Backup Bailey', 0); + const outsider = await createCoach(db, { phone: '+15550009999', email: '', name: 'Outsider', tz: 'America/Chicago' }); + + await checkOverflow(db, sessionId); + assert.equal(relay.sms.length, 0, 'no text to a coach with no phone'); + const ask = relay.emails.find((e) => e.to === 'dana@example.com'); + assert.ok(ask); + assert.match(ask.subject, /Keeper Group is full, 1 waiting/); + assert.match(ask.text, /\/app\/schedule/); + assert.doesNotMatch(ask.text, /Reply YES/); + + await withServer(async (base) => { + const cookie = `cx_session=${await createCoachSession(db, coach.id)}`; + const schedule = await (await fetch(`${base}/app/schedule`, { headers: { cookie } })).text(); + assert.match(schedule, /Ask my roster/); + assert.match(schedule, new RegExp(`action="/app/overflow/${sessionId}"`)); + + // Another coach cannot answer this coach's question. + const other = `cx_session=${await createCoachSession(db, outsider.id)}`; + await post(base, `/app/overflow/${sessionId}`, { answer: 'yes' }, other); + assert.equal(relay.sms.filter((m) => m.to === backup.phone).length, 0); + + const yes = await post(base, `/app/overflow/${sessionId}`, { answer: 'yes' }, cookie); + assert.equal(yes.status, 303); + assert.equal(relay.sms.filter((m) => m.to === backup.phone).length, 1, 'the roster is asked after the coach says yes'); + const after = await (await fetch(`${base}/app/schedule`, { headers: { cookie } })).text(); + assert.doesNotMatch(after, /Ask my roster/); + }); + }); +}); + +test('a coach who joined by email adds a mobile with a text code', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + const { coach } = await seedEmailCoachWithFullSession(db); + await createCoach(db, { phone: '+15550004444', email: '', name: 'Taken', tz: 'America/Chicago' }); + await withServer(async (base) => { + const cookie = `cx_session=${await createCoachSession(db, coach.id)}`; + const schedule = await (await fetch(`${base}/app/schedule`, { headers: { cookie } })).text(); + assert.match(schedule, /Your mobile \(US or Canada\)/); + + assert.equal((await post(base, '/app/phone', { phone: '+44 7700 900123' }, cookie)).headers.get('location'), '/app/schedule?phone=bad'); + + const sent = await post(base, '/app/phone', { phone: '(555) 000-3333' }, cookie); + assert.equal(sent.headers.get('location'), '/app/schedule?phone=sent&to=%2B15550003333'); + const code = /is (\d{6})/.exec(relay.sms.at(-1)!.body)![1]; + const wrong = await post(base, '/app/phone/verify', { phone: '+15550003333', code: '000000' }, cookie); + assert.equal(wrong.headers.get('location'), '/app/schedule?phone=wrong&to=%2B15550003333'); + const ok = await post(base, '/app/phone/verify', { phone: '+15550003333', code }, cookie); + assert.equal(ok.headers.get('location'), '/app/schedule?phone=done'); + assert.equal((await db.query<{ phone: string }>('select phone from coach where id = $1', [coach.id])).rows[0].phone, '+15550003333'); + const done = await (await fetch(`${base}/app/schedule?phone=done`, { headers: { cookie } })).text(); + assert.match(done, /Texts about your sessions will go to \+15550003333/); + assert.doesNotMatch(done, /Your mobile \(US or Canada\)/); + + // A number that belongs to another coach is refused. + const second = await createCoach(db, { phone: null, email: 'sam@example.com', name: 'Sam', tz: 'America/Chicago' }); + const secondCookie = `cx_session=${await createCoachSession(db, second.id)}`; + await post(base, '/app/phone', { phone: '5550004444' }, secondCookie); + const takenCode = /is (\d{6})/.exec(relay.sms.at(-1)!.body)![1]; + const taken = await post(base, '/app/phone/verify', { phone: '+15550004444', code: takenCode }, secondCookie); + assert.equal(taken.headers.get('location'), '/app/schedule?phone=taken'); + }); + }); +}); diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts index 8be79e9..fc3577a 100644 --- a/test/fakes/relay.ts +++ b/test/fakes/relay.ts @@ -11,6 +11,15 @@ export interface FakeSms { body: string; } +export interface FakeEmail { + to: string; + subject: string; + text: string; + html: string | null; + from: string | null; + fromName: string | null; +} + export interface FakeBuyLink { product: string; seller: string; @@ -37,6 +46,7 @@ export interface FakeOnboard { export interface FakeRelay { url: string; sms: FakeSms[]; + emails: FakeEmail[]; buyLinks: FakeBuyLink[]; onboards: FakeOnboard[]; /** The relay's current agreement_version for every product. */ @@ -66,6 +76,7 @@ export async function startFakeRelay(): Promise { app.use(express.json()); const sms: FakeSms[] = []; + const emails: FakeEmail[] = []; const buyLinks: FakeBuyLink[] = []; const onboards: FakeOnboard[] = []; const sellers = new Map(); @@ -136,9 +147,23 @@ export async function startFakeRelay(): Promise { res.json({ id: `sms_${sms.length}` }); }); + // The relay's native shape: to, subject, text or html, optional from / fromName. app.post('/email/send', (req, res) => { if (!requireApiKey(req, res)) return; - res.json({ id: 'email_1' }); + const { to, subject, text, html, from, fromName } = req.body ?? {}; + if (!to || !subject || (!html && !text)) { + res.status(422).json({ error: true, message: 'to, subject, and html or text are required' }); + return; + } + emails.push({ + to: String(to), + subject: String(subject), + text: String(text ?? ''), + html: html ? String(html) : null, + from: from ? String(from) : null, + fromName: fromName ? String(fromName) : null, + }); + res.status(201).json({ success: true, email: { to, subject, status: 'sent', messageId: `email_${emails.length}` } }); }); const server: Server = createServer(app); @@ -148,6 +173,7 @@ export async function startFakeRelay(): Promise { return Object.assign(relay, { url: base, sms, + emails, buyLinks, onboards, seller: (product: string, sellerKey: string) => sellers.get(id(product, sellerKey)), diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index 05235ca..f5b8b9e 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -19,10 +19,12 @@ export async function seedCoachWithSession( const capacity = opts.capacity ?? 2; const priceCents = opts.priceCents ?? 3500; + const phone = `+1555${Math.floor(1000000 + Math.random() * 8999999)}`; const coach = await createCoach(db, { - phone: `+1555${Math.floor(1000000 + Math.random() * 8999999)}`, + phone, name: 'Jamie Coach', - email: 'jamie@example.com', + // Emails are unique per coach, so each seeded coach gets its own. + email: `jamie${phone.slice(-7)}@example.com`, tz: 'America/Chicago', }); diff --git a/test/journeys.test.ts b/test/journeys.test.ts index 637fb8c..4a91319 100644 --- a/test/journeys.test.ts +++ b/test/journeys.test.ts @@ -47,7 +47,7 @@ test('journey 1: coach signs in, creates a session type, generates a week, gets redirect: 'manual', }); assert.equal(otpRes.status, 303); - const phone = decodeURIComponent(new URL(otpRes.headers.get('location') ?? '', base).searchParams.get('phone') ?? ''); + const phone = decodeURIComponent(new URL(otpRes.headers.get('location') ?? '', base).searchParams.get('to') ?? ''); const sentSms = relay.sms.find((m) => m.to === phone); assert.ok(sentSms, 'coach should have received an OTP by SMS'); diff --git a/test/landing.test.ts b/test/landing.test.ts index 70cfd38..34a6f81 100644 --- a/test/landing.test.ts +++ b/test/landing.test.ts @@ -122,7 +122,7 @@ test('typed journey: landing box, phone, draft, Publish, live link', async () => assert.ok(startCookie); const signin = await (await fetch(`${base}/signin`, { headers: { cookie: startCookie! } })).text(); - assert.match(signin, /Saved\. Now your phone/); + assert.match(signin, /Saved\. Now your email or mobile/); const verify = await signInWithStart(base, relay, '5552220001', startCookie!, { name: 'Alex Coach', diff --git a/test/outbound.test.ts b/test/outbound.test.ts index bff178a..18a624a 100644 --- a/test/outbound.test.ts +++ b/test/outbound.test.ts @@ -123,12 +123,12 @@ test('sign-in codes: five a day per number, domestic numbers only', async () => const abroad = await ask('+44 7700 900123'); assert.equal(abroad.status, 422); - assert.match(await abroad.text(), /US or Canadian/); + assert.match(await abroad.text(), /Use your email instead/); }); }); }); -test('nothing in src sends a text except through the guarded send path', () => { +test('nothing in src sends a text or an email except through the guarded send paths', () => { const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'src'); const offenders: string[] = []; const walk = (dir: string) => { @@ -137,8 +137,8 @@ test('nothing in src sends a text except through the guarded send path', () => { if (statSync(full).isDirectory()) walk(full); else if (full.endsWith('.ts')) { const rel = path.relative(root, full); - if (rel === path.join('relay', 'sms.ts') || rel === path.join('domain', 'outbound.ts')) continue; - if (/\bsendSms\b|relay\/sms\.js/.test(readFileSync(full, 'utf8'))) offenders.push(rel); + if ([path.join('relay', 'sms.ts'), path.join('relay', 'email.ts'), path.join('domain', 'outbound.ts')].includes(rel)) continue; + if (/\bsendSms\b|\bsendEmail\b|relay\/sms\.js|relay\/email\.js/.test(readFileSync(full, 'utf8'))) offenders.push(rel); } } }; From 4a8d355ed3d34a2f6b7b648b69c27dc9a7cd6384 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Fri, 2 Oct 2026 09:28:47 -0500 Subject: [PATCH 2/3] Read inbound texts the way the relay sends them, and only from the relay. The relay forwards Twilio's own form fields (From, Body), signed with x-relay-signature over the raw body. The webhook only read lowercase JSON, so a real text would have been rejected. It now reads the raw body in either shape and, once RELAY_INBOUND_SECRET is set, refuses anything not signed by the relay for this exact URL. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 6 +++ README.md | 8 ++++ ROADMAP.md | 24 ++++++++++ docs/PLATFORM.md | 26 ++++++++--- src/lib/inboundSms.ts | 55 +++++++++++++++++++++++ src/routes/webhooks.ts | 16 ++++--- src/server.ts | 6 +++ test/inbound-sms.test.ts | 95 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 222 insertions(+), 14 deletions(-) create mode 100644 src/lib/inboundSms.ts create mode 100644 test/inbound-sms.test.ts 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(); + } + }); +}); From 3ec87c07641115e68fcd374dea0c02e715d6e666 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Fri, 2 Oct 2026 09:38:53 -0500 Subject: [PATCH 3/3] Keep RELAY_INBOUND_SECRET when Railway config is applied. The web variables in .railway/railway.ts are exhaustive: apply deletes any it omits. The inbound secret is set on all three environments now, so the file preserves it, and the README says to add new variables. Co-Authored-By: Claude Opus 5.5 (1M context) --- .railway/README.md | 3 ++- .railway/railway.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.railway/README.md b/.railway/README.md index 6476535..ec92b6e 100644 --- a/.railway/README.md +++ b/.railway/README.md @@ -16,7 +16,8 @@ railway config apply - **Partial `web`.** The file owns only the web service. Postgres, its volume, and slack-cards are not in it and apply never touches them. - **Variables.** Every value stays in Railway; the file marks each one - `preserve()`. Never put a secret here. + `preserve()`. Never put a secret here. **Add every new web variable to the + file as `preserve()`**: apply deletes any variable the file leaves out. - **Node version.** `RAILPACK_NODE_VERSION` comes from `../.nvmrc`. Railpack reads that variable before `package.json` engines, so it decides the Node a deploy runs. To apply everything except a Node change, set diff --git a/.railway/railway.ts b/.railway/railway.ts index 89722cc..c87c457 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -45,6 +45,7 @@ export default defineRailway((ctx) => { replicas: { "us-east4-eqdc4a": 1 }, domains: env.domains.map((domain) => ({ domain, port: 3000 })), // Values live in Railway, never in this file. preserve() keeps each one. + // Every variable on web must be listed here: apply deletes any it omits. env: { APP_BASE_URL: preserve(), APP_ENV: preserve(), @@ -57,6 +58,7 @@ export default defineRailway((ctx) => { RELAY_API_KEY: preserve(), RELAY_BASE_URL: preserve(), RELAY_CONNECT_PRODUCT: preserve(), + RELAY_INBOUND_SECRET: preserve(), RELAY_WEBHOOK_SECRET: preserve(), SESSION_SECRET: preserve(), STORE_BASE_URL: preserve(),