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>
184 lines
8.0 KiB
JavaScript
184 lines
8.0 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|
||
}
|