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:
183
src/data/app.mjs
Normal file
183
src/data/app.mjs
Normal 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
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',
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user