Files
runicgateway.com/src/lib/brand.mjs
wtclaude 1313e748ae
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
feat(beta): phase 5 — the app page and the closed-beta signup
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>
2026-08-24 03:50:51 -05:00

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