feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

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>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

183
src/lib/betaSignup.mjs Normal file
View File

@@ -0,0 +1,183 @@
/**
* 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 };
}
}