Some checks failed
PR checks / checks (pull_request) Failing after 2m16s
The public demo (demo.runicgateway.com) is live, so the site's demo slot can be switched on. Two decisions of record, PLAN.md §15: - D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on the server, caches it for a minute, and lists the published sign-ins with the next wipe. If the demo does not answer, /beta links to its sign-in page instead. Gate two reads as in place. - D83: a deep link into a game module's pages carries data-demo-game, and applyBrand.mjs fills it only for games in the new mounted demoGames list. The Rust features gain links into /rust and /rust/servers/demo. UO's stay hidden on a Rust demo. The public-demo not-built entry is removed, because its resolvedBy was met. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
203 lines
9.9 KiB
JavaScript
203 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. The demo
|
|
* went up on 2026-10-27; with `demoUrl` mounted, `/beta` marks this gate in place and
|
|
* lists the demo's sign-ins as the demo publishes them (D82, `lib/demoLogins.mjs`).
|
|
*
|
|
* 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.',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 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',
|
|
};
|