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

179
src/data/beta.mjs Normal file
View File

@@ -0,0 +1,179 @@
/**
* beta.mjs — the closed beta as data: the limits, the consent wording, and the two gates
* that are not open yet. PLAN.md §8, built in phase 5.
*
* ---------------------------------------------------------------------------------------
* WHY THE CONSENT TEXT LIVES HERE AND NOT IN THE MARKUP
* ---------------------------------------------------------------------------------------
* §8's schema stores `consent_text` — the exact wording somebody agreed to — rather than a
* version number, so a row can always answer "what did this person actually consent to"
* without going back through the git history of a template. That only works if the string
* the page renders and the string the row records are the same object. One export, read by
* the label on the checkbox and by the insert.
*
* Editing it is therefore a real act: every row written from that moment carries the new
* wording, and the old rows keep the old one, which is the behaviour that makes the column
* worth having. `CONSENT_VERSION` is not what the row stores — it exists so an operator
* reading a CSV can group rows without diffing prose.
*
* ---------------------------------------------------------------------------------------
* THE TWO GATES (D26, D27)
* ---------------------------------------------------------------------------------------
* The page collects signups today and cannot invite anybody yet, for two independent
* reasons, and it says both plainly rather than implying a queue that is moving:
*
* 1. THE PLAY TRACK. A closed test has an opt-in URL, and that URL only works for
* addresses already on the tester list — which is the whole reason §8 can publish it
* and still send no email (D7). The developer account exists; the track does not, so
* there is no URL yet. It is a `brand.json` field for the same reason `demoUrl` is:
* the day the track opens, the confirmation screen gains a working link for the cost
* of a file copy and a restart (§7).
*
* 2. SOMEWHERE TO POINT IT. `ConnectScreen.kt` on `Android-app` `main` is blunt about
* this — "nothing else in the app runs until a valid Runic Gateway site is entered and
* validated". An installed app with no deployment behind it is a text field. D27 makes
* the public demo (§15) the tester target rather than naming a private shard, which
* means the beta opens when the demo VM does. `notBuilt.mjs` already carries that
* absence; this page renders the demo link from `demoUrl` when there is one.
*
* Neither gate is a reason not to collect addresses now — the list is what makes the first
* batch possible on day one — but a page that hid them would be advertising a beta that
* cannot start, which is exactly what §1 forbids.
*/
/**
* Google Play's closed-testing rules, as verified in the Play Console documentation on
* **2026-08-24**.
*
* These are the one class of fact on this site that `checkFacts.mjs` cannot police: there
* is no API to fetch them from and Play's testing requirements have changed more than once
* (§8 says so in as many words). So they carry a date, they live in data rather than prose
* like every other fact here, and the date is rendered on the page next to them. A reader
* can tell how old the claim is, and so can whoever re-checks it before launch.
*/
export const playPolicy = {
verifiedOn: '2026-08-24',
/** Opted-in testers required, continuously, before production access can be requested. */
testersRequired: 12,
/** Consecutive days those testers must stay opted in. */
testerDays: 14,
/** Addresses one pasted email list holds. Context for the cap below, not a target. */
addressesPerList: 2000,
};
/**
* The signup limits (§8's "abuse resistance without a third party").
*
* Every one of these is overridable by environment variable, and that is deliberate: the
* numbers are guesses about a form nobody has attacked yet, and the alternative to tuning
* them from the compose file is rebuilding an image to change an integer.
*
* `TOTAL_CAP` is the org lead's choice of 500 — far above any plausible demand for a beta
* that needs twelve people, and far below one list's 2,000, so a run that beats both the
* honeypot and the bucket still cannot fill the box before the form closes and says so.
*/
export const limits = {
/** Rows, across all time, above which the form closes. */
totalCap: readInt('BETA_TOTAL_CAP', 500),
/** Signups one `ip_hash` may make in a rolling hour. */
perHour: readInt('BETA_PER_HOUR', 3),
/** Signups one `ip_hash` may make in a rolling day. */
perDay: readInt('BETA_PER_DAY', 24),
/**
* Seconds a human plausibly needs between the page rendering and the form posting.
*
* Two is §8's number and it is generous in the right direction: a person who has already
* decided still has to type an address and tick a box. A script does not.
*/
minSeconds: readInt('BETA_MIN_SECONDS', 2),
/**
* Seconds after which a rendered form is stale.
*
* Not an abuse control — a page left open overnight has a timestamp that says nothing,
* and re-rendering the form is a better answer than trusting it. Twelve hours.
*/
maxSeconds: readInt('BETA_MAX_SECONDS', 12 * 60 * 60),
};
function readInt(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
// Loud, and then carry on with the default. A typo in a compose file should not stop
// the site from booting, and it must not silently become an unlimited form either.
console.warn(`[beta] ignoring ${name}="${raw}" — expected a positive integer.`);
return fallback;
}
return value;
}
/**
* A label for the batch a row was written in. Stored in no column — see the header.
*/
export const CONSENT_VERSION = '2026-08-24';
/**
* The exact sentence beside the checkbox, and the exact sentence written to `consent_text`.
*
* Written to be true of what the code does, not of what a privacy policy template says:
* the address is kept until the beta ends or removal is asked for, it is pasted into Play
* because that is the only way Play accepts testers, and nothing is mailed to it because
* the site cannot send mail at all (D7).
*/
export const CONSENT_TEXT =
'I understand my email address will be stored so it can be added to the Google Play ' +
'closed test, that it will be shared with Google Play for that purpose only, that ' +
'Runic Gateway sends no email of any kind, and that I can ask for it to be deleted at ' +
'any time.';
/**
* What a tester needs, rendered as the page's eligibility list.
*
* The third item is the one that matters and the one a beta page usually omits. It is
* phrased as a dependency rather than a warning because it is one: the app is a client for
* a deployment, and a client with no server is not a product with a missing feature.
*/
export const requirements = [
{
title: 'An Android device on 10 or newer',
body:
// No backticks. These strings render as text, not as Markdown, so a reader sees the
// punctuation rather than code formatting — caught by looking at the built page.
'API level 29 is the minimum the app is built against. Phones and tablets both; ' +
'there is no TV or Wear build and none is planned.',
},
{
title: 'A Google account, and the willingness to stay opted in',
body:
'Play counts testers who are opted in continuously. Leaving the test and rejoining ' +
'resets that count for everybody, which is the one thing a tester can do that ' +
'actually costs something.',
},
{
title: 'A Runic Gateway deployment to connect to',
body:
'The app ships pointed at nothing. Its first screen asks for the address of a site ' +
'running this platform and validates it before anything else in the app will run — ' +
'so a tester needs either their own deployment or the public demo, which is the ' +
'second of the two things this beta is waiting on.',
},
];
/**
* The form's field names, in one place because three files need to agree about them: the
* markup that renders the inputs, the handler that reads the body, and the test that posts
* one. A honeypot whose name drifts is a honeypot that catches nothing, and nothing about
* a passing build would say so.
*
* `HONEYPOT` is named for something a form plausibly has and a bot will want to fill.
* Naming it `honeypot` would be a note to the bot.
*/
export const fields = {
EMAIL: 'email',
CONSENT: 'consent',
HONEYPOT: 'website',
/** When the form was rendered — signed, see `betaSignup.mjs`. */
ISSUED: 'ts',
};