feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the export CLI of PLAN.md §8, and adds this repository's first test suite. Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"): - D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke test against an unseeded dev instance, captured before the theming work, and five of the fourteen are two-factor prompts. Shipping them would break D4. Phase 9 already builds the rig, so it gains an emulator pass. - D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole app on a validated deployment address, so a tester needs somewhere to point it. The beta therefore waits on the demo VM, and the page says so. - D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint cannot report a validation error without JavaScript. §6's diagram is amended. - D29 — the APK and the beta get equal billing, and the APK link is off: `androidApk.serviceable` is false because the published v0.5.0 build does not work. The panel stays and states that plainly rather than being removed. Three mechanisms the plan did not anticipate: - `liveBrand()` — a server-rendered page never passes through the boot rewrite, so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in takes effect on the next request rather than the next restart. - `checkLinks.mjs` derives on-demand routes from `prerender = false` in the source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires when a route has been built, and an on-demand route never produces a file, so the entry could never rot out. - `npm test` — the five existing checks all read built output, and none of this logic appears there. A honeypot can stop working and leave the build identical. Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606 reserved domains are not contact addresses; the D13 rule is otherwise unchanged. Verified end to end against the built server: every outcome renders with no JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without a restart, and the export CLI round-trips. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
98
PLAN.md
98
PLAN.md
@@ -243,6 +243,7 @@ somewhere other than the thing it decided. The count of record is **twenty-five*
|
||||
| D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
|
||||
| D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
|
||||
| D20–D25 | §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 |
|
||||
| D26–D29 | §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 D20–D25 |
|
||||
| **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) |
|
||||
|
||||
Reference in New Issue
Block a user