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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@ 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=
# 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
3 changes: 2 additions & 1 deletion .railway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,24 @@ 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 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
56 changes: 56 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1226,3 +1226,59 @@ 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.

---

## 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.

9 changes: 6 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
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
9 changes: 8 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
18 changes: 18 additions & 0 deletions src/db/migrations/0011_email_signin.sql
Original file line number Diff line number Diff line change
@@ -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';
81 changes: 70 additions & 11 deletions src/domain/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,45 @@ export function normalizePhone(raw: string): string | null {
return null;
}

export async function createOtp(db: DbClient, phone: string): Promise<string> {
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<string> {
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(),
]);
Expand All @@ -29,13 +61,15 @@ export async function createOtp(db: DbClient, phone: string): Promise<string> {
/** 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<number | null> {
export async function checkOtp(db: DbClient, target: string | Contact, code: string): Promise<number | null> {
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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -87,6 +122,27 @@ export async function findCoachByPhone(db: DbClient, phone: string): Promise<Coa
return result.rows[0] ?? null;
}

export async function findCoachByEmail(db: DbClient, email: string): Promise<CoachRow | null> {
const result = await db.query<CoachRow>(
`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<CoachRow | null> {
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<CoachRow | null> {
const result = await db.query<CoachRow>(
'select id, handle, name, email, phone, tz, connect_recipient_key from coach where handle = $1',
Expand Down Expand Up @@ -118,25 +174,28 @@ export async function reserveHandle(db: DbClient, name: string): Promise<string>
}

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<CoachRow> {
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,
Expand Down
Loading
Loading