feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
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:
179
src/data/beta.mjs
Normal file
179
src/data/beta.mjs
Normal 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',
|
||||
};
|
||||
Reference in New Issue
Block a user