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:
@@ -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(/[?#].*$/, ''));
|
||||
|
||||
Reference in New Issue
Block a user