/** * betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5. * * Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this * is about not trusting a request. The split is what lets the tests drive the whole * decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch * database without a server, which is the only way this logic gets exercised at all. * * --------------------------------------------------------------------------------------- * THE ORDER OF THE CHECKS IS PART OF THE DESIGN * --------------------------------------------------------------------------------------- * Cheap and silent first, expensive and honest last: * * 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately. * A bot that is told it failed learns which field to leave alone next time; a bot that * is told it succeeded goes away. Nothing is written. * 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it * can post to it. Without the signature the timing check is theatre: `ts` is a number * in a hidden field and a bot can put yesterday's in it as easily as today's. * 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab. * 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes. * 5. CAP — the form closes at `totalCap` and says so. * 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and * the only two that get a specific, useful message. * * Steps 3–5 are checked before the address is even parsed, so a limited caller is never * told anything about an address, and step 6's messages can be specific precisely because * everything that could be probing has already been turned away. */ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs'; import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs'; /** * The key that signs a rendered form. * * Random per process when unset, and that is the right default rather than a compromise: * the only cost is that forms rendered before a restart are refused (the page re-renders and * the person tries again), and the alternative — a constant baked into the source — would * let anyone holding this repository mint tokens for every deployment of it. */ const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex'); const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex'); /** The value of the hidden `ts` field: when the page rendered, and proof that it did. */ export function issueFormToken(at = Date.now()) { return `${at}.${sign(at)}`; } /** * Verify a form token and return how long ago it was issued, or `null` if it is not ours. */ export function readFormToken(token) { if (typeof token !== 'string') return null; const dot = token.indexOf('.'); if (dot < 1) return null; const at = Number.parseInt(token.slice(0, dot), 10); if (!Number.isFinite(at)) return null; const given = Buffer.from(token.slice(dot + 1), 'utf8'); const want = Buffer.from(sign(at), 'utf8'); if (given.length !== want.length || !timingSafeEqual(given, want)) return null; return { at, ageSeconds: (Date.now() - at) / 1000 }; } /** * Address validation. * * Deliberately not RFC 5322. That grammar admits quoted strings, comments and address * literals, and a form whose job is to produce a line in a Google Play tester list gains * nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person * types, with a length bound that stops the column being used as storage. * * Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is * about what gets *written* — a CSV pasted into Play should not carry a stranger's * capitalisation choices as though they were significant. */ const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; export function normaliseEmail(raw) { const value = String(raw ?? '').trim().toLowerCase(); if (!value || value.length > 254) return null; if (!EMAIL_RE.test(value)) return null; return value; } /** * The outcomes the page renders. One per branch, so the markup never has to interpret a * message string, and so a new branch cannot be added without giving it a name here. */ export const OUTCOME = { ADDED: 'added', DUPLICATE: 'duplicate', /** Honeypot. Renders as success and writes nothing. */ DECOY: 'decoy', STALE: 'stale', TOO_FAST: 'too-fast', LIMITED: 'limited', FULL: 'full', INVALID_EMAIL: 'invalid-email', NO_CONSENT: 'no-consent', ERROR: 'error', }; /** Did this outcome put something in front of the person that looks like success? */ export const isSuccess = (outcome) => outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY; /** * Run a submitted form through every check and, if it survives, write the row. * * `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a * test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a * message on one page load, not a stack trace in a person's browser. */ export function submit({ form, ip, userAgent }) { const ipHash = hashIp(ip); const get = (name) => { const value = form.get(name); return typeof value === 'string' ? value : ''; }; // 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real // person's rate limit for a shared address by tripping a field that person cannot see. if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY }; // 2 and 3. The form has to have come from a page we rendered, recently but not too // recently. A missing or forged token is treated as staleness rather than as an // accusation: the honest cause — a restart, a tab open since yesterday — is far more // common than the dishonest one, and the remedy the page offers is the same. Like the // honeypot it costs no attempt, for the same reason: a caller that never obtained a // token must not be able to spend the budget of everyone behind a shared address. const token = readFormToken(get(fields.ISSUED)); if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE }; if (token.ageSeconds < limits.minSeconds) { recordAttempt(ipHash, OUTCOME.TOO_FAST); return { outcome: OUTCOME.TOO_FAST }; } // 4. The rate limit, before anything is parsed. const counts = attemptCounts(ipHash); if (counts.hour >= limits.perHour || counts.day >= limits.perDay) { recordAttempt(ipHash, OUTCOME.LIMITED); return { outcome: OUTCOME.LIMITED }; } // 5. The cap. Checked here rather than only when rendering the form, because the form // a person is looking at may have been rendered before the last row went in. if (isFull()) { recordAttempt(ipHash, OUTCOME.FULL); return { outcome: OUTCOME.FULL }; } // 6. The two things a real person gets wrong. const email = normaliseEmail(get(fields.EMAIL)); if (!email) { recordAttempt(ipHash, OUTCOME.INVALID_EMAIL); return { outcome: OUTCOME.INVALID_EMAIL }; } if (!get(fields.CONSENT)) { recordAttempt(ipHash, OUTCOME.NO_CONSENT); return { outcome: OUTCOME.NO_CONSENT, email }; } try { const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT }); const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED; recordAttempt(ipHash, outcome); return { outcome, email }; } catch (error) { // A full disk, a read-only mount, a corrupt file. The operator gets the detail; the // person gets a page that admits it went wrong rather than one that pretends it did not. console.error('[beta] could not record a signup:', error); return { outcome: OUTCOME.ERROR, email }; } }