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

183
src/lib/betaSignup.mjs Normal file
View File

@@ -0,0 +1,183 @@
/**
* betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5.
*
* Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this
* is about not trusting a request. The split is what lets the tests drive the whole
* decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch
* database without a server, which is the only way this logic gets exercised at all.
*
* ---------------------------------------------------------------------------------------
* THE ORDER OF THE CHECKS IS PART OF THE DESIGN
* ---------------------------------------------------------------------------------------
* Cheap and silent first, expensive and honest last:
*
* 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately.
* A bot that is told it failed learns which field to leave alone next time; a bot that
* is told it succeeded goes away. Nothing is written.
* 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it
* can post to it. Without the signature the timing check is theatre: `ts` is a number
* in a hidden field and a bot can put yesterday's in it as easily as today's.
* 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab.
* 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes.
* 5. CAP — the form closes at `totalCap` and says so.
* 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and
* the only two that get a specific, useful message.
*
* Steps 3–5 are checked before the address is even parsed, so a limited caller is never
* told anything about an address, and step 6's messages can be specific precisely because
* everything that could be probing has already been turned away.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs';
import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs';
/**
* The key that signs a rendered form.
*
* Random per process when unset, and that is the right default rather than a compromise:
* the only cost is that forms rendered before a restart are refused (the page re-renders and
* the person tries again), and the alternative — a constant baked into the source — would
* let anyone holding this repository mint tokens for every deployment of it.
*/
const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex');
const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex');
/** The value of the hidden `ts` field: when the page rendered, and proof that it did. */
export function issueFormToken(at = Date.now()) {
return `${at}.${sign(at)}`;
}
/**
* Verify a form token and return how long ago it was issued, or `null` if it is not ours.
*/
export function readFormToken(token) {
if (typeof token !== 'string') return null;
const dot = token.indexOf('.');
if (dot < 1) return null;
const at = Number.parseInt(token.slice(0, dot), 10);
if (!Number.isFinite(at)) return null;
const given = Buffer.from(token.slice(dot + 1), 'utf8');
const want = Buffer.from(sign(at), 'utf8');
if (given.length !== want.length || !timingSafeEqual(given, want)) return null;
return { at, ageSeconds: (Date.now() - at) / 1000 };
}
/**
* Address validation.
*
* Deliberately not RFC 5322. That grammar admits quoted strings, comments and address
* literals, and a form whose job is to produce a line in a Google Play tester list gains
* nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person
* types, with a length bound that stops the column being used as storage.
*
* Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is
* about what gets *written* — a CSV pasted into Play should not carry a stranger's
* capitalisation choices as though they were significant.
*/
const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
export function normaliseEmail(raw) {
const value = String(raw ?? '').trim().toLowerCase();
if (!value || value.length > 254) return null;
if (!EMAIL_RE.test(value)) return null;
return value;
}
/**
* The outcomes the page renders. One per branch, so the markup never has to interpret a
* message string, and so a new branch cannot be added without giving it a name here.
*/
export const OUTCOME = {
ADDED: 'added',
DUPLICATE: 'duplicate',
/** Honeypot. Renders as success and writes nothing. */
DECOY: 'decoy',
STALE: 'stale',
TOO_FAST: 'too-fast',
LIMITED: 'limited',
FULL: 'full',
INVALID_EMAIL: 'invalid-email',
NO_CONSENT: 'no-consent',
ERROR: 'error',
};
/** Did this outcome put something in front of the person that looks like success? */
export const isSuccess = (outcome) =>
outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY;
/**
* Run a submitted form through every check and, if it survives, write the row.
*
* `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a
* test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a
* message on one page load, not a stack trace in a person's browser.
*/
export function submit({ form, ip, userAgent }) {
const ipHash = hashIp(ip);
const get = (name) => {
const value = form.get(name);
return typeof value === 'string' ? value : '';
};
// 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real
// person's rate limit for a shared address by tripping a field that person cannot see.
if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY };
// 2 and 3. The form has to have come from a page we rendered, recently but not too
// recently. A missing or forged token is treated as staleness rather than as an
// accusation: the honest cause — a restart, a tab open since yesterday — is far more
// common than the dishonest one, and the remedy the page offers is the same. Like the
// honeypot it costs no attempt, for the same reason: a caller that never obtained a
// token must not be able to spend the budget of everyone behind a shared address.
const token = readFormToken(get(fields.ISSUED));
if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE };
if (token.ageSeconds < limits.minSeconds) {
recordAttempt(ipHash, OUTCOME.TOO_FAST);
return { outcome: OUTCOME.TOO_FAST };
}
// 4. The rate limit, before anything is parsed.
const counts = attemptCounts(ipHash);
if (counts.hour >= limits.perHour || counts.day >= limits.perDay) {
recordAttempt(ipHash, OUTCOME.LIMITED);
return { outcome: OUTCOME.LIMITED };
}
// 5. The cap. Checked here rather than only when rendering the form, because the form
// a person is looking at may have been rendered before the last row went in.
if (isFull()) {
recordAttempt(ipHash, OUTCOME.FULL);
return { outcome: OUTCOME.FULL };
}
// 6. The two things a real person gets wrong.
const email = normaliseEmail(get(fields.EMAIL));
if (!email) {
recordAttempt(ipHash, OUTCOME.INVALID_EMAIL);
return { outcome: OUTCOME.INVALID_EMAIL };
}
if (!get(fields.CONSENT)) {
recordAttempt(ipHash, OUTCOME.NO_CONSENT);
return { outcome: OUTCOME.NO_CONSENT, email };
}
try {
const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT });
const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED;
recordAttempt(ipHash, outcome);
return { outcome, email };
} catch (error) {
// A full disk, a read-only mount, a corrupt file. The operator gets the detail; the
// person gets a page that admits it went wrong rather than one that pretends it did not.
console.error('[beta] could not record a signup:', error);
return { outcome: OUTCOME.ERROR, email };
}
}

340
src/lib/betaStore.mjs Normal file
View File

@@ -0,0 +1,340 @@
/**
* betaStore.mjs — the closed-beta signup store. PLAN.md §8, built in phase 5.
*
* ---------------------------------------------------------------------------------------
* ONE FILE ON A BIND MOUNT, AND NOTHING ELSE
* ---------------------------------------------------------------------------------------
* SQLite at `<data>/beta.sqlite`, where `<data>` is `/app/data` in the container and
* `./data` in a working tree — the same shape as the brand mount (§6, §7). The whole store
* is one file the operator can copy, back up, or delete, on a disk they already have shell
* on. That is the property that lets §8 refuse to build an admin page: there is no
* authenticated surface on this site, because every operation on this data is performed by
* the one person who can already `cd` to the directory.
*
* ---------------------------------------------------------------------------------------
* WHAT IS AND IS NOT STORED
* ---------------------------------------------------------------------------------------
* The address, because pasting it into Play is the point. The consent wording, because a
* record of consent that cannot reproduce the words is not one. A user agent, because it
* is the only signal that separates a browser from a script after the fact.
*
* **Never the IP address.** `ip_hash` is a salted SHA-256 and the salt lives in the
* environment, so the linkage is destroyed by rotating an env var rather than by a
* migration — and a copy of this file, on its own, cannot be turned back into a list of
* people's addresses. Rate limiting works fine against a hash; identification does not.
*
* ---------------------------------------------------------------------------------------
* OPENED LAZILY, AND SURVIVING NOT BEING OPENABLE
* ---------------------------------------------------------------------------------------
* The database is opened on first use, not at import. Two reasons, both real: the CLI and
* the server import overlapping code and only one of them should create a file in a
* developer's working tree, and `/beta` is a prerendered-looking page whose GET must render
* even when the mount is missing or read-only. A signup that cannot be written is an error
* on one request; a store that throws at import is a site that will not boot.
*/
import { createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { limits } from '../data/beta.mjs';
/** The bind mount. `DATA_DIR` matches `BRAND_DIR`'s convention in `brandAssets.mjs`. */
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
/** The store itself. Overridable outright so the tests can point at a scratch file. */
export const DB_PATH = process.env.BETA_DB || path.join(DATA_DIR, 'beta.sqlite');
/** Where `scripts/beta.mjs export` writes. §8 names this path. */
export const EXPORT_DIR = path.join(DATA_DIR, 'exports');
/**
* The salt for `ip_hash`.
*
* A missing salt is not an error, because a site that refuses to serve a signup form over a
* missing environment variable is worse than one that rate-limits against a salt nobody
* wrote down. But an *unset* salt must never be a *constant* — a hard-coded default would
* make every deployment's hashes identical and therefore reversible by anyone with the
* source, which is this project's threat model in one sentence. So the fallback is random
* per process: rate limiting works within a run, and the linkage does not survive a restart.
*
* Set `BETA_IP_SALT` in the compose file to make the limiter outlive a deploy.
*
* Resolved on first use rather than at import, and the warning goes with it. The CLI shares
* this module and never hashes anything, so an eager constant meant `beta.mjs stats` opened
* with a warning about a variable that operation does not use — which is how an operator
* learns to read past warnings.
*/
let ipSalt = null;
function salt() {
if (ipSalt) return ipSalt;
ipSalt = process.env.BETA_IP_SALT || '';
if (!ipSalt) {
console.warn(
'[beta] BETA_IP_SALT is not set — using a random per-process salt. Rate limits will ' +
'reset on restart. Set it in the compose environment to make them persist.'
);
ipSalt = randomBytes(32).toString('hex');
}
return ipSalt;
}
/**
* The schema of §8, verbatim, plus the one table §8 describes in prose but does not draw.
*
* `email … COLLATE NOCASE` on the UNIQUE constraint is what makes a duplicate answerable
* idempotently rather than twice: `Foo@example.com` and `foo@example.com` are one person,
* and the store — not the caller — is where that has to be true, because the caller is
* three different entry points.
*
* `attempts` is the token bucket §8 asks for, persisted as the events themselves rather
* than as a counter. A counter would need a decay schedule and a clock it trusts; a rolling
* count over rows needs neither, prunes trivially, and can answer "why was this refused"
* long enough after the fact to be useful. It records EVERY attempt that reaches the
* limiter, not every success — a script hammering invalid addresses is exactly the traffic
* the limit exists for, and counting only what succeeded would exempt it.
*/
const SCHEMA = `
CREATE TABLE IF NOT EXISTS signups (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL,
ip_hash TEXT NOT NULL,
user_agent TEXT,
consent_text TEXT NOT NULL,
status TEXT NOT NULL,
note TEXT
);
CREATE INDEX IF NOT EXISTS signups_status ON signups (status);
CREATE TABLE IF NOT EXISTS attempts (
id INTEGER PRIMARY KEY,
ip_hash TEXT NOT NULL,
at TEXT NOT NULL,
outcome TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS attempts_ip_at ON attempts (ip_hash, at);
`;
/** Row states. `exported` means "pasted into Play", which only the CLI can know. */
export const STATUS = { NEW: 'new', EXPORTED: 'exported', REMOVED: 'removed' };
let db = null;
/**
* Open (and migrate) the store, creating the mount directory if it is missing.
*
* WAL is on because two processes touch this file: the server, and the operator running the
* CLI against a running container. In the default rollback journal a reader blocks a writer,
* so `beta.mjs stats` during a signup is a locking error rather than a number. `busy_timeout`
* covers the rest — the writes here are single rows, and waiting five seconds is always
* better than failing a person's signup.
*/
export function open() {
if (db) return db;
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
/** Release the handle. The tests need it; the server never calls it. */
export function close() {
if (!db) return;
db.close();
db = null;
}
/** The salted hash §8 stores in place of an address. */
export function hashIp(ip) {
return createHash('sha256')
.update(`${salt()}:${ip || 'unknown'}`)
.digest('hex');
}
const nowIso = () => new Date().toISOString();
const isoAgo = (ms) => new Date(Date.now() - ms).toISOString();
/**
* Record an attempt, and prune the ones too old to inform any limit.
*
* Pruning on write rather than on a timer keeps the whole store self-maintaining: there is
* no scheduler in this container and nothing should have to remember to run. The window is
* the longest limit plus a margin, so nothing a limit still needs is ever thrown away.
*/
export function recordAttempt(ipHash, outcome) {
const handle = open();
handle.prepare('INSERT INTO attempts (ip_hash, at, outcome) VALUES (?, ?, ?)').run(
ipHash,
nowIso(),
outcome
);
handle.prepare('DELETE FROM attempts WHERE at < ?').run(isoAgo(48 * 60 * 60 * 1000));
}
/**
* How many attempts this hash has made in the last hour and the last day.
*/
export function attemptCounts(ipHash) {
const handle = open();
const count = (since) =>
handle
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE ip_hash = ? AND at >= ?')
.get(ipHash, since).n;
return {
hour: count(isoAgo(60 * 60 * 1000)),
day: count(isoAgo(24 * 60 * 60 * 1000)),
};
}
/** Rows that count against the global cap — everything not withdrawn. */
export function liveCount() {
return open()
.prepare('SELECT COUNT(*) AS n FROM signups WHERE status != ?')
.get(STATUS.REMOVED).n;
}
/** Is the form closed because §8's global cap is reached? */
export function isFull() {
return liveCount() >= limits.totalCap;
}
/**
* Insert a signup, or report that it is already there.
*
* The duplicate case returns `{ duplicate: true }` rather than throwing, and the page says
* "you are already on the list" either way — §8's rule, and the reason for it is not
* politeness. An error that distinguishes "added" from "already enrolled" turns this form
* into an oracle for whether a given address is in the beta, which is a disclosure about a
* person made to anyone who can type their address.
*
* A previously removed address is treated as new, and that follows from `removeSignup`
* rather than being a separate decision: removal ERASES the address, so there is nothing
* left to recognise. Keeping a hash of it in order to say "you were removed" would mean
* retaining a derived identifier for the one person who asked not to be retained. Somebody
* who left and signs up again has chosen to sign up again, which is the correct reading.
*/
export function addSignup({ email, ipHash, userAgent, consentText }) {
const handle = open();
const existing = handle
.prepare('SELECT id, status FROM signups WHERE email = ?')
.get(email);
if (existing) return { duplicate: true, id: existing.id, status: existing.status };
const info = handle
.prepare(
`INSERT INTO signups (email, created_at, ip_hash, user_agent, consent_text, status)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
email,
nowIso(),
ipHash,
// A user agent is a header, and a header is whatever the client felt like sending.
// Truncated because the column is a signal, not a transcript.
(userAgent || '').slice(0, 400) || null,
consentText,
STATUS.NEW
);
return { duplicate: false, id: Number(info.lastInsertRowid), status: STATUS.NEW };
}
/** Rows for the CLI's export, oldest first so a CSV reads in signup order. */
export function pending({ all = false } = {}) {
const handle = open();
return all
? handle
.prepare('SELECT * FROM signups WHERE status != ? ORDER BY id')
.all(STATUS.REMOVED)
: handle.prepare('SELECT * FROM signups WHERE status = ? ORDER BY id').all(STATUS.NEW);
}
/** Mark rows as pasted into Play. */
export function markExported(ids) {
if (!ids.length) return 0;
const handle = open();
const stamp = handle.prepare('UPDATE signups SET status = ?, note = ? WHERE id = ?');
const note = `exported ${nowIso()}`;
const run = handle.transaction((list) => {
for (const id of list) stamp.run(STATUS.EXPORTED, note, id);
return list.length;
});
return run(ids);
}
/**
* Honour a deletion request.
*
* The address itself is overwritten, not just flagged: §9 promises deletion, and a row that
* still holds the address it promised to delete has not delivered on that. What remains is
* a tombstone — the id, the date, and the fact that a removal happened — which is what lets
* the operator answer "did you action my request" without keeping the thing they asked to
* have removed. The placeholder keeps the UNIQUE constraint satisfiable, so the same person
* signing up again later is an ordinary new row.
*
* ONE CONSEQUENCE, AND IT IS THE RIGHT ONE. After this runs, the store cannot tell a
* removed address from one it has never seen — asking twice gives the same answer as asking
* about a stranger. That is what erasure means. The alternative, keeping a hash so a second
* request could say "already removed", would be retaining a derived identifier for the one
* person who has explicitly asked not to be retained, in order to improve a message only an
* operator reads.
*/
export function removeSignup(email) {
const handle = open();
const row = handle.prepare('SELECT id, status FROM signups WHERE email = ?').get(email);
if (!row) return { removed: false };
handle
.prepare(
`UPDATE signups
SET email = ?, ip_hash = '', user_agent = NULL, status = ?, note = ?
WHERE id = ?`
)
.run(`removed-${row.id}@invalid`, STATUS.REMOVED, `removed ${nowIso()}`, row.id);
return { removed: true, id: row.id };
}
/** §8's `stats`. */
export function stats() {
const handle = open();
const byStatus = Object.fromEntries(
handle
.prepare('SELECT status, COUNT(*) AS n FROM signups GROUP BY status')
.all()
.map((row) => [row.status, row.n])
);
return {
total: handle.prepare('SELECT COUNT(*) AS n FROM signups').get().n,
new: byStatus[STATUS.NEW] || 0,
exported: byStatus[STATUS.EXPORTED] || 0,
removed: byStatus[STATUS.REMOVED] || 0,
live: liveCount(),
cap: limits.totalCap,
attemptsLastDay: handle
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE at >= ?')
.get(isoAgo(24 * 60 * 60 * 1000)).n,
path: DB_PATH,
};
}

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