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

@@ -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;
}