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

@@ -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(/[?#].*$/, ''));