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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user