From c366e6559802c2f100184d7c092546a36ea52751 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 20:48:15 +0000 Subject: [PATCH 1/2] feat(sms): toll-free compliance for consent, inbound auth, and legal pages Co-authored-by: Ricardo Vega --- .env.example | 3 + package-lock.json | 7 + package.json | 1 + src/config.ts | 10 ++ src/db/migrations/0010_sms_consent.sql | 24 ++++ src/domain/assistant.ts | 17 +-- src/domain/auth.ts | 7 +- src/domain/cascade.ts | 39 ++++-- src/domain/outbound.ts | 41 +++++- src/domain/sms-consent.ts | 91 +++++++++++++ src/domain/sms-keywords.ts | 17 +++ src/jobs/tick.ts | 3 +- src/lib/html.ts | 24 +++- src/lib/phone.ts | 14 ++ src/lib/relay-inbound.ts | 40 ++++++ src/relay/sms.ts | 22 +++- src/routes/coach.ts | 27 +++- src/routes/public.ts | 67 +++++++++- src/routes/webhooks.ts | 175 +++++++++++++++++++------ src/server.ts | 5 +- test/assistant.test.ts | 43 +++--- test/booking-payment.test.ts | 2 +- test/coach-onboarding.test.ts | 4 +- test/fakes/relay.ts | 7 +- test/helpers/fixtures.ts | 7 + test/helpers/server.ts | 3 + test/helpers/sms-webhook.ts | 55 ++++++++ test/journeys.test.ts | 13 +- test/legal-pages.test.ts | 33 +++++ test/outbound.test.ts | 19 ++- test/overflow-cascade.test.ts | 20 +-- test/phone.test.ts | 15 +++ test/setup.test.ts | 12 +- test/sms-compliance.test.ts | 164 +++++++++++++++++++++++ 34 files changed, 899 insertions(+), 132 deletions(-) create mode 100644 src/db/migrations/0010_sms_consent.sql create mode 100644 src/domain/sms-consent.ts create mode 100644 src/domain/sms-keywords.ts create mode 100644 src/lib/phone.ts create mode 100644 src/lib/relay-inbound.ts create mode 100644 test/helpers/sms-webhook.ts create mode 100644 test/legal-pages.test.ts create mode 100644 test/phone.test.ts create mode 100644 test/sms-compliance.test.ts diff --git a/.env.example b/.env.example index 2956405..d0776e3 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ RELAY_BASE_URL= RELAY_API_KEY= RELAY_CONNECT_PRODUCT= RELAY_WEBHOOK_SECRET= +RELAY_INBOUND_SECRET= +SMS_WEBHOOK_PUBLIC_URL= +SMS_STATUS_WEBHOOK_PUBLIC_URL= LITELLM_BASE= LITELLM_API_KEY= SESSION_SECRET= diff --git a/package-lock.json b/package-lock.json index 4aec789..c758fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@noctusoft/store-client": "file:packages/store-client", "express": "^5.2.1", + "libphonenumber-js": "^1.13.14", "pg": "^8.23.1" }, "devDependencies": { @@ -2061,6 +2062,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.14", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz", + "integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", diff --git a/package.json b/package.json index c981729..0bc61a6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@noctusoft/store-client": "file:packages/store-client", "express": "^5.2.1", + "libphonenumber-js": "^1.13.14", "pg": "^8.23.1" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index bf817c5..a70093e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,3 +31,13 @@ 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; + +export const SMS_BRAND = 'Coachatron'; +export const SMS_PURPOSE = 'booking confirmations, reminders, and session updates'; +export const SMS_CONSENT_TEXT_VERSION = 'v1'; +export const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL ?? 'support@coachatron.com'; +export const RELAY_INBOUND_SECRET = process.env.RELAY_INBOUND_SECRET ?? ''; +export const SMS_WEBHOOK_PUBLIC_URL = + process.env.SMS_WEBHOOK_PUBLIC_URL ?? `${APP_BASE_URL.replace(/\/$/, '')}/webhooks/sms`; +export const SMS_STATUS_WEBHOOK_PUBLIC_URL = + process.env.SMS_STATUS_WEBHOOK_PUBLIC_URL ?? `${APP_BASE_URL.replace(/\/$/, '')}/webhooks/sms-status`; diff --git a/src/db/migrations/0010_sms_consent.sql b/src/db/migrations/0010_sms_consent.sql new file mode 100644 index 0000000..de28375 --- /dev/null +++ b/src/db/migrations/0010_sms_consent.sql @@ -0,0 +1,24 @@ +-- SMS consent and roster double opt-in (toll-free compliance). + +create table if not exists sms_consent ( + id serial primary key, + phone text not null, + purpose text not null, + consent_text_version text not null, + source text not null, + ip text, + user_agent text, + consented_at timestamptz not null default now(), + revoked_at timestamptz, + unique (phone, purpose) +); + +create index if not exists sms_consent_phone on sms_consent (phone); + +create table if not exists sms_consent_pending ( + phone text primary key, + added_by_coach_id integer not null references coach(id), + added_for_label text not null, + purpose text not null, + sent_at timestamptz not null default now() +); diff --git a/src/domain/assistant.ts b/src/domain/assistant.ts index f23a822..2c25b64 100644 --- a/src/domain/assistant.ts +++ b/src/domain/assistant.ts @@ -1,6 +1,6 @@ import type { DbClient } from '../db/client.js'; import type { CoachRow } from './auth.js'; -import { startCascade, isOptedOut } from './cascade.js'; +import { startCascade } from './cascade.js'; import { summarizeMoney } from './money.js'; import { addCalendarDays, zonedParts, zonedTimeToUtc } from './scheduling.js'; import { APP_BASE_URL } from '../config.js'; @@ -542,14 +542,12 @@ async function executeCancel(db: DbClient, coach: CoachRow, sessionId: number): await db.query('update credit set remaining = remaining + 1 where id = $1', [athlete.credit_id]); credits += 1; } - if (!(await isOptedOut(db, athlete.contact_phone))) { - await sendText(db, { - to: athlete.contact_phone, - body: `${athlete.athlete_name}'s ${row.name} on ${formatLocal(row.starts_at_utc, row.tz)} has been cancelled.`, - coachId: coach.id, - template: 'session-cancelled', - }); - } + await sendText(db, { + to: athlete.contact_phone, + body: `${athlete.athlete_name}'s ${row.name} on ${formatLocal(row.starts_at_utc, row.tz)} has been cancelled.`, + coachId: coach.id, + template: 'session-cancelled', + }); } await db.query("update session set status = 'cancelled' where id = $1", [sessionId]); @@ -629,7 +627,6 @@ async function executeBroadcast(db: DbClient, coach: CoachRow, classified: Class ); let sent = 0; for (const athlete of athletes.rows) { - if (await isOptedOut(db, athlete.contact_phone)) continue; const outcome = await sendText(db, { to: athlete.contact_phone, body, coachId: coach.id, template: 'broadcast' }); if (outcome === 'sent') sent += 1; } diff --git a/src/domain/auth.ts b/src/domain/auth.ts index 71564b1..c22761e 100644 --- a/src/domain/auth.ts +++ b/src/domain/auth.ts @@ -1,15 +1,12 @@ import crypto from 'node:crypto'; import type { DbClient } from '../db/client.js'; +import { toE164 } from '../lib/phone.js'; const OTP_TTL_MINUTES = 10; const SESSION_TTL_DAYS = 30; export function normalizePhone(raw: string): string | null { - const digits = raw.replace(/[^0-9]/g, ''); - if (digits.length === 10) return `+1${digits}`; - if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`; - if (raw.startsWith('+') && digits.length >= 8 && digits.length <= 15) return `+${digits}`; - return null; + return toE164(raw); } export async function createOtp(db: DbClient, phone: string): Promise { diff --git a/src/domain/cascade.ts b/src/domain/cascade.ts index 244ac99..a50c946 100644 --- a/src/domain/cascade.ts +++ b/src/domain/cascade.ts @@ -3,6 +3,25 @@ import type { DbClient } from '../db/client.js'; import { sendText } from './outbound.js'; import { expireLivePendingForCoach } from './assistantPending.js'; import { clipSms, formatConfirmWhen } from '../lib/time.js'; +import { SMS_PURPOSE } from '../config.js'; +import { hasActiveConsent, queuePendingConsent } from './sms-consent.js'; + +export async function sendRosterConsentRequest( + db: DbClient, + coachId: number, + coachName: string, + phone: string, +): Promise { + await queuePendingConsent(db, phone, coachId, coachName); + await sendText(db, { + to: phone, + coachId, + template: 'consent-request', + body: clipSms( + `Coachatron: ${coachName} added this number for ${SMS_PURPOSE}. Reply YES to receive these texts. Reply STOP to opt out.`, + ), + }); +} export const OVERFLOW_OFFER_TTL_MINUTES = 20; @@ -15,11 +34,9 @@ export async function upsertOptOut(db: DbClient, phone: string): Promise { await db.query('insert into opt_out (phone) values ($1) on conflict (phone) do nothing', [phone]); } -/** All cascade-initiated (non-critical) sends go through this, never the - * bare relay client, so an opted-out number is silently skipped rather than - * texted again. SPEC.md §10: "STOP handling ... required, not optional." */ +/** All cascade-initiated (non-critical) sends go through sendText, which + * enforces opt-out and consent. */ async function sendUnlessOptedOut(db: DbClient, coachId: number, template: string, to: string, body: string): Promise { - if (await isOptedOut(db, to)) return; await sendText(db, { to, body, coachId, template }); } @@ -169,12 +186,19 @@ export async function startCascade(db: DbClient, sessionId: number): Promise( 'select id from overflow_ask where session_id = $1 and exhausted_notified_at is not null', [sessionId], @@ -195,7 +219,6 @@ export async function startCascade(db: DbClient, sessionId: number): Promise { const result = await db.query<{ n: string }>( `select count(*)::text as n from message_log where status = 'sent' and ${where}`, @@ -145,23 +157,38 @@ async function log(db: DbClient, msg: OutboundText, body: string, status: string } export async function sendText(db: DbClient, msg: OutboundText, now: Date = new Date()): Promise { - const body = toOneSegment(msg.body); + const body = toOneSegment(withBrand(msg.body)); if (!isDomesticDestination(msg.to)) { await log(db, msg, body, 'refused-destination', null, now); return 'refused'; } + if (!CONSENT_EXEMPT_TEMPLATES.has(msg.template)) { + if (await isOptedOut(db, msg.to)) { + await log(db, msg, body, 'refused-opt-out', null, now); + return 'refused'; + } + if (!(await hasActiveConsent(db, msg.to))) { + await log(db, msg, body, 'refused-no-consent', null, now); + return 'refused'; + } + } const hit = await ceilingHit(db, msg, now); if (hit) { await log(db, msg, body, `capped-${hit}`, null, now); console.warn(`sms: ${hit} ceiling reached, not sending ${msg.template} (coach ${msg.coachId ?? '-'})`); return 'capped'; } - try { - const sent = await sendSms({ to: msg.to, body }); - await log(db, msg, body, 'sent', sent.id ?? null, now); + const sent = await sendSms({ to: msg.to, body }); + if (sent.ok) { + await log(db, msg, body, 'sent', sent.id, now); return 'sent'; - } catch (err) { - await log(db, msg, body, 'failed', null, now); - throw err; } + if (sent.code === 21610) { + await revokeConsent(db, msg.to); + await log(db, msg, body, 'failed-opt-out', null, now); + console.warn(`sms: recipient opted out (21610), not sending ${msg.template} to ${msg.to}`); + return 'refused'; + } + await log(db, msg, body, 'failed', null, now); + throw new Error(`relay sms send failed: ${sent.code} ${sent.message}`); } diff --git a/src/domain/sms-consent.ts b/src/domain/sms-consent.ts new file mode 100644 index 0000000..602d499 --- /dev/null +++ b/src/domain/sms-consent.ts @@ -0,0 +1,91 @@ +import type { DbClient } from '../db/client.js'; +import { SMS_CONSENT_TEXT_VERSION, SMS_PURPOSE } from '../config.js'; +import { upsertOptOut, isOptedOut } from './cascade.js'; + +export { isOptedOut }; + +export async function hasActiveConsent(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise { + if (await isOptedOut(db, phone)) return false; + const result = await db.query<{ id: number }>( + `select id from sms_consent + where phone = $1 and purpose = $2 and revoked_at is null`, + [phone, purpose], + ); + return result.rows.length > 0; +} + +export async function recordConsent( + db: DbClient, + phone: string, + source: string, + meta: { ip?: string | null; userAgent?: string | null; purpose?: string }, +): Promise { + const purpose = meta.purpose ?? SMS_PURPOSE; + await db.query( + `insert into sms_consent (phone, purpose, consent_text_version, source, ip, user_agent, consented_at, revoked_at) + values ($1, $2, $3, $4, $5, $6, now(), null) + on conflict (phone, purpose) do update set + consent_text_version = excluded.consent_text_version, + source = excluded.source, + ip = excluded.ip, + user_agent = excluded.user_agent, + consented_at = now(), + revoked_at = null`, + [phone, purpose, SMS_CONSENT_TEXT_VERSION, source, meta.ip ?? null, meta.userAgent ?? null], + ); + await db.query('delete from opt_out where phone = $1', [phone]); +} + +export async function revokeConsent(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise { + await upsertOptOut(db, phone); + await db.query( + `update sms_consent set revoked_at = now() where phone = $1 and purpose = $2 and revoked_at is null`, + [phone, purpose], + ); +} + +export async function clearRevocation(db: DbClient, phone: string, purpose: string = SMS_PURPOSE): Promise { + await db.query('delete from opt_out where phone = $1', [phone]); + await db.query( + `update sms_consent set revoked_at = null where phone = $1 and purpose = $2`, + [phone, purpose], + ); +} + +export async function queuePendingConsent( + db: DbClient, + phone: string, + coachId: number, + addedForLabel: string, + purpose: string = SMS_PURPOSE, +): Promise { + await db.query( + `insert into sms_consent_pending (phone, added_by_coach_id, added_for_label, purpose, sent_at) + values ($1, $2, $3, $4, now()) + on conflict (phone) do update set + added_by_coach_id = excluded.added_by_coach_id, + added_for_label = excluded.added_for_label, + purpose = excluded.purpose, + sent_at = now()`, + [phone, coachId, addedForLabel, purpose], + ); +} + +export async function hasPendingConsent(db: DbClient, phone: string): Promise { + const result = await db.query<{ phone: string }>('select phone from sms_consent_pending where phone = $1', [phone]); + return result.rows.length > 0; +} + +export async function confirmConsentFromReply(db: DbClient, phone: string): Promise { + const pending = await db.query<{ purpose: string }>('select purpose from sms_consent_pending where phone = $1', [phone]); + if (pending.rows.length === 0) return false; + const purpose = pending.rows[0].purpose; + await recordConsent(db, phone, 'reply-yes', { purpose }); + await db.query('delete from sms_consent_pending where phone = $1', [phone]); + return true; +} + +/** Test helper and seeds: grant consent without a form POST. */ +export async function grantSmsConsent(db: DbClient, phone: string, source = 'test'): Promise { + await recordConsent(db, phone, source, {}); +} diff --git a/src/domain/sms-keywords.ts b/src/domain/sms-keywords.ts new file mode 100644 index 0000000..5c03c4e --- /dev/null +++ b/src/domain/sms-keywords.ts @@ -0,0 +1,17 @@ +export type InboundKeyword = 'STOP' | 'START' | 'HELP'; + +const STOP_WORDS = new Set(['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT', 'REVOKE', 'OPTOUT']); +const START_WORDS = new Set(['START', 'UNSTOP']); +const HELP_WORDS = new Set(['HELP', 'INFO']); + +export function matchInboundKeyword(body: string, optOutType: string): InboundKeyword | null { + const type = optOutType.trim().toUpperCase(); + if (type === 'STOP') return 'STOP'; + if (type === 'START') return 'START'; + if (type === 'HELP') return 'HELP'; + const token = body.trim().toUpperCase(); + if (STOP_WORDS.has(token)) return 'STOP'; + if (START_WORDS.has(token)) return 'START'; + if (HELP_WORDS.has(token)) return 'HELP'; + return null; +} diff --git a/src/jobs/tick.ts b/src/jobs/tick.ts index 262f5f7..9387fb2 100644 --- a/src/jobs/tick.ts +++ b/src/jobs/tick.ts @@ -1,5 +1,5 @@ import type { DbClient } from '../db/client.js'; -import { advanceCascade, isOptedOut } from '../domain/cascade.js'; +import { advanceCascade } from '../domain/cascade.js'; import { topUpAllSlots, zonedParts } from '../domain/scheduling.js'; import { clipSms, formatConfirmWhen } from '../lib/time.js'; import { sendText } from '../domain/outbound.js'; @@ -51,7 +51,6 @@ export async function sendDueReminders(db: DbClient, now: Date): Promise [row.booking_id], ); if (claimed.rows.length === 0) continue; - if (await isOptedOut(db, row.contact_phone)) continue; const when = formatConfirmWhen(new Date(row.starts_at_utc).toISOString(), row.tz); const place = row.location_text ? ` at ${row.location_text}` : ''; const outcome = await sendText( diff --git a/src/lib/html.ts b/src/lib/html.ts index 9a6e932..ab5e785 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -283,6 +283,21 @@ export function coachNav(active?: CoachNavKey): SafeHtml { `; } +export function smsConsentCheckbox(): SafeHtml { + return html``; +} + +export function siteFooter(): SafeHtml { + return html``; +} + export function page(title: string, body: SafeHtml): string { return ` @@ -290,10 +305,15 @@ export function page(title: string, body: SafeHtml): string { ${escapeHtml(title)} — Coachatron - + -
${body.value}
+
${body.value}${siteFooter().value}
`; } diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..b3ab830 --- /dev/null +++ b/src/lib/phone.ts @@ -0,0 +1,14 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js'; + +/** Normalize user input to E.164 (default region US). */ +export function toE164(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const parsed = parsePhoneNumberFromString(trimmed, 'US'); + if (parsed?.isValid()) return parsed.format('E.164'); + const digits = trimmed.replace(/[^0-9]/g, ''); + if (digits.length === 10) return `+1${digits}`; + if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`; + if (trimmed.startsWith('+') && digits.length >= 8 && digits.length <= 15) return `+${digits}`; + return null; +} diff --git a/src/lib/relay-inbound.ts b/src/lib/relay-inbound.ts new file mode 100644 index 0000000..a98870c --- /dev/null +++ b/src/lib/relay-inbound.ts @@ -0,0 +1,40 @@ +import crypto from 'node:crypto'; + +export function relayInboundSignature(secret: string, publicUrl: string, rawBody: Buffer): string { + return crypto.createHmac('sha256', secret).update(publicUrl).update(rawBody).digest('base64'); +} + +export function verifyRelaySignature( + secret: string, + publicUrl: string, + rawBody: Buffer, + signatureHeader: string | undefined, +): boolean { + if (!secret || !signatureHeader) return false; + const expected = relayInboundSignature(secret, publicUrl, rawBody); + try { + const a = Buffer.from(signatureHeader, 'base64'); + const b = Buffer.from(expected, 'base64'); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch { + return false; + } +} + +export function parseRelayFormBody(rawBody: Buffer): Record { + const params = new URLSearchParams(rawBody.toString('utf8')); + const out: Record = {}; + for (const [key, value] of params.entries()) { + out[key] = value; + } + return out; +} + +export function formField(body: Record, ...keys: string[]): string { + for (const key of keys) { + const value = body[key]?.trim(); + if (value) return value; + } + return ''; +} diff --git a/src/relay/sms.ts b/src/relay/sms.ts index 497cfbd..90511d0 100644 --- a/src/relay/sms.ts +++ b/src/relay/sms.ts @@ -5,11 +5,27 @@ export interface SendSmsRequest { body: string; } -export async function sendSms(req: SendSmsRequest): Promise<{ id: string }> { +export type SendSmsResult = + | { ok: true; id: string } + | { ok: false; code: number; message: string }; + +export async function sendSms(req: SendSmsRequest): Promise { const res = await relayFetch('/sms/send', { method: 'POST', body: JSON.stringify({ to: req.to, body: req.body }), }); - if (!res.ok) throw new Error(`relay sms send failed: ${res.status}`); - return (await res.json()) as { id: string }; + if (res.ok) { + const json = (await res.json()) as { id?: string }; + return { ok: true, id: json.id ?? 'unknown' }; + } + let code = res.status; + let message = `relay sms send failed: ${res.status}`; + try { + const json = (await res.json()) as { code?: number; message?: string; error?: boolean }; + if (typeof json.code === 'number') code = json.code; + if (typeof json.message === 'string') message = json.message; + } catch { + // keep defaults + } + return { ok: false, code, message }; } diff --git a/src/routes/coach.ts b/src/routes/coach.ts index 3060f04..b9c8427 100644 --- a/src/routes/coach.ts +++ b/src/routes/coach.ts @@ -17,7 +17,7 @@ import { import { APP_BASE_URL, APP_FEE_BPS, OTP_DAILY_MAX_PER_PHONE, 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'; +import { coachNav, html, page, raw, smsConsentCheckbox } from '../lib/html.js'; import { DEFAULT_TZ, formatLocal, isValidTimeZone } from '../lib/time.js'; import { getPackagesForCoach, getPlansForCoach, createPackage, createPlan } from '../domain/pricing.js'; import { summarizeMoney } from '../domain/money.js'; @@ -32,6 +32,8 @@ import { renderSetupPreview, type SetupDraft, } from '../domain/setup.js'; +import { recordConsent } from '../domain/sms-consent.js'; +import { sendRosterConsentRequest } from '../domain/cascade.js'; // ---- Roster functions (M4 overflow cascade) ---- @@ -100,12 +102,24 @@ function renderPhoneForm(value = '', error?: string) {
+ ${smsConsentCheckbox()}
${error ? html`

${error}

` : raw('')}`, ); } +function smsConsentChecked(body: unknown): boolean { + const value = (body as Record | undefined)?.sms_consent; + return value === '1' || value === 'on' || value === true; +} + +function consentMeta(req: Request): { ip: string | null; userAgent: string | null } { + const ip = typeof req.ip === 'string' ? req.ip : null; + const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null; + return { ip, userAgent }; +} + coachRouter.get('/signin', (_req, res) => { res.status(200).send(renderPhoneForm()); }); @@ -117,7 +131,14 @@ coachRouter.post('/signin/otp', async (req, res) => { res.status(422).send(renderPhoneForm(raw_phone, 'Enter a valid phone number.')); return; } + if (!smsConsentChecked(req.body)) { + res + .status(422) + .send(renderPhoneForm(raw_phone, 'Check the box to receive your sign-in code and session texts by SMS.')); + return; + } const db = getDb(); + await recordConsent(db, phone, '/signin/otp', consentMeta(req)); const code = await createOtp(db, phone); const outcome = await sendText(db, { to: phone, @@ -947,6 +968,10 @@ coachRouter.post('/app/roster', requireAuth, async (_req, res) => { } await addRosterMember(db, coachId, name, phone); + const coach = await findCoachById(db, coachId); + if (coach) { + await sendRosterConsentRequest(db, coachId, coach.name, phone); + } res.redirect(303, '/app/roster'); }); diff --git a/src/routes/public.ts b/src/routes/public.ts index 7b8eb2c..afb46ab 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getDb, type DbClient } from '../db/client.js'; import { findCoachByHandle, getConnectRecipientKey, normalizePhone, type CoachRow } from '../domain/auth.js'; -import { html, page, raw } from '../lib/html.js'; +import { html, page, raw, smsConsentCheckbox } from '../lib/html.js'; import { formatLocal } from '../lib/time.js'; import { getPackagesForCoach, @@ -17,6 +17,9 @@ import { } from '../domain/pricing.js'; import { checkOverflow, acceptOffer, declineOffer, describeOffer, getOfferByToken } from '../domain/cascade.js'; import { buyerEmail, connectBuyUrl } from '../relay/buy-link.js'; +import { recordConsent } from '../domain/sms-consent.js'; +import { SMS_BRAND, SMS_PURPOSE, SUPPORT_EMAIL } from '../config.js'; +import type { Request } from 'express'; export const publicRouter = Router(); @@ -50,6 +53,56 @@ function field(body: unknown, key: string): string { return typeof value === 'string' ? value.trim() : ''; } +function smsConsentChecked(body: unknown): boolean { + const value = (body as Record | undefined)?.sms_consent; + return value === '1' || value === 'on' || value === true; +} + +function consentMeta(req: Request, source: string): { ip: string | null; userAgent: string | null; source: string } { + return { + ip: typeof req.ip === 'string' ? req.ip : null, + userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : null, + source, + }; +} + +publicRouter.get('/privacy', (_req, res) => { + res.status(200).send( + page( + 'Privacy', + html`

Privacy Policy

+

Text messages

+

+ ${SMS_BRAND} sends SMS for ${SMS_PURPOSE}. Message frequency varies. Message and data rates may apply. Reply STOP to opt out or HELP for help. +

+

+ We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes. +

`, + ), + ); +}); + +publicRouter.get('/terms', (_req, res) => { + res.status(200).send( + page( + 'Terms', + html`

Terms of Service

+
+

SMS program

+

Program name: ${SMS_BRAND} session texts

+

+ ${SMS_BRAND} sends ${SMS_PURPOSE} when you opt in on our booking or sign-in forms by checking the SMS consent box. +

+

Message frequency varies.

+

Message and data rates may apply.

+

Reply STOP to opt out; reply HELP for help.

+

Help: ${SUPPORT_EMAIL}

+

Carriers are not liable for delayed or undelivered messages.

+
`, + ), + ); +}); + // ---- Screen 8: coach's public page (sessions list) ---- publicRouter.get('/c/:handle', async (req, res) => { @@ -134,6 +187,7 @@ function renderBookingForm( + ${smsConsentCheckbox()} @@ -174,6 +228,11 @@ publicRouter.post('/c/:handle/sessions/:sessionId/book', async (req, res) => { return; } + if (smsConsentChecked(req.body)) { + const meta = consentMeta(req, `/c/${coach.handle}/sessions/${session.id}/book`); + await recordConsent(db, contactPhone, meta.source, { ip: meta.ip, userAgent: meta.userAgent }); + } + // Re-check capacity server-side even if the list page the parent saw was // stale — SPEC.md §7.2. const bookedCount = await countBookedForSession(db, session.id); @@ -187,6 +246,7 @@ publicRouter.post('/c/:handle/sessions/:sessionId/book', async (req, res) => { + ${smsConsentCheckbox()} See other times`, @@ -224,6 +284,11 @@ publicRouter.post('/c/:handle/sessions/:sessionId/waitlist', async (req, res) => return; } + if (smsConsentChecked(req.body)) { + const meta = consentMeta(req, `/c/${coach.handle}/sessions/${session.id}/waitlist`); + await recordConsent(db, contactPhone, meta.source, { ip: meta.ip, userAgent: meta.userAgent }); + } + await db.query( 'insert into waitlist (session_id, contact_phone, athlete_name) values ($1, $2, $3)', [session.id, contactPhone, athleteName], diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 4ab9110..a7b9f63 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, type Request, type Response } from 'express'; import { getDb, type DbClient } from '../db/client.js'; import { sendText } from '../domain/outbound.js'; import { findCoachByPhone, normalizePhone } from '../domain/auth.js'; @@ -6,20 +6,87 @@ import { acceptOffer, declineOffer, startCascade, - upsertOptOut, findPendingAskSessionForCoach, resolveAskWithoutCascade, } from '../domain/cascade.js'; import { handleCoachMessage, keywordToken } from '../domain/assistant.js'; import { getLivePending, logAssistant, takeDailyReplySlot } from '../domain/assistantPending.js'; -import { APP_BASE_URL } from '../config.js'; +import { + APP_BASE_URL, + NODE_ENV, + SMS_BRAND, + SUPPORT_EMAIL, +} from '../config.js'; import { clipSms } from '../lib/time.js'; +import { formField, parseRelayFormBody, verifyRelaySignature } from '../lib/relay-inbound.js'; +import { matchInboundKeyword } from '../domain/sms-keywords.js'; +import { clearRevocation, confirmConsentFromReply, hasPendingConsent, revokeConsent } from '../domain/sms-consent.js'; -export const webhooksRouter = Router(); +export const smsInboundRouter = Router(); +export const smsStatusRouter = Router(); + +function inboundSecret(): string { + return process.env.RELAY_INBOUND_SECRET ?? ''; +} + +function smsWebhookPublicUrl(): string { + return ( + process.env.SMS_WEBHOOK_PUBLIC_URL ?? + `${(process.env.APP_BASE_URL ?? 'https://coachatron.com').replace(/\/$/, '')}/webhooks/sms` + ); +} + +function smsStatusWebhookPublicUrl(): string { + return ( + process.env.SMS_STATUS_WEBHOOK_PUBLIC_URL ?? + `${(process.env.APP_BASE_URL ?? 'https://coachatron.com').replace(/\/$/, '')}/webhooks/sms-status` + ); +} + +function rawBody(req: Request): Buffer { + return Buffer.isBuffer(req.body) ? req.body : Buffer.from(''); +} + +function verifyOrRespond(req: Request, res: Response, publicUrl: string): Record | null { + const secret = inboundSecret(); + const body = rawBody(req); + if (!secret) { + if (NODE_ENV === 'production') { + res.status(503).send('Inbound webhook secret not configured'); + return null; + } + } else { + const header = req.header('x-relay-signature'); + if (!verifyRelaySignature(secret, publicUrl, body, header)) { + res.status(401).send('Unauthorized'); + return null; + } + } + return parseRelayFormBody(body); +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function twimlEmpty(res: Response): void { + res.status(200).type('text/xml').send(''); +} + +function twimlMessage(res: Response, text: string): void { + res + .status(200) + .type('text/xml') + .send(`${escapeXml(text)}`); +} -function field(body: unknown, key: string): string { - const value = (body as Record | undefined)?.[key]; - return typeof value === 'string' ? value.trim() : ''; +function helpReplyText(): string { + return `${SMS_BRAND}: booking confirmations and reminders for coaching sessions. Help: ${SUPPORT_EMAIL}. Msg frequency varies. Msg & data rates may apply. Reply STOP to opt out.`; } async function findSentOfferForPhone( @@ -45,31 +112,25 @@ async function replyOnce(db: DbClient, phone: string, body: string, now: Date): return true; } -// Inbound SMS: STOP/HELP, roster Y, pending confirm, overflow Y, then the -// model for the coach only. Do not uppercase the whole body — keyword -// checks are case-insensitive on a single token (PLATFORM.md §4.1). -webhooksRouter.post('/webhooks/sms', async (req, res) => { +smsInboundRouter.post('/', async (req, res) => { + const form = verifyOrRespond(req, res, smsWebhookPublicUrl()); + if (!form) return; + const db = getDb(); const now = new Date(); - const fromRaw = field(req.body, 'from'); - const body = field(req.body, 'body'); - const from = normalizePhone(fromRaw) ?? fromRaw; + const fromRaw = formField(form, 'From', 'from'); + const body = formField(form, 'Body', 'body'); + const from = normalizePhone(fromRaw); + const optOutType = formField(form, 'OptOutType'); if (!from || !body) { - res.status(400).send('Missing from or body'); + res.status(400).send('Missing From or Body'); return; } - const kw = keywordToken(body); - - if (kw === 'STOP') { - await upsertOptOut(db, from); - await sendText(db, { - to: from, - body: 'Coachatron: you are opted out. Text HELP for help, or use your booking link.', - coachId: null, - template: 'stop', - }); + const keyword = matchInboundKeyword(body, optOutType); + if (keyword === 'STOP') { + await revokeConsent(db, from); await logAssistant(db, { phone: from, channel: 'sms', @@ -78,17 +139,16 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { intent: 'STOP', outcome: 'opted-out', }); - res.status(200).send('opted out'); + twimlEmpty(res); return; } - - if (kw === 'HELP') { - await sendText(db, { - to: from, - body: 'Coachatron: for help, visit your booking or coach link. Text STOP to opt out.', - coachId: null, - template: 'help', - }); + if (keyword === 'START') { + await clearRevocation(db, from); + twimlEmpty(res); + return; + } + if (keyword === 'HELP') { + twimlMessage(res, helpReplyText()); await logAssistant(db, { phone: from, channel: 'sms', @@ -97,25 +157,31 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { intent: 'HELP', outcome: 'help', }); - res.status(200).send('help sent'); + return; + } + + const kw = keywordToken(body); + if (kw === 'Y' && (await hasPendingConsent(db, from))) { + await confirmConsentFromReply(db, from); + twimlEmpty(res); return; } const offer = await findSentOfferForPhone(db, from); if (offer) { if (kw === 'Y') { - const accepted = await acceptOffer(db, offer.id); - res.status(200).send(accepted ? 'offer accepted' : 'offer no longer available'); + await acceptOffer(db, offer.id); + twimlEmpty(res); return; } if (kw === 'N') { await declineOffer(db, offer.id); - res.status(200).send('offer declined'); + twimlEmpty(res); return; } const link = offer.token ? `${APP_BASE_URL}/offer/${offer.token}` : APP_BASE_URL; await replyOnce(db, from, `Coachatron: reply Y or ${link}`, now); - res.status(200).send('offer link'); + twimlEmpty(res); return; } @@ -128,11 +194,11 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { if (pendingAsk) { if (kw === 'Y') { await startCascade(db, pendingAsk); - res.status(200).send('cascade started'); + twimlEmpty(res); return; } await resolveAskWithoutCascade(db, pendingAsk); - res.status(200).send('overflow declined'); + twimlEmpty(res); return; } } @@ -140,10 +206,33 @@ webhooksRouter.post('/webhooks/sms', async (req, res) => { const reply = await handleCoachMessage(db, coach, body, 'sms', now); await sendText(db, { to: from, body: reply.text, coachId: coach.id, template: 'assistant' }, now); - res.status(200).send(reply.kind); + twimlEmpty(res); return; } await replyOnce(db, from, `Coachatron: use your booking link at ${APP_BASE_URL}`, now); - res.status(200).send('unrecognized'); + twimlEmpty(res); }); + +smsStatusRouter.post('/', async (req, res) => { + const form = verifyOrRespond(req, res, smsStatusWebhookPublicUrl()); + if (!form) return; + + const db = getDb(); + const messageSid = formField(form, 'MessageSid'); + const messageStatus = formField(form, 'MessageStatus'); + const errorCode = formField(form, 'ErrorCode'); + const toRaw = formField(form, 'To', 'to'); + const to = normalizePhone(toRaw); + + if (messageSid && messageStatus) { + await db.query('update message_log set status = $1 where provider_id = $2', [messageStatus, messageSid]); + } + if (errorCode === '21610' && to) { + await revokeConsent(db, to); + } + twimlEmpty(res); +}); + +/** Legacy export: other routes may attach here later. */ +export const webhooksRouter = Router(); diff --git a/src/server.ts b/src/server.ts index d474c42..09c5008 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import express from 'express'; import { PORT } from './config.js'; import { coachRouter } from './routes/coach.js'; import { publicRouter } from './routes/public.js'; -import { webhooksRouter } from './routes/webhooks.js'; +import { smsInboundRouter, smsStatusRouter } from './routes/webhooks.js'; import { storeWebhookRouter } from './routes/store-webhook.js'; import { getDb } from './db/client.js'; import { runMigrations } from './db/migrate.js'; @@ -14,6 +14,8 @@ const TICK_MS = 60_000; export function createApp() { const app = express(); app.use('/webhooks/store', express.raw({ type: '*/*' }), storeWebhookRouter); + app.use('/webhooks/sms', express.raw({ type: '*/*' }), smsInboundRouter); + app.use('/webhooks/sms-status', express.raw({ type: '*/*' }), smsStatusRouter); app.use(express.urlencoded({ extended: true })); app.use(express.json()); @@ -31,7 +33,6 @@ export function createApp() { app.use(coachRouter); app.use(publicRouter); - app.use(webhooksRouter); return app; } diff --git a/test/assistant.test.ts b/test/assistant.test.ts index f25622b..3b74cd4 100644 --- a/test/assistant.test.ts +++ b/test/assistant.test.ts @@ -12,13 +12,10 @@ import { addCalendarDays, zonedParts, zonedTimeToUtc } from '../src/domain/sched import { checkOverflow } from '../src/domain/cascade.js'; import { APP_BASE_URL } from '../src/config.js'; import type { ClassifiedIntent } from '../src/llm/schema.js'; +import { postSignedSms } from './helpers/sms-webhook.js'; async function smsTo(base: string, from: string, body: string): Promise { - return fetch(`${base}/webhooks/sms`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ from, body }), - }); + return postSignedSms(base, { From: from, Body: body }); } function cookieFor(token: string): string { @@ -78,6 +75,8 @@ test('pattern cancel confirms, Y texts athletes and returns credits', async () = values ($1, 'Credit Kid', $2, 'PackageCredit', $3, 'booked')`, [seed.sessionId, '+15550000011', credit.rows[0].id], ); + const { grantSmsConsent } = await import('../src/domain/sms-consent.js'); + await grantSmsConsent(db, '+15550000011'); await seedBookedSession(db, seed.sessionId, 'Drop In', '+15550000012'); setComplete(fakeComplete(new Error('model must not run'))); @@ -85,7 +84,7 @@ test('pattern cancel confirms, Y texts athletes and returns credits', async () = await withServer(async (base) => { const ask = await smsTo(base, seed.coach.phone, "cancel tomorrow's 6pm, field's flooded"); assert.equal(ask.status, 200); - assert.equal(await ask.text(), 'confirm'); + assert.match(await ask.text(), / m.to === seed.coach.phone).at(-1); assert.ok(confirm); assert.match(confirm!.body, /Cancel /); @@ -93,7 +92,8 @@ test('pattern cancel confirms, Y texts athletes and returns credits', async () = assert.match(confirm!.body, /2 athletes/); const yes = await smsTo(base, seed.coach.phone, 'Y'); - assert.equal(await yes.text(), 'done'); + assert.equal(yes.status, 200); + assert.match(await yes.text(), / { - const stop = await smsTo(base, seed.coach.phone, 'stop'); - assert.equal(await stop.text(), 'opted out'); + const stop = await smsTo(base, '+15559876543', 'stop'); + assert.equal(stop.status, 200); + assert.match(await stop.text(), /<\/Response>/); const help = await smsTo(base, '+15559990000', 'HeLp'); - assert.equal(await help.text(), 'help sent'); + assert.equal(help.status, 200); + assert.match(await help.text(), //); const english = await smsTo(base, seed.coach.phone, 'What did I collect this week actually'); assert.equal(english.status, 200); @@ -241,7 +243,7 @@ test('STOP and HELP are case-insensitive single tokens; original text reaches th } finally { resetComplete(); } - assert.ok(relay.sms.some((m) => /opted out/i.test(m.body))); + assert.equal(relay.sms.some((m) => /opted out/i.test(m.body)), false); }); }); @@ -260,10 +262,12 @@ test('roster live offer Y never reaches the model; other text gets one link a da await withServer(async (base) => { await smsTo(base, seed.coach.phone, 'Y'); const banana = await smsTo(base, member.phone, 'cancel tomorrow'); - assert.equal(await banana.text(), 'offer link'); + assert.equal(banana.status, 200); + assert.match(await banana.text(), / { const yes = await smsTo(base, seed.coach.phone, 'Y'); - assert.equal(await yes.text(), 'cascade started'); + assert.equal(yes.status, 200); + assert.match(await yes.text(), / { await withRelay(async (relay) => { - await freshDb(); + const db = await freshDb(); + const { grantSmsConsent } = await import('../src/domain/sms-consent.js'); + await grantSmsConsent(db, '+15559990000'); await withServer(async (base) => { const first = await smsTo(base, '+15559990000', 'banana'); - assert.equal(await first.text(), 'unrecognized'); + assert.equal(first.status, 200); + assert.match(await first.text(), / m.to === '+15559990000'); assert.equal(toThem.length, 1); diff --git a/test/booking-payment.test.ts b/test/booking-payment.test.ts index 9bfb17c..2970b26 100644 --- a/test/booking-payment.test.ts +++ b/test/booking-payment.test.ts @@ -17,7 +17,7 @@ async function bookSession(base: string, handle: string, sessionId: number, athl const bookRes = await fetch(`${base}/c/${handle}/sessions/${sessionId}/book`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ athlete_name: athlete, contact_phone: phone }), + body: JSON.stringify({ athlete_name: athlete, contact_phone: phone, sms_consent: '1' }), redirect: 'manual', }); assert.equal(bookRes.status, 303); diff --git a/test/coach-onboarding.test.ts b/test/coach-onboarding.test.ts index f8863b1..cf29e5e 100644 --- a/test/coach-onboarding.test.ts +++ b/test/coach-onboarding.test.ts @@ -19,7 +19,7 @@ test('coach onboarding: OTP sign-in, session type, weekly schedule, public page' const otpRes = await fetch(`${base}/signin/otp`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ phone: '5551234567' }), + body: JSON.stringify({ phone: '5551234567', sms_consent: '1' }), redirect: 'manual', }); assert.equal(otpRes.status, 303); @@ -111,7 +111,7 @@ async function signUp(base: string, relay: { sms: Array<{ to: string; body: stri await fetch(`${base}/signin/otp`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ phone: digits }), + body: JSON.stringify({ phone: digits, sms_consent: '1' }), redirect: 'manual', }); const phone = `+1${digits}`; diff --git a/test/fakes/relay.ts b/test/fakes/relay.ts index 8be79e9..47b62fb 100644 --- a/test/fakes/relay.ts +++ b/test/fakes/relay.ts @@ -132,7 +132,12 @@ export async function startFakeRelay(): Promise { app.post('/sms/send', (req, res) => { if (!requireApiKey(req, res)) return; - sms.push({ to: String(req.body.to), body: String(req.body.body) }); + const to = String(req.body.to); + if (to === '+15552161000') { + res.status(400).json({ error: true, code: 21610, message: 'unsubscribed' }); + return; + } + sms.push({ to, body: String(req.body.body) }); res.json({ id: `sms_${sms.length}` }); }); diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index 05235ca..80c0f49 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -1,5 +1,6 @@ import type { DbClient } from '../../src/db/client.js'; import { createCoach, type CoachRow } from '../../src/domain/auth.js'; +import { grantSmsConsent } from '../../src/domain/sms-consent.js'; import crypto from 'node:crypto'; export interface SeededSession { @@ -25,6 +26,7 @@ export async function seedCoachWithSession( email: 'jamie@example.com', tz: 'America/Chicago', }); + await grantSmsConsent(db, coach.phone); const typeResult = await db.query<{ id: number }>( `insert into session_type (coach_id, name, duration_min, capacity, price_cents, active) @@ -75,6 +77,7 @@ export async function seedRosterMember( coachId: number, name: string, priority: number, + opts: { consent?: boolean } = {}, ): Promise { const phone = `+1777${Math.floor(1000000 + Math.random() * 8999999)}`; const result = await db.query<{ id: number }>( @@ -83,6 +86,9 @@ export async function seedRosterMember( returning id`, [coachId, name, phone, priority], ); + if (opts?.consent !== false) { + await grantSmsConsent(db, phone); + } return { id: result.rows[0].id, phone }; } @@ -96,6 +102,7 @@ export async function seedBookedSession(db: DbClient, sessionId: number, athlete returning id`, [sessionId, athleteName, contactPhone, manageToken], ); + await grantSmsConsent(db, contactPhone); return result.rows[0].id; } diff --git a/test/helpers/server.ts b/test/helpers/server.ts index f8742b6..fb37dcb 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -1,6 +1,7 @@ import { createServer, type Server } from 'node:http'; import { createApp } from '../../src/server.js'; import { listenLoopback } from './listen.js'; +import { configureSmsWebhooks, clearSmsWebhookEnv } from './sms-webhook.js'; /** Starts the app on an ephemeral port for the duration of `fn`, then closes * it. Every HTTP-level test uses this instead of guessing a port. */ @@ -15,11 +16,13 @@ export async function withServer(fn: (base: string) => Promise): Promise((resolve) => server.close(() => resolve())); } diff --git a/test/helpers/sms-webhook.ts b/test/helpers/sms-webhook.ts new file mode 100644 index 0000000..7a95976 --- /dev/null +++ b/test/helpers/sms-webhook.ts @@ -0,0 +1,55 @@ +import { relayInboundSignature } from '../../src/lib/relay-inbound.js'; + +export const TEST_INBOUND_SECRET = 'test-inbound-secret'; + +export function configureSmsWebhooks(base: string): void { + process.env.RELAY_INBOUND_SECRET = TEST_INBOUND_SECRET; + process.env.SMS_WEBHOOK_PUBLIC_URL = `${base}/webhooks/sms`; + process.env.SMS_STATUS_WEBHOOK_PUBLIC_URL = `${base}/webhooks/sms-status`; +} + +export function clearSmsWebhookEnv(): void { + delete process.env.RELAY_INBOUND_SECRET; + delete process.env.SMS_WEBHOOK_PUBLIC_URL; + delete process.env.SMS_STATUS_WEBHOOK_PUBLIC_URL; +} + +function encodeForm(fields: Record): string { + return Object.entries(fields) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); +} + +export async function postSignedSms( + base: string, + fields: Record, + opts: { secret?: string; publicUrl?: string; signature?: string | null } = {}, +): Promise { + const body = encodeForm(fields); + const publicUrl = opts.publicUrl ?? `${base}/webhooks/sms`; + const secret = opts.secret ?? TEST_INBOUND_SECRET; + let signature = opts.signature; + if (signature === undefined) { + signature = secret ? relayInboundSignature(secret, publicUrl, Buffer.from(body)) : undefined; + } + const headers: Record = { 'content-type': 'application/x-www-form-urlencoded' }; + if (signature) headers['x-relay-signature'] = signature; + return fetch(`${base}/webhooks/sms`, { method: 'POST', headers, body }); +} + +export async function postSignedSmsStatus( + base: string, + fields: Record, + opts: { secret?: string; publicUrl?: string; signature?: string | null } = {}, +): Promise { + const body = encodeForm(fields); + const publicUrl = opts.publicUrl ?? `${base}/webhooks/sms-status`; + const secret = opts.secret ?? TEST_INBOUND_SECRET; + let signature = opts.signature; + if (signature === undefined) { + signature = secret ? relayInboundSignature(secret, publicUrl, Buffer.from(body)) : undefined; + } + const headers: Record = { 'content-type': 'application/x-www-form-urlencoded' }; + if (signature) headers['x-relay-signature'] = signature; + return fetch(`${base}/webhooks/sms-status`, { method: 'POST', headers, body }); +} diff --git a/test/journeys.test.ts b/test/journeys.test.ts index d10a3b2..563ad93 100644 --- a/test/journeys.test.ts +++ b/test/journeys.test.ts @@ -6,6 +6,7 @@ import { withRelay } from './helpers/relay.js'; import { seedCoachWithSession, seedRosterMember, seedBookedSession, seedWaitlistEntry } from './helpers/fixtures.js'; import { checkOverflow } from '../src/domain/cascade.js'; import { buyerFromLocation, postStorePaid } from './helpers/store-event.js'; +import { postSignedSms } from './helpers/sms-webhook.js'; /** * End-to-end smoke test for the three journeys named in the idea's "I will @@ -28,11 +29,7 @@ function extractSessionCookie(res: Response): string { } async function smsTo(base: string, from: string, body: string): Promise { - return fetch(`${base}/webhooks/sms`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ from, body }), - }); + return postSignedSms(base, { From: from, Body: body }); } test('journey 1: coach signs in, creates a session type, generates a week, gets /c/', async () => { @@ -43,7 +40,7 @@ test('journey 1: coach signs in, creates a session type, generates a week, gets const otpRes = await fetch(`${base}/signin/otp`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ phone: '5551110001' }), + body: JSON.stringify({ phone: '5551110001', sms_consent: '1' }), redirect: 'manual', }); assert.equal(otpRes.status, 303); @@ -108,7 +105,7 @@ test('journey 2: parent books and pays a drop-in via the fake relay', async () = const bookRes = await fetch(`${base}/c/${seed.coach.handle}/sessions/${seed.sessionId}/book`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ athlete_name: 'Parent Journey Athlete', contact_phone: '5552220001' }), + body: JSON.stringify({ athlete_name: 'Parent Journey Athlete', contact_phone: '5552220001', sms_consent: '1' }), redirect: 'manual', }); assert.equal(bookRes.status, 303); @@ -218,7 +215,7 @@ test('journey 4: a new coach talks their week in and publishes it, three decisio await fetch(`${base}/signin/otp`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ phone: '5550004444' }), + body: JSON.stringify({ phone: '5550004444', sms_consent: '1' }), redirect: 'manual', }); const code = /code is (\d{6})/.exec(relay.sms.find((m) => m.to === '+15550004444')!.body)![1]; diff --git a/test/legal-pages.test.ts b/test/legal-pages.test.ts new file mode 100644 index 0000000..a7e8768 --- /dev/null +++ b/test/legal-pages.test.ts @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { withServer } from './helpers/server.js'; + +const VERBATIM = + 'We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes.'; + +test('privacy page includes required SMS sentence verbatim', async () => { + await withServer(async (base) => { + const html = await (await fetch(`${base}/privacy`)).text(); + assert.match(html, new RegExp(VERBATIM.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(html, /Text messages/); + }); +}); + +test('terms page has sms section with required phrases', async () => { + await withServer(async (base) => { + const html = await (await fetch(`${base}/terms`)).text(); + assert.match(html, /id="sms"/); + assert.match(html, /Message frequency varies/); + assert.match(html, /Message and data rates may apply/); + assert.match(html, /Reply STOP to opt out; reply HELP for help/); + assert.match(html, /Carriers are not liable for delayed or undelivered messages/); + }); +}); + +test('sign-in page footer links to privacy and terms', async () => { + await withServer(async (base) => { + const html = await (await fetch(`${base}/signin`)).text(); + assert.match(html, /href="\/privacy"/); + assert.match(html, /href="\/terms"/); + }); +}); diff --git a/test/outbound.test.ts b/test/outbound.test.ts index bff178a..02873f7 100644 --- a/test/outbound.test.ts +++ b/test/outbound.test.ts @@ -7,6 +7,7 @@ import { withServer } from './helpers/server.js'; import { withRelay } from './helpers/relay.js'; import { seedCoachWithSession } from './helpers/fixtures.js'; import { isDomesticDestination, segmentCount, sendText, toOneSegment } from '../src/domain/outbound.js'; +import { grantSmsConsent } from '../src/domain/sms-consent.js'; import type { DbClient } from '../src/db/client.js'; async function statuses(db: DbClient): Promise { @@ -55,6 +56,7 @@ test('only US and Canadian numbers are texted', () => { test('a foreign number is refused and logged, never sent', async () => { await withRelay(async (relay) => { const db = await freshDb(); + await grantSmsConsent(db, '+447700900123'); assert.equal(await sendText(db, { to: '+447700900123', body: 'hi', coachId: null, template: 'otp' }), 'refused'); assert.equal(relay.sms.length, 0); assert.deepEqual(await statuses(db), ['refused-destination']); @@ -69,6 +71,7 @@ test('a coach stops at 300 texts a day and 2,000 a month', async () => { await fillLog(db, 299, coach.id, new Date('2026-10-20T14:00:00Z')); const msg = { to: '+15550001234', body: 'hi', coachId: coach.id, template: 'reminder' }; + await grantSmsConsent(db, msg.to); assert.equal(await sendText(db, msg, now), 'sent'); assert.equal(await sendText(db, msg, now), 'capped'); assert.equal(relay.sms.length, 1); @@ -92,6 +95,7 @@ test('the product stops at 400 a day per active coach, never below 500', async ( const db = await freshDb(); const now = new Date(); const msg = { to: '+15550001234', body: 'hi', coachId: null, template: 'auto-reply' }; + await grantSmsConsent(db, msg.to); await fillLog(db, 500, null, now); assert.equal(await sendText(db, msg, now), 'capped'); @@ -108,11 +112,11 @@ test('sign-in codes: five a day per number, domestic numbers only', async () => await withRelay(async (relay) => { await freshDb(); await withServer(async (base) => { - const ask = (phone: string) => + const ask = (phone: string, consent = true) => fetch(`${base}/signin/otp`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ phone }), + body: JSON.stringify({ phone, ...(consent ? { sms_consent: '1' } : {}) }), redirect: 'manual', }); for (let i = 0; i < 5; i += 1) assert.equal((await ask('5550007777')).status, 303); @@ -128,6 +132,17 @@ test('sign-in codes: five a day per number, domestic numbers only', async () => }); }); +test('sendText refuses without consent and prefixes brand', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + assert.equal(await sendText(db, { to: '+15550009999', body: 'hello', coachId: null, template: 'reminder' }), 'refused'); + assert.equal(relay.sms.length, 0); + await grantSmsConsent(db, '+15550009999'); + assert.equal(await sendText(db, { to: '+15550009999', body: 'hello', coachId: null, template: 'reminder' }), 'sent'); + assert.match(relay.sms[0].body, /^Coachatron: hello/); + }); +}); + test('nothing in src sends a text except through the guarded send path', () => { const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'src'); const offenders: string[] = []; diff --git a/test/overflow-cascade.test.ts b/test/overflow-cascade.test.ts index 385e7f3..ec8eefb 100644 --- a/test/overflow-cascade.test.ts +++ b/test/overflow-cascade.test.ts @@ -6,13 +6,10 @@ import { withRelay } from './helpers/relay.js'; import { seedCoachWithSession, seedRosterMember, seedBookedSession, seedWaitlistEntry } from './helpers/fixtures.js'; import { checkOverflow, startCascade, advanceCascade, acceptOffer } from '../src/domain/cascade.js'; import type { DbClient } from '../src/db/client.js'; +import { postSignedSms } from './helpers/sms-webhook.js'; async function smsTo(base: string, from: string, body: string): Promise { - return fetch(`${base}/webhooks/sms`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ from, body }), - }); + return postSignedSms(base, { From: from, Body: body }); } async function countOffers(db: DbClient, sessionId: number): Promise { @@ -196,22 +193,25 @@ test('STOP opts a roster member out; the cascade skips them without ever offerin ]); assert.equal(offers.rows.length, 1); assert.equal(offers.rows[0].roster_member_id, second.id, 'the opted-out member must be skipped entirely'); - assert.equal(relay.sms.filter((m) => m.to === first.phone).length, 1, 'only the STOP confirmation, never an offer'); + assert.equal(relay.sms.filter((m) => m.to === first.phone).length, 0, 'STOP does not trigger an offer or extra text'); }); }); test('HELP always gets exactly one reply; an unrecognized keyword points at the web link', async () => { await withRelay(async (relay) => { - await freshDb(); + const db = await freshDb(); + const { grantSmsConsent } = await import('../src/domain/sms-consent.js'); + await grantSmsConsent(db, '+15559990000'); await withServer(async (base) => { const helpRes = await smsTo(base, '+15559990000', 'HELP'); assert.equal(helpRes.status, 200); - assert.equal(relay.sms.length, 1); + assert.match(await helpRes.text(), //); + assert.equal(relay.sms.length, 0); const otherRes = await smsTo(base, '+15559990000', 'banana'); assert.equal(otherRes.status, 200); - assert.equal(relay.sms.length, 2); - assert.match(relay.sms[1].body, /link/i); + assert.equal(relay.sms.length, 1); + assert.match(relay.sms[0].body, /link/i); }); }); }); diff --git a/test/phone.test.ts b/test/phone.test.ts new file mode 100644 index 0000000..bfcd5a7 --- /dev/null +++ b/test/phone.test.ts @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toE164 } from '../src/lib/phone.js'; + +test('toE164 normalizes US numbers', () => { + assert.equal(toE164('5551234567'), '+15551234567'); + assert.equal(toE164('(555) 123-4567'), '+15551234567'); + assert.equal(toE164('+1 555 123 4567'), '+15551234567'); + assert.equal(toE164('15551234567'), '+15551234567'); +}); + +test('toE164 rejects invalid numbers', () => { + assert.equal(toE164('invalid-phone'), null); + assert.equal(toE164('123'), null); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts index a2747ac..39da2e2 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -10,6 +10,7 @@ import { createCoach, createCoachSession, type CoachRow } from '../src/domain/au import { describeWeekly, renderSetupPreview, validateSetupPlan, type ExistingType } from '../src/domain/setup.js'; import type { ChatMessage } from '../src/llm/schema.js'; import type { DbClient } from '../src/db/client.js'; +import { postSignedSms } from './helpers/sms-webhook.js'; const WEEK = { coach_timezone: null, @@ -33,6 +34,8 @@ function withWeek(overrides: Record) { async function newCoach(db: DbClient, phone = '+15550009999'): Promise<{ coach: CoachRow; cookie: string }> { const coach = await createCoach(db, { phone, name: 'Dana Keeper', email: '', tz: 'America/Chicago' }); + const { grantSmsConsent } = await import('../src/domain/sms-consent.js'); + await grantSmsConsent(db, phone); return { coach, cookie: `cx_session=${await createCoachSession(db, coach.id)}` }; } @@ -285,17 +288,12 @@ test('by SMS, a coach with no schedule gets a draft and one short reply; a sched setComplete(llm); try { await withServer(async (base) => { - const sms = (from: string, body: string) => - fetch(`${base}/webhooks/sms`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ from, body }), - }); + const sms = (from: string, body: string) => postSignedSms(base, { From: from, Body: body }); await sms(coach.phone, 'Keeper group Tuesdays and Thursdays at 6 at Field 3, an hour, 8 kids, $35'); const replies = relay.sms.filter((m) => m.to === coach.phone); assert.equal(replies.length, 1); - assert.match(replies[0].body, /^Got it: 2 session types, 4 times a week\. Check it and tap Publish: /); + assert.match(replies[0].body, /Got it: 2 session types, 4 times a week\. Check it and tap Publish:/); assert.ok(replies[0].body.length <= 160); assert.equal(await count(db, 'setup_draft where coach_id = $1', [coach.id]), 1); assert.equal(await count(db, 'session'), 0); diff --git a/test/sms-compliance.test.ts b/test/sms-compliance.test.ts new file mode 100644 index 0000000..28f3251 --- /dev/null +++ b/test/sms-compliance.test.ts @@ -0,0 +1,164 @@ +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 { seedCoachWithSession, seedRosterMember } from './helpers/fixtures.js'; +import { grantSmsConsent } from '../src/domain/sms-consent.js'; +import { sendText } from '../src/domain/outbound.js'; +import { isOptedOut } from '../src/domain/cascade.js'; +import { + postSignedSms, + postSignedSmsStatus, + TEST_INBOUND_SECRET, +} from './helpers/sms-webhook.js'; +import { relayInboundSignature } from '../src/lib/relay-inbound.js'; + +test('inbound webhook rejects missing signature when secret is set', async () => { + await withRelay(async () => { + await freshDb(); + await withServer(async (base) => { + const body = Object.entries({ From: '+15551234567', Body: 'STOP' }) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const res = await fetch(`${base}/webhooks/sms`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + assert.equal(res.status, 401); + }); + }); +}); + +test('inbound webhook accepts valid signature and handles STOP', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15551234567'); + await withServer(async (base) => { + const res = await postSignedSms(base, { From: '+15551234567', Body: 'STOP' }); + assert.equal(res.status, 200); + assert.match(await res.text(), /<\/Response>/); + assert.equal(await isOptedOut(db, '+15551234567'), true); + }); + }); +}); + +test('inbound webhook rejects tampered body', async () => { + await withRelay(async () => { + await freshDb(); + await withServer(async (base) => { + const fields = { From: '+15551234567', Body: 'STOP' }; + const body = Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const sig = relayInboundSignature(TEST_INBOUND_SECRET, `${base}/webhooks/sms`, Buffer.from(body)); + const tampered = `${body}&x=1`; + const res = await fetch(`${base}/webhooks/sms`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-relay-signature': sig }, + body: tampered, + }); + assert.equal(res.status, 401); + }); + }); +}); + +test('inbound webhook rejects signature for wrong public URL', async () => { + await withRelay(async () => { + await freshDb(); + await withServer(async (base) => { + const fields = { From: '+15551234567', Body: 'HELP' }; + const body = Object.entries(fields) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const sig = relayInboundSignature(TEST_INBOUND_SECRET, 'https://coachatron.com/webhooks/sms', Buffer.from(body)); + const res = await fetch(`${base}/webhooks/sms`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-relay-signature': sig }, + body, + }); + assert.equal(res.status, 401); + }); + }); +}); + +test('START clears opt-out', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15551234567'); + await withServer(async (base) => { + await postSignedSms(base, { From: '+15551234567', Body: 'STOP' }); + assert.equal(await isOptedOut(db, '+15551234567'), true); + await postSignedSms(base, { From: '+15551234567', Body: 'START' }); + assert.equal(await isOptedOut(db, '+15551234567'), false); + }); + }); +}); + +test('HELP returns TwiML with support email', async () => { + await withRelay(async () => { + await freshDb(); + await withServer(async (base) => { + const res = await postSignedSms(base, { From: '+15551234567', Body: 'HELP' }); + const xml = await res.text(); + assert.match(xml, //); + assert.match(xml, /support@coachatron\.com/); + }); + }); +}); + +test('OptOutType=STOP is honored', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15551234567'); + await withServer(async (base) => { + await postSignedSms(base, { From: '+15551234567', Body: 'hi', OptOutType: 'STOP' }); + assert.equal(await isOptedOut(db, '+15551234567'), true); + }); + }); +}); + +test('relay 21610 on send records opt-out', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15552161000'); + const outcome = await sendText(db, { to: '+15552161000', body: 'hi', coachId: null, template: 'reminder' }); + assert.equal(outcome, 'refused'); + assert.equal(await isOptedOut(db, '+15552161000'), true); + }); +}); + +test('status callback 21610 records opt-out', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15551234567'); + await withServer(async (base) => { + await postSignedSmsStatus(base, { + MessageSid: 'SM123', + MessageStatus: 'failed', + ErrorCode: '21610', + To: '+15551234567', + }); + assert.equal(await isOptedOut(db, '+15551234567'), true); + }); + }); +}); + +test('roster member replies YES to record consent after confirmation text', async () => { + await withRelay(async (relay) => { + const db = await freshDb(); + const seed = await seedCoachWithSession(db); + const member = await seedRosterMember(db, seed.coach.id, 'Backup', 0, { consent: false }); + const { sendRosterConsentRequest } = await import('../src/domain/cascade.js'); + const { hasActiveConsent } = await import('../src/domain/sms-consent.js'); + await sendRosterConsentRequest(db, seed.coach.id, seed.coach.name, member.phone); + assert.equal(relay.sms.filter((m) => m.to === member.phone).length, 1); + assert.equal(await hasActiveConsent(db, member.phone), false); + + await withServer(async (base) => { + await postSignedSms(base, { From: member.phone, Body: 'YES' }); + }); + assert.equal(await hasActiveConsent(db, member.phone), true); + }); +}); From 5084ea996c73c9a8b1d8829ce2ca5c837070190f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 20:52:54 +0000 Subject: [PATCH 2/2] test(sms): cover OptOutType START and HELP inbound keywords Co-authored-by: Ricardo Vega --- test/sms-compliance.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/sms-compliance.test.ts b/test/sms-compliance.test.ts index 28f3251..f422d75 100644 --- a/test/sms-compliance.test.ts +++ b/test/sms-compliance.test.ts @@ -119,6 +119,30 @@ test('OptOutType=STOP is honored', async () => { }); }); +test('OptOutType=START clears opt-out', async () => { + await withRelay(async () => { + const db = await freshDb(); + await grantSmsConsent(db, '+15551234567'); + await withServer(async (base) => { + await postSignedSms(base, { From: '+15551234567', Body: 'hi', OptOutType: 'STOP' }); + assert.equal(await isOptedOut(db, '+15551234567'), true); + await postSignedSms(base, { From: '+15551234567', Body: 'hi', OptOutType: 'START' }); + assert.equal(await isOptedOut(db, '+15551234567'), false); + }); + }); +}); + +test('OptOutType=HELP returns TwiML', async () => { + await withRelay(async () => { + await freshDb(); + await withServer(async (base) => { + const res = await postSignedSms(base, { From: '+15551234567', Body: 'hello', OptOutType: 'HELP' }); + assert.equal(res.status, 200); + assert.match(await res.text(), //); + }); + }); +}); + test('relay 21610 on send records opt-out', async () => { await withRelay(async () => { const db = await freshDb();