feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
  when a route has been built, and an on-demand route never produces a file, so
  the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

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) |