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>
116 lines
5.3 KiB
JavaScript
116 lines
5.3 KiB
JavaScript
import { readFileSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
|
|
|
|
/**
|
|
* The single accessor for brand text (§7). Every template reads brand through here and
|
|
* never imports `brand.json` directly.
|
|
*
|
|
* ---------------------------------------------------------------------------
|
|
* WHAT THIS RETURNS, AND HOW THE MOUNT STILL WINS
|
|
* ---------------------------------------------------------------------------
|
|
* These are the STOCK values, read from `brand-default/brand.json` at build time, and they
|
|
* are what gets baked into the prerendered HTML. That is correct and complete for a stock
|
|
* deployment, which is the common case.
|
|
*
|
|
* The mount reaches the text afterwards, from outside this module. Phase 1 recorded the
|
|
* conflict here — §7 promises that renaming the product or changing the Discord invite is
|
|
* a file edit plus a restart, while §6 prerenders every page, so a build-time value is
|
|
* baked where no mounted file can reach it. The org lead settled it on 2026-08-20:
|
|
* `scripts/applyBrand.mjs` rewrites the built HTML at boot, before the server opens a
|
|
* socket, replacing what was baked with what the mount says. Every page stays prerendered,
|
|
* the docs are covered by the same pass, and Pagefind still has static HTML to index.
|
|
*
|
|
* Two consequences for anyone adding a field here:
|
|
*
|
|
* - A new brand string is not automatically rewritable. Add it to `TEXT_FIELDS` in
|
|
* `applyBrand.mjs`, or it is build-time only and §7 quietly stops being true for it.
|
|
* - The rewrite is a plain string replacement, so a default that is short or that occurs
|
|
* in ordinary markup is unsafe. `scripts/checkBrand.mjs` fails the build for one.
|
|
*
|
|
* Assets never had this problem: `GET /brand/*` reads the mount per request.
|
|
*/
|
|
export const brand = Object.freeze({ ...brandDefault });
|
|
|
|
/**
|
|
* `brand.json` carries `$comment` keys for the operator who opens the mounted copy. They
|
|
* are documentation, not fields, and must never reach a template.
|
|
*/
|
|
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;
|
|
}
|