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

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