Files
runicgateway.com/src/lib/betaSignup.mjs
wtclaude 1313e748ae
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
feat(beta): phase 5 — the app page and the closed-beta signup
Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
  when a route has been built, and an on-demand route never produces a file, so
  the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 03:50:51 -05:00

184 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 35 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 };
}
}