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

183
src/data/app.mjs Normal file
View File

@@ -0,0 +1,183 @@
/**
* app.mjs — what the Android client does, as data. PLAN.md §10 `/app/`, phase 5.
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS NOT capabilities.mjs
* ---------------------------------------------------------------------------------------
* `capabilities.mjs` describes what a DEPLOYMENT does, and it is checked against
* `module-uo`'s manifest because the module declares its capabilities in a machine-readable
* file. The app declares nothing of the kind: what it does is a set of screens in
* `Routes.kt`, and there is no manifest to diff against. So these are written from that
* file and carry the route names, which is the closest thing to a citation available — a
* reader who wants to check a claim here has a file to open.
*
* That is also why the app's list is shorter than the platform's rather than a mirror of
* it. The app is a client for the parts of a deployment a person uses on a phone; the parts
* it does not have are not missing, they are the parts nobody wants on a phone.
*
* ---------------------------------------------------------------------------------------
* "GATED BY WHAT THE DEPLOYMENT PUBLISHES" IS LOAD-BEARING, NOT A DISCLAIMER
* ---------------------------------------------------------------------------------------
* Almost everything here is conditional on the site the app is pointed at: shard screens
* appear only when the deployment runs a game module and has that visibility feature
* turned on, staff screens only for a staff account, push only where the operator runs an
* ntfy. A features list that omitted that would be describing an app nobody will see,
* because there is no default deployment — see `requiresDeployment` below.
*/
/**
* The single most misunderstood thing about this app, stated once and rendered prominently.
*
* `ConnectScreen.kt` on `Android-app` `main`: "First-run 'Connect to your shard's website'
* screen. Nothing else in the app runs until a valid Runic Gateway site is entered and
* validated." Not a soft default that can be changed later in settings — the gate on
* everything else. An install with no deployment behind it is a text field, and a page that
* let somebody find that out after downloading would have wasted their time on purpose.
*/
export const requiresDeployment = {
title: 'It is a client. It ships pointed at nothing.',
body:
'The first screen asks for the web address of a site running Runic Gateway and checks ' +
'it before anything else in the app will open. There is no default server, no ' +
'directory of servers, and no account with us — the app talks to the deployment you ' +
'name and to nothing else. If you do not run one and are not a member of a community ' +
'that does, the app has nothing to show you yet.',
};
/**
* @typedef {object} AppFeature
* @property {string} title
* @property {string} body
* @property {string} [gate] What the deployment must provide for this to appear at all.
*/
/** @type {{ heading: string, blurb: string, items: AppFeature[] }[]} */
export const appFeatures = [
{
heading: 'The shard, live',
blurb:
'The same feed the website shows, on a phone. Every one of these appears only when ' +
'the deployment runs a game module and the operator has published that surface — ' +
'the visibility settings are per-feature and default to off.',
items: [
{
title: 'Status, and the boards',
body:
'Whether the game server is up, plus champion spawns, guilds, city governors and ' +
'houses as the shard reports them.',
gate: 'A game module, and the matching visibility feature',
},
{
title: 'The player marketplace',
body:
'Player-run vendors and what is on their shelves, down to a single vendor. Item ' +
"names arrive as the game's own string ids and are resolved against its string " +
'table, so they read the way they read in the client.',
gate: 'A game module publishing the market',
},
{
title: 'Rules, leaderboards and the spawn atlas',
body:
"The deployment's published ruleset, its points and loyalty boards, and the " +
'creature atlas — what spawns where, and what it drops.',
gate: 'A game module, per feature',
},
],
},
{
heading: "The site's content",
blurb: 'News, wiki and pages, read natively rather than in a browser frame.',
items: [
{
title: 'News, by category',
body:
"Announcements and posts in the categories the site defines, and a link from " +
'anywhere on the web opens the matching tab rather than the top of the list.',
},
{
title: 'The wiki and the site pages',
body:
'Wiki articles and whatever pages the operator has written in the admin panel, ' +
'including the ones they added to the navigation themselves.',
},
],
},
{
heading: 'Your account',
blurb:
'Staff are players too — the self-service screens are role-agnostic, and an ' +
'administrator sees their own characters on the same screen everybody else does.',
items: [
{
title: 'Signing in, including two-factor',
body:
'A native sign-in with an authenticator code or a single-use recovery code, or ' +
"single sign-on handed off to the deployment's own provider in a browser tab. " +
'Trusting a device skips the code for thirty days, and that trust is revocable ' +
'per device from the app.',
},
{
title: 'Your characters, vendors and houses',
body:
'Character sheets, the vendors you run and the houses you own, visible to the ' +
'account they belong to and to nobody else.',
gate: 'A game module',
},
],
},
{
heading: 'Notifications, on infrastructure the operator owns',
blurb:
'Opt-in, per stream, and delivered without a third party — which is unusual enough ' +
'to be worth spelling out.',
items: [
{
title: 'Self-hosted push',
body:
"Push arrives over the deployment's own ntfy server, not Firebase. The app has " +
'no Google messaging dependency at all, which is why it works on a device with ' +
'no Play Services and why no notification passes through anyone else on its way ' +
'to the phone.',
gate: 'An ntfy server the operator runs',
},
{
title: 'The message carries no content',
body:
'What is pushed is which stream fired and an opaque reference — never the ' +
'subject, the sender or the text. The app opens the right screen and fetches the ' +
'actual content over the authenticated API, so a notification sitting on a lock ' +
'screen discloses nothing.',
},
],
},
{
heading: 'Staff work, if you are staff',
blurb:
'Gated by role in the menu and re-checked against the database on every request, so ' +
'a demoted account loses the screens immediately rather than at next sign-in.',
items: [
{
title: 'Moderation, support and content',
body:
'The dashboard, the moderation queue, support requests and content editing — ' +
'enough to answer a report from a phone. The surfaces that would let somebody ' +
'reconfigure the deployment stay on the web.',
gate: 'A staff role on the deployment',
},
],
},
{
heading: 'It looks like the deployment it is pointed at',
blurb: '',
items: [
{
title: 'The theme comes down the wire',
body:
"The palette, the type, the corner radii, the logo, the hero image and even the " +
"navigation order are read from the site's own appearance settings. Two " +
'communities running this app do not see the same app, and neither of them had ' +
'to build one.',
},
],
},
];

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

View File

@@ -127,7 +127,11 @@ export const notBuilt = [
},
{
id: 'public-demo',
scope: ['features'],
// Phase 5 added `app` and `beta`, and that is not tidying. D27 makes the demo the
// deployment a beta tester connects to, so on those two pages this stopped being a
// thing the site lacks and became the thing the beta is waiting for. An absence that
// blocks a call to action has to be on the page carrying that call to action.
scope: ['features', 'app', 'beta'],
title: 'A public demo you can click through',
body:
'Planned and out of scope today: a virtual machine running the whole stack including ' +
@@ -138,6 +142,46 @@ export const notBuilt = [
'The machine being stood up. The site is already built to gain it by way of one line ' +
'in a configuration file, rather than a rebuild.',
},
/* ---------------------------------------------------------------------------------------
THE ANDROID CLIENT (phase 5)
These are about the app rather than the platform, and they live here rather than in a
second list on `/app/` for the reason this file exists at all: two lists of absences
drift, and the one that drifts is always the one nobody is looking at. The `scope` tag
is what keeps them off the pages they would be noise on.
--------------------------------------------------------------------------------------- */
{
id: 'ios-app',
scope: ['app'],
title: 'An iOS app',
body:
'Android only. There is no iOS build, no cross-platform layer waiting to grow one, ' +
'and no work in progress — the app is native Kotlin and Compose, so a second ' +
'platform would be a second app rather than another build target.',
resolvedBy: 'Nothing planned. A deployment is a website first, and that works on any phone.',
},
{
id: 'play-listing',
scope: ['app', 'beta'],
title: 'A listing on Google Play',
body:
'The app is not published. A developer account exists; the closed test is the next ' +
'step, and production access cannot even be requested until a run of testers has ' +
'been opted in continuously — which is what the beta is for, and why the beta is not ' +
'a formality.',
resolvedBy: 'The closed test running its course, and then a production review.',
link: { href: '/beta/', label: 'The closed beta' },
},
{
id: 'app-offline',
scope: ['app'],
title: 'Reading anything offline',
body:
'Every screen is a live read against the deployment. Nothing is cached for offline ' +
'use, so the app with no signal is an app with no content.',
resolvedBy: 'Somebody asking for it. Nobody has.',
},
];
/** The entries a given page renders, in file order. */

View File

@@ -53,6 +53,28 @@
"androidApplicationId": "com.runicgateway.app",
"$comment_androidApk": [
"Phase 5 / `/app/`. The app is not on any store, so the only way to install it is the",
"signed APK attached to each Android-app release. `asset` and `checksums` are the two",
"assets checkFacts.mjs asserts exist on releases/latest, and `minSdk` is re-read from",
"the app's build.gradle.kts — so the download block on /app/ cannot outlive the file it",
"points at, and 'Android 10 or newer' cannot outlive the number that makes it true.",
"",
"`serviceable` is the one value here with no authority to check it against, and it is",
"deliberately manual. It answers a question no API can: does the published build",
"actually work. The org lead reports v0.5.0's does not, so the download block renders a",
"'being replaced' state instead of a link, and flipping this to true is the single edit",
"that turns the link back on once a working build is released. A check cannot run an",
"APK; a person can, and this is where they record that they did."
],
"androidApk": {
"asset": "runic-gateway-0.5.0.apk",
"checksums": "SHA256SUMS",
"minSdk": 29,
"minAndroid": "10",
"serviceable": false
},
"gitea": {
"base": "https://gitea.whitlocktech.com",
"org": "RunicGateway"