/** * 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. */ import { legal } from './legal.mjs'; /** * 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. * * Suffixed rather than re-dated when phase 6 added the age clause on the same day the * original wording was written: two different sentences must not share a label, and the * date is what an operator groups a CSV by. */ export const CONSENT_VERSION = '2026-08-24b'; /** * 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). * * Phase 6 added the age (D31), and it goes FIRST because it is the only clause the person * ticking the box is asserting rather than acknowledging — everything after it is a * description of what we do. `legal.minimumAge` is interpolated rather than typed, because * /privacy and /terms state the same number and the Data Safety notes answer a question * about it; four surfaces, one source. * * Editing this string is a real act: `consent_text` stores the wording rather than a * version, so rows written from here on carry the new sentence and older rows keep the one * they were given. That is the property that makes the column worth having. */ export const CONSENT_TEXT = `I am ${legal.minimumAge} or older. 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: `Being ${legal.minimumAge} or older`, body: 'The beta is for adults. Nothing verifies it and nothing pretends to — ticking the ' + 'box on the form is the whole of it — but it is the condition the list is collected ' + 'under, and it is why the form needs no parental consent machinery it could not ' + 'honestly operate.', }, { 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', };