Files
runicgateway.com/src/data/beta.mjs
wtclaude a2faf07104
All checks were successful
PR checks / checks (pull_request) Successful in 55s
feat(legal): phase 6 — the privacy policy and the terms
PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.

Four decisions taken by the org lead before either page was written, recorded in
§9 under "How phase 6 built the legal pages":

  D30  DNS-only records, so the reverse proxy on the host keeps the only access
       log. Described qualitatively — the retention belongs to the proxy, and a
       policy that quotes a number the deployment does not enforce is worse than
       one that does not.
  D31  Eighteen or older. Above the children's-consent threshold everywhere in the
       EEA, so consent works with no parental-consent machinery this form could not
       honestly operate. Four surfaces render it from src/data/legal.mjs, and every
       one says plainly that nothing verifies it.
  D32  No governing-law clause. Nothing of value is contracted for here.
  D33  PLAY_DATA_SAFETY.md is generated from src/data/collection.mjs and checked in
       CI, so the published policy and the answers given to Google cannot drift.

/privacy is three separately-scoped sections because "we" means three different
parties: this site (one form, no cookies, no third-party requests), the Android app
(we operate no server it talks to — the rows are what the DEVICE holds), and a
self-hosted deployment (the operator is the controller, not us). Every row names the
file it was read out of, because a policy is the document most likely to be written
from a template and least likely to be re-read against the software.

/terms governs only what we run: this site, the beta list, and the APK we publish.
The software is governed by its licence, and a community's deployment by that
community — a terms page claiming authority over every install of a GPL program is
the thing a generated template gets wrong.

Also here:
  - the age clause changed CONSENT_TEXT, so CONSENT_VERSION gained a suffix; rows
    written from now on carry the new sentence and older rows keep theirs
  - PLANNED_ROUTES is now empty — these were its last two entries, and its reverse
    check is what forced the deletion; the list stays for phases 7 and 8
  - test/legal.test.mjs asserts the structural promises no build check can see,
    including that every mapped Play row still answers "not collected, not shared"
  - --check normalises line endings: the repo has no .gitattributes and Windows
    checkouts are CRLF, so a byte comparison would fail for every Windows developer
    while passing in CI

Verified: npm run verify green end to end (tokens, brand, data safety, astro check,
36 tests, build, 214 links, 19 facts), both pages walked in a browser, and neither
overflows at 390px. One defect the checks could not see and a look could: the
retention line was being pushed to the foot of the tallest card in its row, opening
a void in the middle of the short ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 04:21:47 -05:00

204 lines
9.9 KiB
JavaScript

/**
* 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',
};