feat(beta): phase 5 — the app page and the closed-beta signup #8

Merged
whitlocktech merged 1 commits from feat/phase-5-beta into main 2026-08-24 08:55:02 +00:00
21 changed files with 3366 additions and 22 deletions
Showing only changes of commit 1313e748ae - Show all commits

View File

@@ -41,6 +41,20 @@ jobs:
- name: Types
run: npm run check
- name: Unit tests
# PLAN.md §8 — the beta signup's decision path: honeypot, form token, timing, rate
# limit, cap, validation, duplicate, removal.
#
# The first thing in this repository that the other checks cannot see. They all read
# the built output, and none of this appears there: a honeypot that has stopped
# working produces a build that is identical in every way to one where it works.
#
# The test file is NAMED rather than the directory passed. `node --test test/` fails
# on Node 22 with MODULE_NOT_FOUND — directory mode is not portable across the
# versions this org runs, and this workflow pins 22 while developers are on 24, so
# the shorter form would pass locally and break only here.
run: npm test
- name: Production build
run: npm run build

98
PLAN.md
View File

@@ -243,6 +243,7 @@ somewhere other than the thing it decided. The count of record is **twenty-five*
| D14D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
| D17D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
| D20D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
| D26D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
---
@@ -257,6 +258,9 @@ POST and write it somewhere (§8), and branding must be overridable by dropping
mount **without rebuilding the image** (§7) — which means the bytes cannot be fingerprinted into
the build output.
*Amended 2026-08-24 by D28: the signup is `/beta` itself rather than a `POST /api/beta-signup`
endpoint. The dynamic surface is still two routes and the reasoning above is unchanged; see §8.*
Why not a separate API service: one container is one thing to deploy, one thing to patch, and one
log to read. The dynamic surface is three endpoints.
@@ -266,7 +270,7 @@ log to read. The dynamic surface is three endpoints.
│ Astro (Node adapter) │
│ ├── prerendered pages ......... marketing, docs, legal — plain HTML │
│ ├── GET /brand/* ............ reads the bind mount, falls back to defaults │
│ └── POST /api/beta-signup ..... writes SQLite on the bind mount
│ └── GET + POST /beta/ ......... renders the form; writes SQLite on the mount
│ │
│ /app/brand-default ..... baked into the image (stock logo, tokens, brand.json) │
└─────────┬──────────────────────────────────────────────┬───────────────────────────┘
@@ -435,6 +439,17 @@ identify. The salt lives in the container environment, so rotating it destroys t
deliberately. `consent_text` stores the wording itself rather than a version number, so a record can
always answer "what exactly did this person agree to" without archaeology.
Phase 5 added the second table this section describes in prose but does not draw — `attempts`
(`ip_hash`, `at`, `outcome`), which is the token bucket below, persisted as the events themselves
rather than as a counter that would need a decay schedule and a clock it trusts. It is pruned on
write, so nothing has to remember to run.
One consequence of `remove` that follows from the promise rather than from a separate choice: the
address is **overwritten**, not flagged, so afterwards the store cannot tell a removed address from
one it has never seen. Somebody who left and signs up again is an ordinary new row. Keeping a hash
so the form could say "you were removed" would mean retaining a derived identifier for the one
person who explicitly asked not to be retained.
### Abuse resistance without a third party
D9 and §6's CSP forbid external requests, so no captcha service. Instead:
@@ -457,6 +472,11 @@ docker compose exec site node scripts/beta.mjs remove <email> # deletion reques
docker compose exec site node scripts/beta.mjs stats
```
Phase 5 note: `export` writes **two** files, not one. The `.csv` is the record (id, address, date,
status, the consent wording); the `.txt` beside it is one address per line, which is what Play's
tester list actually wants pasted. Producing only the CSV would mean hand-editing it before every
paste, which is where a mistake would come from.
Deliberately not an admin page. An authenticated HTTP surface on a marketing site is a login form,
a session, a password to rotate and a thing to patch — for an operation performed by the one person
who already has shell on the host, against a file already on their disk. The CSV lands in the bind
@@ -464,6 +484,74 @@ mount and is opened locally.
`remove` exists because §9 promises deletion on request and a promise needs a mechanism.
### How phase 5 built the app and the beta
Four decisions taken before the pages were written (org lead, 2026-08-24), plus what the
repositories said when the plan above was checked against them.
**Three things this section had assumed that turned out not to hold.** §10 promised `/app/` "the 14
existing screenshots"; they exist and are the wrong fourteen (D26). §8 never said what a tester
would point the app at, and the app points at nothing by default (D27). And `/app/` can offer a
download today, because every `Android-app` release attaches a signed APK — which §8 and §10 both
omitted, having been written as though Play were the only delivery path.
**D26 — the screenshots are reserved for phase 9, and the slot ships empty.** `docs/android/screenshots/`
is a trusted-device and recovery-code smoke test from 2026-07-22: captured against a development
instance with no seeded content, before the theming work changed how every screen looks, and five
of the fourteen are two-factor prompts. Shipping them would break D4 and would show an app that no
longer looks like that. Phase 9 already stands up the review stack and seeds content for the web
screenshots, so it gains an emulator pass and the phone shots then show the same deployment on the
same day. `src/components/app/Screenshots.astro` exists now, rendering nothing, so filling it is a
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
forward into phase 5.
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
been entered and validated — so an installed app with no deployment behind it is a text field. The
alternative considered and declined was naming UOMysticmoon, which would have opened the beta to
players immediately at the cost of publishing a private shard's address on a public page. The
consequence is accepted rather than hidden: **the beta cannot start until §15's demo VM exists**,
which is the second of the two gates `/beta` states outright. It costs nothing today, because D28's
other gate — no closed test track — is open anyway.
**D28 — `/beta` handles its own POST; there is no `/api/beta-signup`.** §6 specified an endpoint,
and an endpoint cannot report a validation error without JavaScript: it answers with JSON, which
makes the form script-only, or with a redirect, which returns a person who mistyped an address to a
blank form with no explanation. Both are poor on a page whose job is conversion, and the first is
worse on a site with no analytics — a form that silently does nothing for a reader with scripts off
has no way of telling anyone it is broken. Handling the POST in the page costs one on-demand route
and buys a form that works with JavaScript disabled, renders every outcome in the real layout, and
needs no client-side code, so nothing on it argues with the CSP.
**D29 — the APK and the beta get equal billing, and the APK link is currently off.** Two panels of
the same weight: sideload today, or join the closed test for Play delivery and automatic updates.
The beta argues for itself on convenience rather than on being the only door. But the org lead
reports that the published `v0.5.0` build does not work, so `platform.json`'s `androidApk.serviceable`
is `false` and the panel renders a plain statement that the build is being replaced rather than a
link. That flag is the one value in `platform.json` with no authority to check it against, and
deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asserts the two assets
still exist and that `minSdk` still says what "Android 10 or newer" claims, so the link is correct
the moment a working build flips the boolean. The panel is not removed while the link is off: a page
that omitted sideloading would read, to somebody who knows the APK exists, as a page hiding it.
**Three mechanisms this phase added that the plan did not anticipate.**
- **`liveBrand()`, because a server-rendered page cannot use the boot rewrite.** §7's mechanism
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
takes effect on the **next request**, with no restart.
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
and rule 1 resolves links against the build, where it has no file. The fix is not a
`PLANNED_ROUTES` entry — that list's 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 and would become the
permanent exemption the two-way check exists to prevent. Instead the routes are derived from the
source: a page exporting `prerender = false` is one. Delete `beta.astro` and the links fail again.
- **A test suite, for the first time in this repository.** The five checks of §12 all read built
output, and none of this phase's logic appears there — a honeypot can stop working entirely and
produce a build identical to one where it works. `node --test`, named file rather than directory
(`node --test test/` fails on Node 22, which is what CI runs).
---
## 9. Legal pages
@@ -521,8 +609,8 @@ Organised by what a reader is trying to do. A reader should never need to know t
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
| `/app/` | The Android app: what it does, the 14 existing screenshots, and the beta CTA |
| `/beta/` | The closed-beta signup (§8) |
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) |
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
| `/privacy/`, `/terms/` | §9 |
@@ -819,11 +907,11 @@ a mechanism rather than diligence:
| **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring |
| **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot |
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20D25 |
| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) |
| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) |
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
| **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** |
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, **all five check scripts** (tokens, brand, links, facts, types), mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |

View File

@@ -11,10 +11,11 @@ closed beta: **players**, who want the app.
platform state, the org lead's decisions, the information architecture, and the build phases. Read
it before changing anything here.
**Status: phase 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout
shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages
phase 4, and the documentation — the installation path, which is the priority of the whole project —
phase 7.
**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the
homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
are the legal pages (phase 6) and then the documentation — the installation path, which is the
priority of the whole project — in phases 7 and 8.
---
@@ -39,9 +40,12 @@ makes that would otherwise decay quietly.
```bash
npm run check:tokens # no colour literal outside the token file
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
npm run check:brand # the branding pipeline's two quiet failures
npm run check # astro check
npm run verify # all of the above, then a production build
npm test # the beta signup's decision path
npm run build && npm run check:links # every internal link resolves (reads the build)
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
npm run verify # all of the above, in that order
```
**`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in
@@ -71,6 +75,44 @@ every literal `/brand/...` URL in the source through the route's own classifier,
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
**`checkLinks.mjs`** reads `dist/client` rather than `src/`, because half the links these pages
carry are assembled from data files and template literals and a source scan sees an expression. It
also refuses a commit permalink into any org repository — those stop tracking the document they name
without ever 404ing, which is the failure a link checker would otherwise call healthy.
**`npm test`** is the one check that reads none of the above. Everything else inspects built output,
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and
removal. Run the file by name — `node --test test/` fails on Node 22, which is what CI uses.
## The closed-beta signup
`/beta` is the only page that renders per request and the only one that writes anything. It handles
its own POST, so the form works with JavaScript disabled and every outcome renders in the real
layout. The store is SQLite on the `data/` bind mount; **the raw IP address is never recorded**,
only a salted hash used to rate-limit.
There is no admin page, by design — the tester list is managed from a shell:
```bash
npm run beta -- stats # counts, and where the store lives
npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported
npm run beta -- export -- --all # everything, including already-exported rows
npm run beta -- remove someone@example.com
```
| Variable | Default | What it does |
|---|---|---|
| `DATA_DIR` | `./data` | The bind mount holding `beta.sqlite` and `exports/` |
| `BETA_IP_SALT` | random per process | Salts `ip_hash`. Unset means rate limits reset on restart |
| `BETA_FORM_KEY` | random per process | Signs the form token, so a script must fetch the page before posting |
| `BETA_TOTAL_CAP` | `500` | Rows above which the form closes and says so |
| `BETA_PER_HOUR` / `BETA_PER_DAY` | `3` / `24` | Attempts one connection may make |
Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make
every deployment's hashes identical and therefore reversible by anyone holding this repository.
## Branding is bind-mounted data
`brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be
@@ -114,14 +156,20 @@ src/
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
layouts/, components/ The marketing chrome.
pages/ Marketing routes.
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
lib/brand.mjs The single accessor for brand text.
lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two
routes that render per request and so miss the boot rewrite.
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal.
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
config/sidebar.mjs The documentation journey, and the planned tree behind it.
brand-default/ The stock brand, baked into the image and always complete.
scripts/ The build-time checks, plus applyBrand (boot) and brand:assets (manual).
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
and beta.mjs (the tester-list CLI).
test/ node --test. The logic the other checks cannot see.
```
Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides

View File

@@ -28,5 +28,20 @@
"is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
"file — no rebuild, consistent with §7."
],
"demoUrl": ""
"demoUrl": "",
"$comment_beta": [
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
"exists, and /beta renders a waiting state rather than a broken link while it is.",
"",
"It is safe to publish once it is filled in, and that is the whole reason the beta can",
"work with a site that sends no email (D7): the opt-in link only works for addresses",
"already on the tester list, so anyone else who opens it is refused. Google does not",
"notify testers on the email-list path either — Discord carries the announcement.",
"",
"/beta is one of the two routes that render per request, so unlike every other field",
"here this one is read from the mounted copy on the NEXT REQUEST rather than at the",
"next restart. Paste the URL in and reload the page."
],
"betaOptInUrl": ""
}

420
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@fontsource-variable/cinzel": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"astro": "^7.2.4",
"better-sqlite3": "^12.11.1",
"sharp": "^0.35.3"
},
"devDependencies": {
@@ -3123,6 +3124,26 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcp-47": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz",
@@ -3148,12 +3169,70 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -3235,6 +3314,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
@@ -3508,6 +3593,30 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
@@ -3703,6 +3812,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -3928,6 +4046,15 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expressive-code": {
"version": "0.44.1",
"resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz",
@@ -4011,6 +4138,12 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/find-process": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz",
@@ -4064,6 +4197,12 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4116,6 +4255,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
@@ -4593,12 +4738,38 @@
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -6164,6 +6335,33 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -6204,6 +6402,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/neotraverse": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz",
@@ -6226,6 +6430,18 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -6301,6 +6517,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
@@ -6547,6 +6772,33 @@
"node": ">=4"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
@@ -6591,6 +6843,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/radix3": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
@@ -6610,6 +6872,35 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
@@ -7044,6 +7335,26 @@
"@rolldown/binding-win32-x64-msvc": "1.2.5"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/satteri": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz",
@@ -7194,6 +7505,51 @@
"node": ">=20"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7274,6 +7630,15 @@
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -7321,6 +7686,15 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -7385,6 +7759,34 @@
"node": ">=16"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
@@ -7461,6 +7863,18 @@
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/typesafe-path": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
@@ -8285,6 +8699,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",

View File

@@ -18,8 +18,10 @@
"check:tokens": "node scripts/checkTokens.mjs",
"check:brand": "node scripts/checkBrand.mjs",
"check:links": "node scripts/checkLinks.mjs",
"beta": "node scripts/beta.mjs",
"test": "node --test test/beta.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs",
"verify": "npm run check:tokens && npm run check:brand && npm run check && npm run build && npm run check:links && npm run check:facts"
"verify": "npm run check:tokens && npm run check:brand && npm run check && npm test && npm run build && npm run check:links && npm run check:facts"
},
"dependencies": {
"@astrojs/node": "^11.1.4",
@@ -27,6 +29,7 @@
"@fontsource-variable/cinzel": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"astro": "^7.2.4",
"better-sqlite3": "^12.11.1",
"sharp": "^0.35.3"
},
"devDependencies": {

169
scripts/beta.mjs Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5.
*
* node scripts/beta.mjs export → data/exports/<date>.csv, marks rows exported
* node scripts/beta.mjs export --all → everything, including already-exported rows
* node scripts/beta.mjs remove <email> → a deletion request
* node scripts/beta.mjs stats
*
* In the container, with the compose file of §6:
*
* docker compose exec site node scripts/beta.mjs export
*
* ---------------------------------------------------------------------------------------
* WHY THIS IS A CLI AND NOT AN ADMIN PAGE
* ---------------------------------------------------------------------------------------
* §8 is explicit, and the argument is worth restating where somebody might be tempted to
* "improve" it. An authenticated HTTP surface on a marketing site is a login form, a
* session, a password to rotate, a lockout policy and a thing to patch — brought into
* existence for an operation performed by the one person who already has shell on the host,
* against a file already on their disk. Adding it would mean this site had an attack
* surface where it currently has none, and the only thing gained is not having to type a
* command.
*
* The CSV lands in the bind mount and is opened locally. Google Play has no API for adding
* an individual tester — every route into a closed test ends with a human pasting a list —
* so the last step is manual no matter how this is built.
*
* `remove` exists because §9 promises deletion on request, and a promise with no mechanism
* behind it is a sentence.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
EXPORT_DIR,
close,
markExported,
pending,
removeSignup,
stats,
} from '../src/lib/betaStore.mjs';
const [command, ...rest] = process.argv.slice(2);
const USAGE = `
node scripts/beta.mjs export [--all] write a CSV of the tester list
node scripts/beta.mjs remove <email> honour a deletion request
node scripts/beta.mjs stats counts, and where the store lives
`;
/**
* RFC 4180 quoting. Overkill for addresses that have already been validated against a
* regex that admits no commas or quotes — and worth having anyway, because the day this
* function is wrong is the day somebody pastes a corrupted list into a system that emails
* strangers, and nothing about that failure would be visible in the CSV.
*/
const csvCell = (value) => {
const text = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
};
function doExport(all) {
const rows = pending({ all });
if (!rows.length) {
console.log(
all
? 'Nothing to export — the list is empty.'
: 'Nothing new to export. Use --all to re-export rows already marked exported.'
);
return;
}
fs.mkdirSync(EXPORT_DIR, { recursive: true });
// Dated rather than sequential, and suffixed only if a second export happens the same
// day: the file name should say when the list was taken, because that is the question
// being asked when somebody finds three of these in a directory next year.
const day = new Date().toISOString().slice(0, 10);
let file = path.join(EXPORT_DIR, `${day}.csv`);
for (let n = 2; fs.existsSync(file); n += 1) {
file = path.join(EXPORT_DIR, `${day}-${n}.csv`);
}
// Two files, deliberately. Play's tester list wants addresses and nothing else — one per
// line, ready to paste — while the CSV is the record: when they signed up, what they
// agreed to, what state the row is in. Producing only the CSV would mean hand-editing it
// before every paste, which is where a mistake would come from.
const csv = [
['id', 'email', 'created_at', 'status', 'consent_text'].join(','),
...rows.map((row) =>
[row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',')
),
].join('\r\n');
const listFile = file.replace(/\.csv$/, '.txt');
fs.writeFileSync(file, `${csv}\r\n`, 'utf8');
fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8');
const marked = markExported(rows.map((row) => row.id));
console.log(`Wrote ${rows.length} row(s):`);
console.log(` ${file} the record`);
console.log(` ${listFile} paste this into Play`);
console.log(`Marked ${marked} row(s) exported.`);
console.log(
'\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' +
'Testers still have to open the opt-in link themselves; being on the list is not enough.'
);
}
function doRemove(email) {
if (!email) {
console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com');
process.exitCode = 2;
return;
}
const result = removeSignup(email.trim().toLowerCase());
if (result.removed) {
console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`);
console.log(
'If that row was already exported, remove the address from the Play tester list too — ' +
'this store is not the only copy once a CSV has been pasted.'
);
} else if (result.alreadyRemoved) {
console.log(`#${result.id} was already removed. Nothing to do.`);
} else {
console.log('No such address on the list. Nothing to do.');
}
}
function doStats() {
const s = stats();
const rows = [
['store', s.path],
['total rows', s.total],
['new (not yet exported)', s.new],
['exported', s.exported],
['removed', s.removed],
['counting toward the cap', `${s.live} / ${s.cap}`],
['attempts, last 24h', s.attemptsLastDay],
];
const width = Math.max(...rows.map(([label]) => label.length));
for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`);
}
try {
switch (command) {
case 'export':
doExport(rest.includes('--all'));
break;
case 'remove':
doRemove(rest[0]);
break;
case 'stats':
doStats();
break;
default:
console.log(USAGE);
process.exitCode = command ? 2 : 0;
}
} finally {
close();
}

View File

@@ -148,6 +148,7 @@ if (brand) {
'discordInvite',
'giteaOrg',
'demoUrl',
'betaOptInUrl',
];
for (const field of REQUIRED_FIELDS) {
@@ -213,6 +214,17 @@ if (brand) {
' be string-replaced. It is handled by the data-attribute gate instead.'
);
}
// betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the
// only page that reads it renders per request, so it never passes through the boot
// rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting
// it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches.
if (rewritable.includes('betaOptInUrl')) {
fail(
'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' +
' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().'
);
}
}
}

View File

@@ -187,7 +187,48 @@ async function checkWebsiteHasNoReleases() {
}
// ---------------------------------------------------------------------------
// 8. D13 — the contact address lives in exactly one file
// 8. The Android APK `/app/` offers, and the Android version it claims to need
//
// The app is on no store, so the download block on `/app/` links straight at a release
// asset — the one kind of link on this site that 404s the moment a filename changes,
// because the filename carries the version. Both asset names are asserted against
// releases/latest, so a release that renames or drops either turns this repo red before a
// visitor finds a dead link.
//
// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a
// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising
// the minimum in the app would otherwise leave this site telling people with Android 10
// that it works for them. The mapping from API level to the marketing version is a fixed
// table, so checking the number is enough to protect the sentence.
//
// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No
// fetch can tell whether a build works, so that value is a person's word, and the site
// treats it as the gate on the link rather than the link as the gate on itself.
// ---------------------------------------------------------------------------
async function checkAndroidApk() {
const authority = 'Android-app releases/latest assets';
const release = await json('Android-app/releases/latest');
const names = new Set((release.assets || []).map((asset) => asset.name));
const apk = platform.androidApk;
record(`apk asset`, true, names.has(apk.asset), `${authority}${apk.asset}`);
record(`apk checksums`, true, names.has(apk.checksums), `${authority}${apk.checksums}`);
// The asset name carries the version, so it has to agree with the release this site
// already quotes — a mismatch here means one of the two was updated alone.
const tag = String(release.tag_name || '').replace(/^v/, '');
record('apk names the release', true, apk.asset.includes(tag), `${authority}${release.tag_name}`);
const gradleAuthority = 'Android-app main:app/build.gradle.kts';
const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main');
const minSdk = Number(
extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority)
);
record('android minSdk', apk.minSdk, minSdk, gradleAuthority);
}
// ---------------------------------------------------------------------------
// 9. D13 — the contact address lives in exactly one file
// ---------------------------------------------------------------------------
const CONTACT_CHECK = 'contact address (D13)';
@@ -201,6 +242,25 @@ const SCAN_EXT = new Set([
// every commit, and the noreply Gitea uses for the bot identity.
const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']);
/**
* Domains reserved by RFC 2606 and RFC 6761 for documentation and examples.
*
* Phase 5 is what needed this, and the exemption is principled rather than a concession.
* The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so
* that changing the published address stays a file copy. An `example.com` address cannot be
* a contact address — the domain is reserved precisely so that documentation can use it and
* it can never route to anybody — so exempting these weakens nothing.
*
* Without it the rule would have forbidden the signup form's `placeholder="you@example.com"`
* and the CLI's usage line, which is the check telling somebody to write a worse page in
* order to satisfy a rule about a different problem. A check people have to work around is
* one they eventually switch off.
*
* Matched on the domain, not on the exact address, because these appear with whatever local
* part reads best in context.
*/
const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i;
async function* walk(dir) {
let entries;
try {
@@ -226,6 +286,7 @@ async function checkContactAddressIsIsolated() {
const text = readFileSync(file, 'utf8');
for (const match of text.matchAll(EMAIL_RE)) {
if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue;
if (RESERVED_DOMAINS.test(match[0])) continue;
const line = text.slice(0, match.index).split('\n').length;
offenders.push(`${path.relative(ROOT, file)}:${line}${match[0]}`);
}
@@ -270,6 +331,7 @@ async function main() {
checkBundle,
checkReleases,
checkWebsiteHasNoReleases,
checkAndroidApk,
];
for (const check of network) {

View File

@@ -65,6 +65,50 @@ const GITEA_HOST = new URL(platform.gitea.base).host;
*/
const RUNTIME_PREFIXES = ['/brand/'];
/**
* Pages that render per request, and therefore have no file in `dist/client` to resolve
* against — discovered from the source rather than listed here.
*
* Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`,
* which is an asset route with its own checker and is skipped by prefix above; `/beta/` is
* the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like
* any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link
* that is perfectly good.
*
* The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact
* way that list's own comment warns about. 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. It
* would become the permanent exemption the two-way check exists to prevent.
*
* So the route is derived instead: a file under `src/pages/` that exports `prerender =
* false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing
* rules. That is a fact about the source, checkable at the same moment, and it cannot go
* stale — delete `beta.astro` and the links to `/beta/` start failing again immediately,
* which is the behaviour rule 1 is there to provide.
*
* Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand
* route, already covered by prefix, and inventing a matcher for a case that does not exist
* would be guessing at a shape nobody has written yet.
*/
async function findOnDemandRoutes() {
const pagesDir = path.join(ROOT, 'src', 'pages');
const routes = new Set();
for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) {
const source = readFileSync(file, 'utf8');
if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
const relative = path.relative(pagesDir, file).split(path.sep).join('/');
if (relative.includes('[')) continue;
const withoutExt = relative.replace(/\.(astro|ts|js)$/, '');
const name = withoutExt.replace(/(^|\/)index$/, '');
routes.add(name ? `/${name}/` : '/');
}
return routes;
}
/**
* Routes the site links today that a later phase builds.
*
@@ -86,8 +130,6 @@ const RUNTIME_PREFIXES = ['/brand/'];
* Adding to it is a deliberate act. If a route is not in §10, it does not belong here.
*/
const PLANNED_ROUTES = new Map([
['/app/', 'phase 5 — the Android app page'],
['/beta/', 'phase 5 — the closed-beta signup'],
['/privacy/', 'phase 6 — the privacy policy'],
['/terms/', 'phase 6 — the terms'],
]);
@@ -103,7 +145,7 @@ function fail(file, line, message) {
failures.push({ file, line, message });
}
async function* walk(dir) {
async function* walk(dir, extensions = ['.html']) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
@@ -112,8 +154,8 @@ async function* walk(dir) {
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (path.extname(entry.name) === '.html') yield full;
if (entry.isDirectory()) yield* walk(full, extensions);
else if (extensions.includes(path.extname(entry.name))) yield full;
}
}
@@ -154,6 +196,8 @@ if (!existsSync(DIST)) {
process.exit(1);
}
const onDemandRoutes = await findOnDemandRoutes();
/* =======================================================================================
1. Internal links resolve
======================================================================================= */
@@ -195,6 +239,10 @@ for await (const file of walk(DIST)) {
if (resolvesInBuild(value)) continue;
// A page that renders per request has no file to find. Checked here rather than as a
// prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes.
if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue;
const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, ''));
if (planned) {
plannedSeen.add(value.replace(/[?#].*$/, ''));

View File

@@ -0,0 +1,124 @@
---
/**
* The app's screenshot strip — defined now, empty until phase 9 (D26).
*
* ---------------------------------------------------------------------------------------
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
* ---------------------------------------------------------------------------------------
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
* development instance with no seeded content, before the theming work that changed how
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
*
* Shipping them would break two things at once: D4, which says real screenshots from the
* review stack rather than placeholders, and §1, because they would show an app that no
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
* it, and phase 9 is already the phase that stands up the review stack and seeds the
* content the web screenshots need. Adding an emulator pass to a rig that is being built
* anyway is most of the work already done, and it has the property that the phone shots
* and the browser shots then show the same deployment on the same day.
*
* The component exists rather than the page carrying a `TODO` because a defined shape is
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
*
* ---------------------------------------------------------------------------------------
* WHAT PHASE 9 SHOULD PUT HERE
* ---------------------------------------------------------------------------------------
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
* seeded review stack, one per idea rather than one per screen: the shard hub with live
* data, the marketplace, a character sheet, the news list, the notification settings, and
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
* target — it was the number that happened to exist.
*
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
* the image, not branding an operator overrides (§7).
*/
/**
* One capture. `width` and `height` are the real pixel dimensions and are required rather
* than optional: without them the page reflows as each image decodes, and a strip of six
* phone screenshots is the worst possible place for that.
*
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
* annotated by inference is `any[]` and `astro check` is right to refuse it.
*/
interface Shot {
/** Site-absolute path under `/screens/`. */
src: string;
/** What the screen shows, for somebody who cannot see it. */
alt: string;
caption: string;
width: number;
height: number;
}
const shots: Shot[] = [];
---
{
shots.length > 0 && (
<section class="page section shots">
<h2>What it looks like</h2>
<p class="prose shots__lede">
Captured against a real deployment with real content, not mocked up. The app takes
its colours, type and navigation from the site it is connected to, so these show one
community's app rather than a neutral one.
</p>
<ul class="shots__grid">
{shots.map((shot) => (
<li class="shots__item">
<img
src={shot.src}
alt={shot.alt}
width={shot.width}
height={shot.height}
loading="lazy"
decoding="async"
/>
<p class="shots__caption">{shot.caption}</p>
</li>
))}
</ul>
</section>
)
}
<style>
.shots h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.shots__lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
.shots__grid {
display: grid;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
.shots__item img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.shots__caption {
margin: 0.85rem 0 0;
color: var(--dim);
font-size: 0.88rem;
}
</style>

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"

183
src/lib/betaSignup.mjs Normal file
View File

@@ -0,0 +1,183 @@
/**
* betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5.
*
* Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this
* is about not trusting a request. The split is what lets the tests drive the whole
* decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch
* database without a server, which is the only way this logic gets exercised at all.
*
* ---------------------------------------------------------------------------------------
* THE ORDER OF THE CHECKS IS PART OF THE DESIGN
* ---------------------------------------------------------------------------------------
* Cheap and silent first, expensive and honest last:
*
* 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately.
* A bot that is told it failed learns which field to leave alone next time; a bot that
* is told it succeeded goes away. Nothing is written.
* 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it
* can post to it. Without the signature the timing check is theatre: `ts` is a number
* in a hidden field and a bot can put yesterday's in it as easily as today's.
* 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab.
* 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes.
* 5. CAP — the form closes at `totalCap` and says so.
* 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and
* the only two that get a specific, useful message.
*
* Steps 35 are checked before the address is even parsed, so a limited caller is never
* told anything about an address, and step 6's messages can be specific precisely because
* everything that could be probing has already been turned away.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs';
import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs';
/**
* The key that signs a rendered form.
*
* Random per process when unset, and that is the right default rather than a compromise:
* the only cost is that forms rendered before a restart are refused (the page re-renders and
* the person tries again), and the alternative — a constant baked into the source — would
* let anyone holding this repository mint tokens for every deployment of it.
*/
const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex');
const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex');
/** The value of the hidden `ts` field: when the page rendered, and proof that it did. */
export function issueFormToken(at = Date.now()) {
return `${at}.${sign(at)}`;
}
/**
* Verify a form token and return how long ago it was issued, or `null` if it is not ours.
*/
export function readFormToken(token) {
if (typeof token !== 'string') return null;
const dot = token.indexOf('.');
if (dot < 1) return null;
const at = Number.parseInt(token.slice(0, dot), 10);
if (!Number.isFinite(at)) return null;
const given = Buffer.from(token.slice(dot + 1), 'utf8');
const want = Buffer.from(sign(at), 'utf8');
if (given.length !== want.length || !timingSafeEqual(given, want)) return null;
return { at, ageSeconds: (Date.now() - at) / 1000 };
}
/**
* Address validation.
*
* Deliberately not RFC 5322. That grammar admits quoted strings, comments and address
* literals, and a form whose job is to produce a line in a Google Play tester list gains
* nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person
* types, with a length bound that stops the column being used as storage.
*
* Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is
* about what gets *written* — a CSV pasted into Play should not carry a stranger's
* capitalisation choices as though they were significant.
*/
const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
export function normaliseEmail(raw) {
const value = String(raw ?? '').trim().toLowerCase();
if (!value || value.length > 254) return null;
if (!EMAIL_RE.test(value)) return null;
return value;
}
/**
* The outcomes the page renders. One per branch, so the markup never has to interpret a
* message string, and so a new branch cannot be added without giving it a name here.
*/
export const OUTCOME = {
ADDED: 'added',
DUPLICATE: 'duplicate',
/** Honeypot. Renders as success and writes nothing. */
DECOY: 'decoy',
STALE: 'stale',
TOO_FAST: 'too-fast',
LIMITED: 'limited',
FULL: 'full',
INVALID_EMAIL: 'invalid-email',
NO_CONSENT: 'no-consent',
ERROR: 'error',
};
/** Did this outcome put something in front of the person that looks like success? */
export const isSuccess = (outcome) =>
outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY;
/**
* Run a submitted form through every check and, if it survives, write the row.
*
* `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a
* test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a
* message on one page load, not a stack trace in a person's browser.
*/
export function submit({ form, ip, userAgent }) {
const ipHash = hashIp(ip);
const get = (name) => {
const value = form.get(name);
return typeof value === 'string' ? value : '';
};
// 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real
// person's rate limit for a shared address by tripping a field that person cannot see.
if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY };
// 2 and 3. The form has to have come from a page we rendered, recently but not too
// recently. A missing or forged token is treated as staleness rather than as an
// accusation: the honest cause — a restart, a tab open since yesterday — is far more
// common than the dishonest one, and the remedy the page offers is the same. Like the
// honeypot it costs no attempt, for the same reason: a caller that never obtained a
// token must not be able to spend the budget of everyone behind a shared address.
const token = readFormToken(get(fields.ISSUED));
if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE };
if (token.ageSeconds < limits.minSeconds) {
recordAttempt(ipHash, OUTCOME.TOO_FAST);
return { outcome: OUTCOME.TOO_FAST };
}
// 4. The rate limit, before anything is parsed.
const counts = attemptCounts(ipHash);
if (counts.hour >= limits.perHour || counts.day >= limits.perDay) {
recordAttempt(ipHash, OUTCOME.LIMITED);
return { outcome: OUTCOME.LIMITED };
}
// 5. The cap. Checked here rather than only when rendering the form, because the form
// a person is looking at may have been rendered before the last row went in.
if (isFull()) {
recordAttempt(ipHash, OUTCOME.FULL);
return { outcome: OUTCOME.FULL };
}
// 6. The two things a real person gets wrong.
const email = normaliseEmail(get(fields.EMAIL));
if (!email) {
recordAttempt(ipHash, OUTCOME.INVALID_EMAIL);
return { outcome: OUTCOME.INVALID_EMAIL };
}
if (!get(fields.CONSENT)) {
recordAttempt(ipHash, OUTCOME.NO_CONSENT);
return { outcome: OUTCOME.NO_CONSENT, email };
}
try {
const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT });
const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED;
recordAttempt(ipHash, outcome);
return { outcome, email };
} catch (error) {
// A full disk, a read-only mount, a corrupt file. The operator gets the detail; the
// person gets a page that admits it went wrong rather than one that pretends it did not.
console.error('[beta] could not record a signup:', error);
return { outcome: OUTCOME.ERROR, email };
}
}

340
src/lib/betaStore.mjs Normal file
View File

@@ -0,0 +1,340 @@
/**
* betaStore.mjs — the closed-beta signup store. PLAN.md §8, built in phase 5.
*
* ---------------------------------------------------------------------------------------
* ONE FILE ON A BIND MOUNT, AND NOTHING ELSE
* ---------------------------------------------------------------------------------------
* SQLite at `<data>/beta.sqlite`, where `<data>` is `/app/data` in the container and
* `./data` in a working tree — the same shape as the brand mount (§6, §7). The whole store
* is one file the operator can copy, back up, or delete, on a disk they already have shell
* on. That is the property that lets §8 refuse to build an admin page: there is no
* authenticated surface on this site, because every operation on this data is performed by
* the one person who can already `cd` to the directory.
*
* ---------------------------------------------------------------------------------------
* WHAT IS AND IS NOT STORED
* ---------------------------------------------------------------------------------------
* The address, because pasting it into Play is the point. The consent wording, because a
* record of consent that cannot reproduce the words is not one. A user agent, because it
* is the only signal that separates a browser from a script after the fact.
*
* **Never the IP address.** `ip_hash` is a salted SHA-256 and the salt lives in the
* environment, so the linkage is destroyed by rotating an env var rather than by a
* migration — and a copy of this file, on its own, cannot be turned back into a list of
* people's addresses. Rate limiting works fine against a hash; identification does not.
*
* ---------------------------------------------------------------------------------------
* OPENED LAZILY, AND SURVIVING NOT BEING OPENABLE
* ---------------------------------------------------------------------------------------
* The database is opened on first use, not at import. Two reasons, both real: the CLI and
* the server import overlapping code and only one of them should create a file in a
* developer's working tree, and `/beta` is a prerendered-looking page whose GET must render
* even when the mount is missing or read-only. A signup that cannot be written is an error
* on one request; a store that throws at import is a site that will not boot.
*/
import { createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { limits } from '../data/beta.mjs';
/** The bind mount. `DATA_DIR` matches `BRAND_DIR`'s convention in `brandAssets.mjs`. */
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
/** The store itself. Overridable outright so the tests can point at a scratch file. */
export const DB_PATH = process.env.BETA_DB || path.join(DATA_DIR, 'beta.sqlite');
/** Where `scripts/beta.mjs export` writes. §8 names this path. */
export const EXPORT_DIR = path.join(DATA_DIR, 'exports');
/**
* The salt for `ip_hash`.
*
* A missing salt is not an error, because a site that refuses to serve a signup form over a
* missing environment variable is worse than one that rate-limits against a salt nobody
* wrote down. But an *unset* salt must never be a *constant* — a hard-coded default would
* make every deployment's hashes identical and therefore reversible by anyone with the
* source, which is this project's threat model in one sentence. So the fallback is random
* per process: rate limiting works within a run, and the linkage does not survive a restart.
*
* Set `BETA_IP_SALT` in the compose file to make the limiter outlive a deploy.
*
* Resolved on first use rather than at import, and the warning goes with it. The CLI shares
* this module and never hashes anything, so an eager constant meant `beta.mjs stats` opened
* with a warning about a variable that operation does not use — which is how an operator
* learns to read past warnings.
*/
let ipSalt = null;
function salt() {
if (ipSalt) return ipSalt;
ipSalt = process.env.BETA_IP_SALT || '';
if (!ipSalt) {
console.warn(
'[beta] BETA_IP_SALT is not set — using a random per-process salt. Rate limits will ' +
'reset on restart. Set it in the compose environment to make them persist.'
);
ipSalt = randomBytes(32).toString('hex');
}
return ipSalt;
}
/**
* The schema of §8, verbatim, plus the one table §8 describes in prose but does not draw.
*
* `email … COLLATE NOCASE` on the UNIQUE constraint is what makes a duplicate answerable
* idempotently rather than twice: `Foo@example.com` and `foo@example.com` are one person,
* and the store — not the caller — is where that has to be true, because the caller is
* three different entry points.
*
* `attempts` is the token bucket §8 asks for, persisted as the events themselves rather
* than as a counter. A counter would need a decay schedule and a clock it trusts; a rolling
* count over rows needs neither, prunes trivially, and can answer "why was this refused"
* long enough after the fact to be useful. It records EVERY attempt that reaches the
* limiter, not every success — a script hammering invalid addresses is exactly the traffic
* the limit exists for, and counting only what succeeded would exempt it.
*/
const SCHEMA = `
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL,
ip_hash TEXT NOT NULL,
user_agent TEXT,
consent_text TEXT NOT NULL,
status TEXT NOT NULL,
note TEXT
);
CREATE INDEX IF NOT EXISTS signups_status ON signups (status);
CREATE TABLE IF NOT EXISTS attempts (
id INTEGER PRIMARY KEY,
ip_hash TEXT NOT NULL,
at TEXT NOT NULL,
outcome TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS attempts_ip_at ON attempts (ip_hash, at);
`;
/** Row states. `exported` means "pasted into Play", which only the CLI can know. */
export const STATUS = { NEW: 'new', EXPORTED: 'exported', REMOVED: 'removed' };
let db = null;
/**
* Open (and migrate) the store, creating the mount directory if it is missing.
*
* WAL is on because two processes touch this file: the server, and the operator running the
* CLI against a running container. In the default rollback journal a reader blocks a writer,
* so `beta.mjs stats` during a signup is a locking error rather than a number. `busy_timeout`
* covers the rest — the writes here are single rows, and waiting five seconds is always
* better than failing a person's signup.
*/
export function open() {
if (db) return db;
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
/** Release the handle. The tests need it; the server never calls it. */
export function close() {
if (!db) return;
db.close();
db = null;
}
/** The salted hash §8 stores in place of an address. */
export function hashIp(ip) {
return createHash('sha256')
.update(`${salt()}:${ip || 'unknown'}`)
.digest('hex');
}
const nowIso = () => new Date().toISOString();
const isoAgo = (ms) => new Date(Date.now() - ms).toISOString();
/**
* Record an attempt, and prune the ones too old to inform any limit.
*
* Pruning on write rather than on a timer keeps the whole store self-maintaining: there is
* no scheduler in this container and nothing should have to remember to run. The window is
* the longest limit plus a margin, so nothing a limit still needs is ever thrown away.
*/
export function recordAttempt(ipHash, outcome) {
const handle = open();
handle.prepare('INSERT INTO attempts (ip_hash, at, outcome) VALUES (?, ?, ?)').run(
ipHash,
nowIso(),
outcome
);
handle.prepare('DELETE FROM attempts WHERE at < ?').run(isoAgo(48 * 60 * 60 * 1000));
}
/**
* How many attempts this hash has made in the last hour and the last day.
*/
export function attemptCounts(ipHash) {
const handle = open();
const count = (since) =>
handle
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE ip_hash = ? AND at >= ?')
.get(ipHash, since).n;
return {
hour: count(isoAgo(60 * 60 * 1000)),
day: count(isoAgo(24 * 60 * 60 * 1000)),
};
}
/** Rows that count against the global cap — everything not withdrawn. */
export function liveCount() {
return open()
.prepare('SELECT COUNT(*) AS n FROM signups WHERE status != ?')
.get(STATUS.REMOVED).n;
}
/** Is the form closed because §8's global cap is reached? */
export function isFull() {
return liveCount() >= limits.totalCap;
}
/**
* Insert a signup, or report that it is already there.
*
* The duplicate case returns `{ duplicate: true }` rather than throwing, and the page says
* "you are already on the list" either way — §8's rule, and the reason for it is not
* politeness. An error that distinguishes "added" from "already enrolled" turns this form
* into an oracle for whether a given address is in the beta, which is a disclosure about a
* person made to anyone who can type their address.
*
* A previously removed address is treated as new, and that follows from `removeSignup`
* rather than being a separate decision: removal ERASES the address, so there is nothing
* left to recognise. Keeping a hash of it in order to say "you were removed" would mean
* retaining a derived identifier for the one person who asked not to be retained. Somebody
* who left and signs up again has chosen to sign up again, which is the correct reading.
*/
export function addSignup({ email, ipHash, userAgent, consentText }) {
const handle = open();
const existing = handle
.prepare('SELECT id, status FROM signups WHERE email = ?')
.get(email);
if (existing) return { duplicate: true, id: existing.id, status: existing.status };
const info = handle
.prepare(
`INSERT INTO signups (email, created_at, ip_hash, user_agent, consent_text, status)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
email,
nowIso(),
ipHash,
// A user agent is a header, and a header is whatever the client felt like sending.
// Truncated because the column is a signal, not a transcript.
(userAgent || '').slice(0, 400) || null,
consentText,
STATUS.NEW
);
return { duplicate: false, id: Number(info.lastInsertRowid), status: STATUS.NEW };
}
/** Rows for the CLI's export, oldest first so a CSV reads in signup order. */
export function pending({ all = false } = {}) {
const handle = open();
return all
? handle
.prepare('SELECT * FROM signups WHERE status != ? ORDER BY id')
.all(STATUS.REMOVED)
: handle.prepare('SELECT * FROM signups WHERE status = ? ORDER BY id').all(STATUS.NEW);
}
/** Mark rows as pasted into Play. */
export function markExported(ids) {
if (!ids.length) return 0;
const handle = open();
const stamp = handle.prepare('UPDATE signups SET status = ?, note = ? WHERE id = ?');
const note = `exported ${nowIso()}`;
const run = handle.transaction((list) => {
for (const id of list) stamp.run(STATUS.EXPORTED, note, id);
return list.length;
});
return run(ids);
}
/**
* Honour a deletion request.
*
* The address itself is overwritten, not just flagged: §9 promises deletion, and a row that
* still holds the address it promised to delete has not delivered on that. What remains is
* a tombstone — the id, the date, and the fact that a removal happened — which is what lets
* the operator answer "did you action my request" without keeping the thing they asked to
* have removed. The placeholder keeps the UNIQUE constraint satisfiable, so the same person
* signing up again later is an ordinary new row.
*
* ONE CONSEQUENCE, AND IT IS THE RIGHT ONE. After this runs, the store cannot tell a
* removed address from one it has never seen — asking twice gives the same answer as asking
* about a stranger. That is what erasure means. The alternative, keeping a hash so a second
* request could say "already removed", would be retaining a derived identifier for the one
* person who has explicitly asked not to be retained, in order to improve a message only an
* operator reads.
*/
export function removeSignup(email) {
const handle = open();
const row = handle.prepare('SELECT id, status FROM signups WHERE email = ?').get(email);
if (!row) return { removed: false };
handle
.prepare(
`UPDATE signups
SET email = ?, ip_hash = '', user_agent = NULL, status = ?, note = ?
WHERE id = ?`
)
.run(`removed-${row.id}@invalid`, STATUS.REMOVED, `removed ${nowIso()}`, row.id);
return { removed: true, id: row.id };
}
/** §8's `stats`. */
export function stats() {
const handle = open();
const byStatus = Object.fromEntries(
handle
.prepare('SELECT status, COUNT(*) AS n FROM signups GROUP BY status')
.all()
.map((row) => [row.status, row.n])
);
return {
total: handle.prepare('SELECT COUNT(*) AS n FROM signups').get().n,
new: byStatus[STATUS.NEW] || 0,
exported: byStatus[STATUS.EXPORTED] || 0,
removed: byStatus[STATUS.REMOVED] || 0,
live: liveCount(),
cap: limits.totalCap,
attemptsLastDay: handle
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE at >= ?')
.get(isoAgo(24 * 60 * 60 * 1000)).n,
path: DB_PATH,
};
}

View File

@@ -1,3 +1,6 @@
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
/**
@@ -37,3 +40,76 @@ export const brand = Object.freeze({ ...brandDefault });
export function brandFields() {
return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$')));
}
/* =========================================================================================
THE LIVE READ, FOR ON-DEMAND ROUTES ONLY (phase 5)
=========================================================================================
Everything above is build-time, and the boot rewrite is what carries the mount into
prerendered HTML. Neither reaches a page that renders per request: `applyBrand.mjs`
rewrites files in `dist/client`, and an on-demand route's HTML never existed as a file.
A server-rendered page reading `brand` would therefore show the STOCK value forever, no
matter what is mounted — §7 quietly untrue, on exactly the page that needs it most.
So `/beta` reads the mount itself. It is allowed to, because it is already executing:
the reason the rest of the site cannot is that it is not running when its HTML is made,
and that argument does not apply here.
It is also strictly better where it applies. The rewrite happens at boot, so changing a
mounted value means restarting the container; this is picked up on the next request. An
operator who pastes the Play opt-in URL into `brand.json` has a working confirmation
screen before they have finished reading this sentence.
The mtime guard is what keeps that from being a file read per request. `statSync` on a
file the OS has cached is cheap enough to do on every render and honest enough to notice
an edit immediately, which a TTL would not be. */
const MOUNTED_BRAND = path.join(
process.env.BRAND_DIR || path.join(process.cwd(), 'brand'),
'brand.json'
);
let cache = { mtimeMs: -1, value: brand };
/**
* The brand as it is on disk right now: the mounted `brand.json` layered over the stock
* one, per key. Use from on-demand routes; prerendered pages must keep using `brand`.
*
* Never throws. A missing mount is the normal case, and a malformed one is the operator's
* typo — both fall back to stock with a log line, for the reason `applyBrand.mjs` gives at
* length: a site up with the wrong logo beats a site down with the right one.
*/
export function liveBrand() {
let mtimeMs;
try {
mtimeMs = statSync(MOUNTED_BRAND).mtimeMs;
} catch {
// No mounted file. Cache the stock answer against a sentinel so the miss is not
// re-statted into a re-parse every request.
if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, value: brand };
return cache.value;
}
if (mtimeMs === cache.mtimeMs) return cache.value;
let mounted = null;
try {
mounted = JSON.parse(readFileSync(MOUNTED_BRAND, 'utf8'));
} catch (error) {
console.error(`[brand] the mounted brand.json is not valid JSON and is being ignored: ${error.message}`);
}
const merged = { ...brand };
if (mounted && typeof mounted === 'object') {
for (const [key, value] of Object.entries(mounted)) {
// Same two rules as the boot rewrite: `$comment` keys are documentation, and a field
// the stock file does not declare is a typo rather than a new feature.
if (key.startsWith('$')) continue;
if (!(key in brand)) continue;
if (typeof value === 'string') merged[key] = value;
}
}
cache = { mtimeMs, value: Object.freeze(merged) };
return cache.value;
}

350
src/pages/app.astro Normal file
View File

@@ -0,0 +1,350 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import Screenshots from '../components/app/Screenshots.astro';
import platform from '../data/platform.json';
import { appFeatures, requiresDeployment } from '../data/app.mjs';
import { playPolicy } from '../data/beta.mjs';
/**
* `/app/` — PLAN.md §10, phase 5.
*
* ---------------------------------------------------------------------------------------
* THE PAGE LEADS WITH A LIMITATION, ON PURPOSE
* ---------------------------------------------------------------------------------------
* Directly under the lede, before a single feature, this page says the app ships pointed at
* nothing and will not open until it is given the address of a deployment. That is an
* unusual thing to put above the fold and it is the right thing here, because the
* alternative is somebody installing a 13 MB client and discovering it on the first screen.
* §1's "understated honesty" is cheapest to keep exactly at the moment it costs a download.
*
* ---------------------------------------------------------------------------------------
* TWO WAYS TO GET IT, EQUALLY WEIGHTED — AND ONE OF THEM IS CURRENTLY OFF
* ---------------------------------------------------------------------------------------
* The org lead's call: the sideload and the beta get the same visual weight, with the beta
* arguing for itself on delivery and updates rather than on being the only door. Both
* panels are the same component, side by side, neither styled as the primary.
*
* The APK panel then has a second state, because the published build does not work. It
* renders `platform.androidApk.serviceable ? <the two download links> : <a plain statement
* that the build is being replaced>`. That flag is a person's judgement rather than a
* fetched fact — `checkFacts.mjs` asserts the assets EXIST but cannot assert they run — so
* turning the link back on is one boolean in `platform.json`, in the same commit as
* whatever release fixed it.
*
* Note what this deliberately does not do: it does not remove the panel. A page that simply
* omitted sideloading while the build is broken would read, to somebody who was told the
* APK exists, as a page hiding it.
*
* ---------------------------------------------------------------------------------------
* THE SCREENSHOT SLOT IS EMPTY AND THAT IS THE DECISION (D26)
* ---------------------------------------------------------------------------------------
* §10 promised "the 14 existing screenshots". They exist, and they are the wrong fourteen:
* a July trusted-device smoke test against an unseeded development instance, captured
* before the theming work landed, showing mostly login and two-factor screens over an
* almost empty home page. Shipping them would break D4 (real screenshots, from the review
* stack, not placeholders) and would show an app that no longer looks like that.
*
* So `Screenshots.astro` renders nothing until phase 9 fills it — the phase that already
* stands up the review stack and seeds presentable content, and now also captures the app
* against it, so the phone shots and the web shots show the same deployment. The component
* exists now so the slot has a defined shape and phase 9 is a data change.
*/
const title = 'The Android app';
const description =
'A native Android client for a Runic Gateway deployment: the shard, the site and your ' +
'account, themed by whichever community you point it at.';
const apk = platform.androidApk;
const release = platform.releases['Android-app'];
const releasePage = `${platform.gitea.base}/${platform.gitea.org}/Android-app/releases`;
const downloadBase = `${releasePage}/download/${release}`;
---
<Base title={title} description={description}>
<PageHeader eyebrow="On your phone" title="The app for a deployment you already use">
<p>
A native Android client — Kotlin and Compose, not a website in a frame. It shows the
live game data a deployment publishes, the news and wiki it hosts, and the parts of
your account that make sense on a phone.
</p>
<p>
It is <a href={releasePage} rel="noopener noreferrer">open source like everything else
here</a>, and it carries no Google messaging dependency: notifications arrive over a
server the operator runs.
</p>
</PageHeader>
<!-- The limitation, before the features. See the note above. -->
<section class="page section">
<div class="panel prereq">
<h2>{requiresDeployment.title}</h2>
<p>{requiresDeployment.body}</p>
</div>
</section>
<section class="page section">
<h2 class="app-h2">What it does</h2>
<p class="prose app-lede">
Grouped by what you would open it for. Most of this is conditional on the deployment
you connect to — a community that runs no game module has a news and account app, and
that is a legitimate way to run this.
</p>
{
appFeatures.map((group) => (
<section class="app-group">
<h3>{group.heading}</h3>
{group.blurb && <p class="app-group__blurb">{group.blurb}</p>}
<ul class="app-grid">
{group.items.map((item) => (
<li class="panel app-item">
<h4>{item.title}</h4>
<p>{item.body}</p>
{item.gate && (
<p class="app-item__gate">
<span class="app-item__gate-label">Needs</span>
{item.gate}
</p>
)}
</li>
))}
</ul>
</section>
))
}
</section>
<Screenshots />
<section class="page section" id="get-it">
<h2 class="app-h2">Two ways to get it</h2>
<p class="prose app-lede">
Neither is the &ldquo;real&rdquo; one. Sideloading works today and always will;
the closed test is how it reaches a phone through Play, with updates that install
themselves.
</p>
<div class="app-getgrid">
<div class="panel app-get">
<p class="eyebrow">Direct download</p>
<h3>The signed APK</h3>
{
apk.serviceable ? (
<>
<p>
Built and signed by the same CI that cuts every release. Android asks you to
allow installing from your browser or file manager the first time; the
checksum file is there so you can verify what you downloaded before you do.
</p>
<p class="app-get__actions">
<a class="btn btn--primary" href={`${downloadBase}/${apk.asset}`} rel="noopener noreferrer">
Download {release}
</a>
<a class="btn btn--ghost" href={`${downloadBase}/${apk.checksums}`} rel="noopener noreferrer">
Checksums
</a>
</p>
</>
) : (
<>
<p>
<strong>The published build is being replaced.</strong> {release} is on the
releases page but does not install and run correctly, so this page does not
link it — a download that wastes your time is worse than no download.
</p>
<p>
The next release restores this. Nothing about the app has been withdrawn and
the source has not moved; it is one build that went out wrong.
</p>
<p class="app-get__actions">
<a class="btn btn--ghost" href={releasePage} rel="noopener noreferrer">
The releases page
</a>
</p>
</>
)
}
<p class="app-get__foot">
Android {apk.minAndroid} or newer &middot; installs as <code>{platform.androidApplicationId}</code>
</p>
</div>
<div class="panel app-get">
<p class="eyebrow">Google Play</p>
<h3>The closed beta</h3>
<p>
Delivery through Play, and updates that arrive on their own instead of being
downloaded again. It is a closed test, so a place on it has to be granted — the
list is being collected now.
</p>
<p>
It has not opened yet, and the page says why in full rather than promising a date:
Play needs {playPolicy.testersRequired} people opted in for {playPolicy.testerDays}
{' '}days before the app can go any further, and a tester needs somewhere to point
it.
</p>
<p class="app-get__actions">
<a class="btn btn--primary" href="/beta/">Join the list</a>
</p>
<p class="app-get__foot">
No email is ever sent &middot; the address is used for the tester list and nothing else
</p>
</div>
</div>
</section>
<NotBuilt scope="app" title="What the app does not do" />
</Base>
<style>
.app-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.app-lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
/* The prerequisite panel. Given a gold edge rather than a warning colour: it is a fact
about the product, not an error state, and D8's house style does not shout. */
.prereq {
border-color: var(--gold-deep);
}
.prereq h2 {
margin: 0 0 0.6rem;
color: var(--gold);
font-size: clamp(1.25rem, 2.6vw, 1.5rem);
}
.prereq p {
margin: 0;
max-width: var(--measure);
color: var(--text);
}
.app-group + .app-group {
margin-top: 2.75rem;
}
.app-group h3 {
margin: 0 0 0.4rem;
color: var(--head);
font-size: 1.25rem;
}
.app-group__blurb {
margin: 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.96rem;
}
.app-grid {
display: grid;
gap: 1rem;
margin: 1.25rem 0 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
.app-item {
display: flex;
flex-direction: column;
}
.app-item h4 {
margin: 0 0 0.5rem;
color: var(--gold);
font-size: 1rem;
}
.app-item p {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.94rem;
}
/* Same foot-of-card treatment as NotBuilt's exit condition, so "what this needs" reads
as the same kind of statement in the same place on every card in a row. */
.app-item__gate {
flex: 0;
margin: 1rem 0 0;
padding-top: 0.8rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.86rem;
}
.app-item__gate-label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
/* Two columns that stay equal. `1fr 1fr` rather than auto-fit is the whole point of the
org lead's "equal billing": auto-fit would let the longer panel take more room and
turn a deliberate tie into an accidental winner. */
.app-getgrid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 720px) {
.app-getgrid {
grid-template-columns: 1fr;
}
}
.app-get {
display: flex;
flex-direction: column;
}
.app-get h3 {
margin: 0.35rem 0 0.75rem;
font-size: 1.3rem;
}
.app-get p {
margin: 0 0 0.9rem;
color: var(--muted);
font-size: 0.95rem;
}
.app-get__actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
/* Pushes the buttons to the same line in both panels regardless of prose length —
the second half of keeping the billing equal. */
margin-top: auto;
padding-top: 0.4rem;
}
.app-get__foot {
margin: 1rem 0 0;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.84rem;
}
.app-get__foot code {
font-family: var(--mono);
font-size: 0.92em;
}
</style>

645
src/pages/beta.astro Normal file
View File

@@ -0,0 +1,645 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import { liveBrand } from '../lib/brand.mjs';
import { isFull, liveCount } from '../lib/betaStore.mjs';
import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs';
import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs';
/**
* `/beta/` — the closed-beta signup. PLAN.md §8, phase 5.
*
* ---------------------------------------------------------------------------------------
* THE SECOND ROUTE THAT EXECUTES PER REQUEST — AND IT HANDLES ITS OWN POST (D28)
* ---------------------------------------------------------------------------------------
* §6 lists the dynamic surface as `GET /brand/*` and `POST /api/beta-signup`. The org lead
* amended that on 2026-08-24: this page is the endpoint, and there is no `/api/` route.
*
* The reason is the one thing the endpoint shape cannot do. A separate API route has to
* answer a browser somehow — as JSON, which means the form only works with JavaScript, or
* as a redirect, which means an invalid address returns the person to a blank form with no
* explanation of what went wrong. Both are worse than they sound on a page whose entire job
* is conversion (§8 says to write it to convert), and the first is worse still on a site
* that has no analytics and no third-party anything: a form that silently does nothing for
* a reader with scripts off is a form that has no way of telling anyone it is broken.
*
* Handling the POST here costs one on-demand route and buys a form that works with
* JavaScript disabled, renders every outcome in the real layout, and needs no client-side
* code at all — so nothing on this page has to argue with the strict CSP either.
*
* ---------------------------------------------------------------------------------------
* TWO GATES, BOTH STATED, NEITHER HIDDEN (D27)
* ---------------------------------------------------------------------------------------
* The beta cannot start yet for two independent reasons — no Play track, and nowhere for a
* tester to point the app (see `beta.mjs` for both in full). The page collects addresses
* anyway, because the list is what makes the first batch possible on day one, and says
* plainly that it is a list rather than a queue that is moving. The demo is rendered
* through `NotBuilt` so the absence appears in the same shape it takes everywhere else on
* the site rather than as an apology invented for this page.
*
* ---------------------------------------------------------------------------------------
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
* ---------------------------------------------------------------------------------------
* When `betaOptInUrl` is mounted, the confirmation screen prints the Play opt-in link. That
* is only safe because of how Play's closed testing works: the link admits addresses that
* are already on the tester list and refuses everyone else. It is what lets D7's "the site
* sends no email" hold — Google does not notify testers on the email-list path either, so
* something has to carry the link, and a page the person is already looking at is a better
* channel than an email nobody can send.
*/
export const prerender = false;
const brand = liveBrand();
/**
* A POST is a submission; anything else is somebody arriving. `Astro.request.formData()`
* parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form
* is the former — no file input, nothing to stream.
*
* The `try` is not defensive dressing. A malformed body throws here, and the person who
* would see that stack trace is somebody whose browser or proxy mangled a request, not an
* attacker — they should get the form back with a message, the same as a stale token.
*/
let result = null;
if (Astro.request.method === 'POST') {
try {
const form = await Astro.request.formData();
result = submit({
form,
// `x-forwarded-for` is whatever the proxy in front of this container puts there, and
// its first entry is the client as that proxy saw it. It is trusted only as far as
// rate limiting, and it is hashed before it is stored — see betaStore.mjs. Behind a
// proxy that does not set it, everyone shares one bucket, which fails toward refusing
// signups rather than toward accepting abuse.
ip:
Astro.request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
Astro.clientAddress,
userAgent: Astro.request.headers.get('user-agent'),
});
} catch (error) {
console.error('[beta] could not read the submitted form:', error);
result = { outcome: OUTCOME.ERROR };
}
}
/**
* The cap is read per render so the form closes the moment it is reached, and so a store
* that cannot be opened at all does not take the page down with it — a `/beta` that shows
* the argument and admits the form is unavailable is worth more than a 500.
*/
let full = false;
let signed = 0;
let storeDown = false;
try {
full = isFull();
signed = liveCount();
} catch (error) {
console.error('[beta] the signup store is not available:', error);
storeDown = true;
}
const showForm = !storeDown && !full && !isSuccess(result?.outcome);
/**
* The message for each outcome. One object rather than a chain of conditionals in the
* markup, so a new outcome added to `OUTCOME` without a message here is visibly missing
* rather than silently rendering an empty box.
*
* The duplicate case says exactly what the added case says, on purpose. §8's rule: an
* answer that distinguished them would turn this form into a way of asking whether any
* given address is in the beta.
*/
const NOTICES = {
[OUTCOME.ADDED]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DUPLICATE]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.DECOY]: {
tone: 'ok',
title: "You're on the list.",
body: 'Nothing else is needed from you right now.',
},
[OUTCOME.STALE]: {
tone: 'warn',
title: 'This form had been open a while.',
body: 'Nothing was submitted. Here it is again — the details you typed were not kept.',
},
[OUTCOME.TOO_FAST]: {
tone: 'warn',
title: 'That was submitted faster than the page could be read.',
body:
'Nothing was recorded. If you are a person and not a script, wait a moment and send ' +
'it again — the check is a crude one and it is occasionally wrong about people.',
},
[OUTCOME.LIMITED]: {
tone: 'warn',
title: 'Too many attempts from your connection.',
body:
'Try again later. The limit counts attempts rather than signups, so a few mistyped ' +
'addresses can reach it — nothing has gone wrong with your place on the list.',
},
[OUTCOME.FULL]: {
tone: 'warn',
title: 'The list is closed for now.',
body: 'It has reached its cap. Discord is the place to hear when it reopens.',
},
[OUTCOME.INVALID_EMAIL]: {
tone: 'warn',
title: "That address doesn't look right.",
body: 'Check it and send it again. It has to be the Google account you use on your phone.',
},
[OUTCOME.NO_CONSENT]: {
tone: 'warn',
title: 'The consent box was not ticked.',
body: 'The address cannot be stored without it, so nothing was recorded.',
},
[OUTCOME.ERROR]: {
tone: 'warn',
title: 'Something went wrong at our end.',
body:
'Your address was not recorded. This is worth reporting in Discord if it keeps ' +
'happening — it means the site has a problem, not that you do.',
},
};
const notice = result ? NOTICES[result.outcome] : null;
/** Only ever shown on a success screen, and only when the track exists. */
const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : '';
const title = 'The closed beta';
const description =
'Join the list for the Runic Gateway Android app closed test. No email is ever sent.';
const formToken = issueFormToken();
---
<Base title={title} description={description}>
<PageHeader eyebrow="Android" title="Join the closed test">
<p>
The <a href="/app/">Android app</a> is heading for Google Play by way of a closed
test. This is the list of people who want a place on it.
</p>
<p>
It has not opened yet, and the two reasons are below rather than behind a
&ldquo;coming soon&rdquo;. Adding your address now means you are in the first batch
rather than hearing about it afterwards.
</p>
</PageHeader>
{
notice && (
<section class="page section beta-notice-wrap">
<div class={`panel beta-notice beta-notice--${notice.tone}`} role="status">
<h2>{notice.title}</h2>
<p>{notice.body}</p>
{isSuccess(result?.outcome) && (
<div class="beta-next">
<h3>What happens next</h3>
<ol>
<li>
Batches are added to the tester list by hand — there is no way to automate
it, so it happens when a person sits down to do it.
</li>
<li>
{optInUrl ? (
<>
Open the opt-in link with the same Google account once you have been
added. It only works for addresses already on the list, so it is safe
to share this page but not useful to.
<br />
<a class="btn btn--primary beta-next__optin" href={optInUrl} rel="noopener noreferrer">
The Play opt-in link
</a>
</>
) : (
<>
When the test track exists you will need to open its opt-in link with
the same Google account. It is not created yet, so there is nothing to
link here — this page will show it as soon as there is.
</>
)}
</li>
<li>
<a href={brand.discordInvite} rel="noopener noreferrer">Discord</a> carries
the announcement for each batch. It has to: this site sends no email, to
you or to anyone, ever.
</li>
</ol>
</div>
)}
</div>
</section>
)
}
<section class="page section">
<h2 class="beta-h2">What it is waiting on</h2>
<p class="prose beta-lede">
Two things, neither of which is a date. Both are visible from outside, so there is no
reason to be vague about them.
</p>
<ol class="beta-gates">
<li class="panel beta-gate">
<p class="eyebrow">Gate one</p>
<h3>The test track</h3>
<p>
The developer account exists; the closed test does not yet. Google needs
{' '}{playPolicy.testersRequired} testers opted in continuously for
{' '}{playPolicy.testerDays} days before the app can be put forward for a
production release, which is exactly why the list is being built before the track
opens rather than after.
</p>
<p class="beta-gate__foot">
Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed
before.
</p>
</li>
<li class="panel beta-gate">
<p class="eyebrow">Gate two</p>
<h3>Somewhere to point it</h3>
<p>
The app is a client and ships pointed at nothing — its first screen asks for the
address of a site running this platform. So a tester needs a deployment, and the
public demo is the one being built for that. Until it is running, a place on the
test would be a place to install an app with nothing behind it.
</p>
<p class="beta-gate__foot">
If you already run a Runic Gateway deployment, this gate does not apply to you —
say so in Discord.
</p>
</li>
</ol>
</section>
<section class="page section">
<h2 class="beta-h2">What a tester needs</h2>
<ul class="beta-reqs">
{
requirements.map((requirement) => (
<li class="panel beta-req">
<h3>{requirement.title}</h3>
<p>{requirement.body}</p>
</li>
))
}
</ul>
</section>
<section class="page section" id="form">
<h2 class="beta-h2">The list</h2>
{
storeDown && (
<div class="panel beta-notice beta-notice--warn">
<h3>The form is unavailable.</h3>
<p>
Signups cannot be recorded at the moment — this is a fault at our end and it has
been logged. Everything else on this page is still true.
</p>
</div>
)
}
{
!storeDown && full && !notice && (
<div class="panel beta-notice beta-notice--warn">
<h3>The list is closed for now.</h3>
<p>
It has reached its cap of {limits.totalCap}. Discord is the place to hear when it
reopens.
</p>
</div>
)
}
{
showForm && (
<div class="beta-formwrap">
{/*
Posts to itself with no fragment. `#form` was the obvious thing to write and it
is wrong twice: Chrome does not honour a fragment on a POST response anyway, and
if it did it would scroll past the notice — which renders under the page header
and is the thing the person needs to read. Landing at the top is the behaviour,
so the markup should say so rather than ask for something else and get it.
*/}
<form class="panel beta-form" method="post" action="/beta/">
<p class="beta-form__intro">
One field and a box to tick. The address has to be the Google account you use
on the phone you would test with — Play matches the tester list against the
account, not the device.
</p>
<label class="beta-form__label" for="beta-email">
Google account email
</label>
<input
class="beta-form__input"
id="beta-email"
type="email"
name={fields.EMAIL}
autocomplete="email"
inputmode="email"
required
maxlength="254"
placeholder="you@example.com"
/>
{/*
The honeypot. Hidden from people in three independent ways because any one of
them alone is a browser quirk away from being visible to somebody using a
screen reader or a text browser: off-screen, removed from the accessibility
tree, and excluded from tab order. `autocomplete="off"` matters most of all —
a browser that helpfully fills this in would fail a real person's signup.
*/}
<div class="beta-form__decoy" aria-hidden="true">
<label for="beta-website">Website</label>
<input
id="beta-website"
type="text"
name={fields.HONEYPOT}
tabindex="-1"
autocomplete="off"
/>
</div>
<input type="hidden" name={fields.ISSUED} value={formToken} />
<label class="beta-form__consent">
<input type="checkbox" name={fields.CONSENT} value="yes" required />
<span>{CONSENT_TEXT}</span>
</label>
<button class="btn btn--primary beta-form__submit" type="submit">
Add me to the list
</button>
<p class="beta-form__foot">
Stored: the address, the wording above, the date, and a one-way hash of your
connection used only to rate-limit this form. Never your IP address itself.
Ask in Discord to have it deleted and it will be.
</p>
</form>
<aside class="beta-count">
<p class="beta-count__n">{signed}</p>
<p class="beta-count__label">
on the list &middot; cap {limits.totalCap}
</p>
<p class="beta-count__note">
Published because a number nobody can see is a number people assume. Play needs
{' '}{playPolicy.testersRequired} to actually opt in, which is a different and
harder number than this one.
</p>
</aside>
</div>
)
}
</section>
<NotBuilt scope="beta" title="What is not in place yet" />
</Base>
<style>
.beta-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.beta-lede {
margin: 0 0 2.25rem;
color: var(--muted);
}
/* The result of a submission, directly under the header where a person's eye already is
after a page reload. `role="status"` so it is announced rather than silently replacing
the form for anyone not looking at the screen. */
.beta-notice-wrap {
padding-top: 0;
}
.beta-notice h2,
.beta-notice h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.beta-notice p {
margin: 0;
max-width: var(--measure);
color: var(--text);
}
.beta-notice--ok {
border-color: var(--mode-live);
}
.beta-notice--ok h2 {
color: var(--mode-live);
}
.beta-notice--warn {
border-color: var(--gold-deep);
}
.beta-notice--warn h2,
.beta-notice--warn h3 {
color: var(--gold);
}
.beta-next {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line-soft);
}
.beta-next h3 {
margin: 0 0 0.75rem;
color: var(--head);
font-size: 1.05rem;
}
.beta-next ol {
margin: 0;
padding-left: 1.25rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-next li + li {
margin-top: 0.75rem;
}
.beta-next__optin {
margin-top: 0.85rem;
}
.beta-gates,
.beta-reqs {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}
.beta-gate h3,
.beta-req h3 {
margin: 0.35rem 0 0.6rem;
color: var(--gold);
font-size: 1.1rem;
}
.beta-gate p,
.beta-req p {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.beta-gate__foot {
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.86rem;
}
/* The form and the counter. The counter is deliberately narrow and secondary — it is
context for the decision, not the reason to make it. */
.beta-formwrap {
display: grid;
gap: 1rem;
align-items: start;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
@media (max-width: 780px) {
.beta-formwrap {
grid-template-columns: 1fr;
}
}
.beta-form__intro {
margin: 0 0 1.5rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.beta-form__label {
display: block;
margin-bottom: 0.4rem;
color: var(--head);
font-size: 0.9rem;
font-weight: 600;
}
.beta-form__input {
display: block;
width: 100%;
max-width: 26rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-input);
background: var(--panel-flat);
color: var(--ink);
font-family: var(--sans);
font-size: 1rem;
}
.beta-form__input:focus-visible {
border-color: var(--portal);
outline: 2px solid var(--portal-bright);
outline-offset: 1px;
}
/* Off-screen rather than `display: none`: a bot that reads CSS skips a hidden field, and
one that does not read CSS fills this in. Kept in the layout and out of everything
else — see the markup for why all three of these are needed. */
.beta-form__decoy {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.beta-form__consent {
display: flex;
gap: 0.7rem;
align-items: flex-start;
margin: 1.5rem 0;
max-width: var(--measure);
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
cursor: pointer;
}
.beta-form__consent input {
flex: none;
margin-top: 0.2rem;
width: 1.05rem;
height: 1.05rem;
accent-color: var(--portal);
}
.beta-form__submit {
margin-bottom: 1.5rem;
}
.beta-form__foot {
margin: 0;
padding-top: 1rem;
border-top: 1px solid var(--line-soft);
max-width: var(--measure);
color: var(--dim);
font-size: 0.85rem;
}
.beta-count {
padding: 1.5rem;
border: 1px solid var(--line-soft);
border-radius: var(--radius-panel);
text-align: center;
}
.beta-count__n {
margin: 0;
color: var(--gold);
font-family: var(--display);
font-size: 3rem;
line-height: 1;
}
.beta-count__label {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.beta-count__note {
margin: 1rem 0 0;
padding-top: 0.9rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.82rem;
text-align: left;
}
</style>

319
test/beta.test.mjs Normal file
View File

@@ -0,0 +1,319 @@
/**
* The signup path, tested where it makes decisions. PLAN.md §8, phase 5.
*
* ---------------------------------------------------------------------------------------
* WHY THIS REPOSITORY HAS A TEST SUITE NOW, HAVING NOT NEEDED ONE FOR FOUR PHASES
* ---------------------------------------------------------------------------------------
* Phases 14 are pages, and pages are checked by the five scripts in §12: a broken link, a
* stale version, a colour literal, a drifted brand contract. Every one of those defects is
* visible in the built output, which is exactly why a check that reads the built output
* catches them.
*
* Phase 5 is the first thing here that is neither a page nor visible in one. Whether a
* honeypot is checked before the rate limit, whether a duplicate is answered idempotently,
* whether the cap counts removed rows — none of that shows up in `dist`, none of it is
* exercised by loading the page, and all of it is the kind of logic that is wrong quietly.
* The honeypot in particular has the worst failure mode available: it can stop working
* entirely and nothing anywhere gets slower, redder or noisier.
*
* So: `node --test`, the same runner the `website` server uses, against a scratch database.
*
* ---------------------------------------------------------------------------------------
* THE ENVIRONMENT HAS TO BE SET BEFORE THE IMPORTS
* ---------------------------------------------------------------------------------------
* `betaStore.mjs` resolves `DB_PATH` and the salt at module scope, and `beta.mjs` reads the
* limits the same way. A dynamic import after `process.env` is set is therefore not a
* stylistic choice — a static import would bind a developer's real `data/beta.sqlite` and
* this file would quietly test, and pollute, the actual list.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, before, beforeEach, describe, it } from 'node:test';
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-beta-'));
process.env.BETA_DB = path.join(scratch, 'beta.sqlite');
process.env.BETA_IP_SALT = 'test-salt';
process.env.BETA_FORM_KEY = 'test-form-key';
process.env.BETA_TOTAL_CAP = '5';
process.env.BETA_PER_HOUR = '3';
process.env.BETA_PER_DAY = '4';
process.env.BETA_MIN_SECONDS = '2';
let store;
let signup;
let data;
before(async () => {
store = await import('../src/lib/betaStore.mjs');
signup = await import('../src/lib/betaSignup.mjs');
data = await import('../src/data/beta.mjs');
});
after(() => {
store.close();
fs.rmSync(scratch, { recursive: true, force: true });
});
beforeEach(() => {
const db = store.open();
db.exec('DELETE FROM signups; DELETE FROM attempts;');
});
/** A form as the handler sees it, aged past the minimum by default. */
function form(overrides = {}, ageSeconds = 5) {
const { fields } = data;
const map = new Map([
[fields.EMAIL, 'person@example.com'],
[fields.CONSENT, 'yes'],
[fields.HONEYPOT, ''],
[fields.ISSUED, signup.issueFormToken(Date.now() - ageSeconds * 1000)],
]);
for (const [key, value] of Object.entries(overrides)) map.set(key, value);
return map;
}
const post = (overrides, ageSeconds, ip = '198.51.100.7') =>
signup.submit({ form: form(overrides, ageSeconds), ip, userAgent: 'test' });
describe('address validation', () => {
it('accepts the shapes a person types, including a plus-alias', () => {
for (const value of ['a@b.co', 'first.last+play@example.co.uk', 'UPPER@Example.COM']) {
assert.ok(signup.normaliseEmail(value), `${value} should be accepted`);
}
});
it('lower-cases, so a CSV does not carry capitalisation as though it mattered', () => {
assert.equal(signup.normaliseEmail(' Person@Example.COM '), 'person@example.com');
});
it('rejects what would break a pasted tester list', () => {
for (const value of [
'',
'no-at-sign',
'two@@example.com',
'trailing@example',
'a b@example.com',
'comma,injection@example.com',
`${'x'.repeat(250)}@example.com`,
]) {
assert.equal(signup.normaliseEmail(value), null, `${value} should be rejected`);
}
});
});
describe('the form token', () => {
it('round-trips an age', () => {
const read = signup.readFormToken(signup.issueFormToken(Date.now() - 30_000));
assert.ok(read);
assert.ok(read.ageSeconds >= 29 && read.ageSeconds < 32);
});
it('refuses a token it did not sign — a bot cannot mint its own timestamp', () => {
assert.equal(signup.readFormToken(`${Date.now()}.deadbeef`), null);
assert.equal(signup.readFormToken(String(Date.now())), null);
assert.equal(signup.readFormToken(''), null);
assert.equal(signup.readFormToken(undefined), null);
});
it('refuses a valid signature over a tampered timestamp', () => {
const token = signup.issueFormToken(Date.now() - 5000);
const [, mac] = token.split('.');
assert.equal(signup.readFormToken(`${Date.now() - 900_000}.${mac}`), null);
});
});
describe('a good submission', () => {
it('is added, and stores the consent wording rather than a version', () => {
const result = post();
assert.equal(result.outcome, signup.OUTCOME.ADDED);
const row = store.open().prepare('SELECT * FROM signups').get();
assert.equal(row.email, 'person@example.com');
assert.equal(row.status, store.STATUS.NEW);
assert.equal(row.consent_text, data.CONSENT_TEXT);
});
it('never stores the IP address itself', () => {
post({}, 5, '203.0.113.9');
const row = store.open().prepare('SELECT ip_hash FROM signups').get();
assert.ok(!row.ip_hash.includes('203.0.113.9'));
assert.equal(row.ip_hash.length, 64);
assert.equal(row.ip_hash, store.hashIp('203.0.113.9'));
});
});
describe('duplicates are answered idempotently', () => {
it('is the same outcome shape whether or not the address was already there', () => {
assert.equal(post().outcome, signup.OUTCOME.ADDED);
assert.equal(post().outcome, signup.OUTCOME.DUPLICATE);
// Both render the identical screen — §8's rule, so the form cannot be used to ask
// whether a given address is enrolled. This asserts the property the page relies on.
assert.ok(signup.isSuccess(signup.OUTCOME.ADDED));
assert.ok(signup.isSuccess(signup.OUTCOME.DUPLICATE));
assert.equal(store.liveCount(), 1);
});
it('treats a differently-cased address as the same person', () => {
post();
assert.equal(post({ [data.fields.EMAIL]: 'PERSON@EXAMPLE.COM' }).outcome, signup.OUTCOME.DUPLICATE);
assert.equal(store.liveCount(), 1);
});
it('treats a re-signup after removal as new, because removal erased the address', () => {
post();
store.removeSignup('person@example.com');
// Not a duplicate: there is nothing left in the store to match against, which is the
// point of erasing rather than flagging. See removeSignup's note.
assert.equal(post().outcome, signup.OUTCOME.ADDED);
const rows = store.open().prepare('SELECT status FROM signups ORDER BY id').all();
assert.deepEqual(
rows.map((row) => row.status),
[store.STATUS.REMOVED, store.STATUS.NEW]
);
});
});
describe('the honeypot', () => {
it('writes nothing and reports success', () => {
const result = post({ [data.fields.HONEYPOT]: 'http://spam.example' });
assert.equal(result.outcome, signup.OUTCOME.DECOY);
assert.ok(signup.isSuccess(result.outcome), 'a bot must be told it succeeded');
assert.equal(store.liveCount(), 0);
});
it('costs no rate-limit budget, so it cannot be used to lock out a shared address', () => {
for (let i = 0; i < 10; i += 1) post({ [data.fields.HONEYPOT]: 'x' });
assert.equal(post().outcome, signup.OUTCOME.ADDED);
});
});
describe('timing', () => {
it('refuses a submission faster than a person could make', () => {
assert.equal(post({}, 0).outcome, signup.OUTCOME.TOO_FAST);
assert.equal(store.liveCount(), 0);
});
it('refuses a form rendered too long ago', () => {
const stale = signup.issueFormToken(Date.now() - (data.limits.maxSeconds + 60) * 1000);
assert.equal(post({ [data.fields.ISSUED]: stale }).outcome, signup.OUTCOME.STALE);
});
});
describe('the rate limit', () => {
it('counts attempts rather than successes, so bad addresses are not free', () => {
const bad = { [data.fields.EMAIL]: 'nope' };
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL);
assert.equal(post().outcome, signup.OUTCOME.LIMITED, 'the hourly budget is spent');
});
it('is per connection, not global', () => {
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.1');
assert.equal(post({}, 5, '198.51.100.1').outcome, signup.OUTCOME.LIMITED);
assert.equal(post({}, 5, '198.51.100.2').outcome, signup.OUTCOME.ADDED);
});
it('tells a limited caller nothing about any address', () => {
post();
for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.3');
// The address IS on the list; a limited caller must still be told only that they are
// limited. Ordering the limit ahead of the lookup is what makes that true.
const result = post({}, 5, '198.51.100.3');
assert.equal(result.outcome, signup.OUTCOME.LIMITED);
assert.equal(result.email, undefined);
});
});
describe('the global cap', () => {
const fill = (n) => {
for (let i = 0; i < n; i += 1) post({ [data.fields.EMAIL]: `p${i}@example.com` }, 5, `10.0.0.${i}`);
};
it('closes the form at the cap', () => {
fill(5);
assert.equal(store.liveCount(), 5);
assert.ok(store.isFull());
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.1').outcome, signup.OUTCOME.FULL);
});
it('does not count a removed row against it', () => {
fill(5);
store.removeSignup('p0@example.com');
assert.equal(store.liveCount(), 4);
assert.ok(!store.isFull());
assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.2').outcome, signup.OUTCOME.ADDED);
});
});
describe('consent', () => {
it('is required, and an unticked box records nothing', () => {
assert.equal(post({ [data.fields.CONSENT]: '' }).outcome, signup.OUTCOME.NO_CONSENT);
assert.equal(store.liveCount(), 0);
});
});
describe('removal', () => {
it('overwrites the address rather than flagging the row', () => {
post();
const result = store.removeSignup('person@example.com');
assert.ok(result.removed);
const row = store.open().prepare('SELECT * FROM signups WHERE id = ?').get(result.id);
assert.equal(row.status, store.STATUS.REMOVED);
assert.equal(row.ip_hash, '');
assert.ok(!row.email.includes('person@example.com'), 'the address must be gone, not marked');
assert.match(row.note, /^removed /);
});
it('is indistinguishable from never having been there, once done', () => {
post();
store.removeSignup('person@example.com');
// Asking again gives exactly the answer a stranger's address gives. That is not a gap
// in the implementation — it is what "the address is gone" has to mean, and the test
// exists to stop somebody "fixing" it by keeping a hash of the removed address.
assert.deepEqual(store.removeSignup('person@example.com'), { removed: false });
assert.deepEqual(store.removeSignup('nobody@example.com'), { removed: false });
});
});
describe('the export', () => {
it('takes new rows and marks them, so a second export does not repeat them', () => {
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.0.1');
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.0.2');
const first = store.pending();
assert.equal(first.length, 2);
store.markExported(first.map((row) => row.id));
assert.equal(store.pending().length, 0);
assert.equal(store.pending({ all: true }).length, 2);
});
it('leaves removed rows out of --all as well as out of the default', () => {
post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.1.1');
post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.1.2');
store.removeSignup('a@example.com');
assert.deepEqual(
store.pending({ all: true }).map((row) => row.email),
['b@example.com']
);
});
});