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>
400 lines
16 KiB
JavaScript
400 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* checkBrand.mjs — PLAN.md §7
|
|
*
|
|
* The branding pipeline makes two promises that nothing else in the build can verify, and
|
|
* both fail quietly rather than loudly. This is their mechanism, in the same spirit as
|
|
* `checkTokens.mjs`: diligence does not survive contact with a year of commits.
|
|
*
|
|
* 1. EVERY `/brand/*` URL THE SITE ASKS FOR MUST ACTUALLY RESOLVE.
|
|
* The route serves an allowlist of names and derives a fixed set of sizes. A template
|
|
* that asks for `/brand/logo-44.webp` — a plausible number that is not on the list —
|
|
* gets a 404, and a missing logo is exactly the kind of thing that looks like a
|
|
* styling glitch and survives review. So every literal `/brand/...` in the source is
|
|
* put through the route's own classifier, rather than a copy of its rules.
|
|
*
|
|
* 2. EVERY REWRITABLE BRAND STRING MUST BE SAFE TO REPLACE BLINDLY.
|
|
* `applyBrand.mjs` swaps brand text in built HTML with plain string replacement.
|
|
* That is safe only while the values are distinctive: a `siteName` of "Site", or a
|
|
* tagline that contains the site name inside it, would corrupt pages at boot on a
|
|
* machine nobody is watching. Checking it here makes the failure a red build.
|
|
*
|
|
* 3. `brand-default/` must be complete, because §7 says it always is.
|
|
*
|
|
* node scripts/checkBrand.mjs
|
|
*/
|
|
|
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
import { readdir } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
import sharp from 'sharp';
|
|
|
|
import { classify } from '../src/lib/brandAssets.mjs';
|
|
|
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
const DEFAULTS = path.join(ROOT, 'brand-default');
|
|
|
|
const failures = [];
|
|
const fail = (message) => failures.push(message);
|
|
|
|
/* =======================================================================================
|
|
1. brand-default is complete
|
|
======================================================================================= */
|
|
|
|
/**
|
|
* The stock set is deliberately small. Everything else the site requests — every logo size,
|
|
* both PWA icons, the apple-touch icon, the favicons and the .ico — is derived at runtime
|
|
* from `logo.png`, so that an operator rebrands by replacing one file rather than fifteen.
|
|
* Adding a precomputed derivative here would quietly undo that.
|
|
*/
|
|
const REQUIRED = ['brand.json', 'theme.css', 'logo.png', 'wordmark.svg', 'og-image.png'];
|
|
|
|
for (const name of REQUIRED) {
|
|
const file = path.join(DEFAULTS, name);
|
|
if (!existsSync(file)) {
|
|
fail(`brand-default/${name} is missing — §7 requires the stock brand to be complete.`);
|
|
} else if (statSync(file).size === 0) {
|
|
fail(`brand-default/${name} is empty.`);
|
|
}
|
|
}
|
|
|
|
if (existsSync(path.join(DEFAULTS, 'logo.png'))) {
|
|
const meta = await sharp(path.join(DEFAULTS, 'logo.png')).metadata();
|
|
if (meta.width !== meta.height) {
|
|
fail(`brand-default/logo.png is ${meta.width}x${meta.height}; the mark must be square.`);
|
|
}
|
|
// 512 is the largest thing anything asks for (icon-512.png). A smaller source would be
|
|
// upscaled into an installed app icon, which is where it would be most visible.
|
|
if (meta.width < 512) {
|
|
fail(`brand-default/logo.png is ${meta.width}px; derivatives go up to 512 and must not upscale.`);
|
|
}
|
|
if (!meta.hasAlpha) {
|
|
fail('brand-default/logo.png has no alpha channel; the mark would carry a background.');
|
|
}
|
|
}
|
|
|
|
/* =======================================================================================
|
|
2. Every /brand/* URL in the source resolves
|
|
======================================================================================= */
|
|
|
|
const SCAN_EXT = new Set(['.astro', '.ts', '.tsx', '.js', '.mjs', '.css', '.md', '.mdx', '.json']);
|
|
|
|
async function* walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
yield* walk(full);
|
|
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
const referenced = new Map(); // name -> [where]
|
|
|
|
for await (const file of walk(path.join(ROOT, 'src'))) {
|
|
const source = readFileSync(file, 'utf8');
|
|
const relative = path.relative(ROOT, file);
|
|
|
|
for (const match of source.matchAll(/\/brand\/([a-z0-9][a-z0-9._-]*)/g)) {
|
|
const name = match[1];
|
|
const line = source.slice(0, match.index).split('\n').length;
|
|
if (!referenced.has(name)) referenced.set(name, []);
|
|
referenced.get(name).push(`${relative}:${line}`);
|
|
}
|
|
}
|
|
|
|
for (const [name, sites] of referenced) {
|
|
// The classifier is imported from the route's own module rather than reimplemented, so
|
|
// this check cannot drift from what the server will actually do.
|
|
if (!classify(name)) {
|
|
fail(
|
|
`/brand/${name} is requested by ${sites.join(', ')} but the route would 404 it.\n` +
|
|
` Add it to STATIC_FILES, NAMED_DERIVATIVES or DERIVABLE_SIZES in ` +
|
|
`src/lib/brandAssets.mjs — or use a size that is already on the list.`
|
|
);
|
|
}
|
|
}
|
|
|
|
/* =======================================================================================
|
|
3. The rewritable brand strings are safe to replace blindly
|
|
======================================================================================= */
|
|
|
|
const brandPath = path.join(DEFAULTS, 'brand.json');
|
|
let brand = null;
|
|
|
|
if (existsSync(brandPath)) {
|
|
try {
|
|
brand = JSON.parse(readFileSync(brandPath, 'utf8'));
|
|
} catch (error) {
|
|
fail(`brand-default/brand.json is not valid JSON: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (brand) {
|
|
const REQUIRED_FIELDS = [
|
|
'siteName',
|
|
'tagline',
|
|
'contactEmail',
|
|
'discordInvite',
|
|
'giteaOrg',
|
|
'demoUrl',
|
|
'betaOptInUrl',
|
|
];
|
|
|
|
for (const field of REQUIRED_FIELDS) {
|
|
if (typeof brand[field] !== 'string') {
|
|
fail(`brand.json is missing the string field "${field}".`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
|
|
* restating the list. A field added there and forgotten here would be unchecked; a field
|
|
* added here and forgotten there would be silently build-time only. Either way the two
|
|
* disagreeing is the bug, so the check is that they agree.
|
|
*/
|
|
const applySource = readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8');
|
|
const declared = applySource.match(/const TEXT_FIELDS = \[([^\]]*)\]/);
|
|
|
|
if (!declared) {
|
|
fail('could not find TEXT_FIELDS in scripts/applyBrand.mjs — has it been renamed?');
|
|
} else {
|
|
const rewritable = [...declared[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
|
|
for (const field of rewritable) {
|
|
if (!(field in brand)) {
|
|
fail(`applyBrand.mjs rewrites "${field}", which brand.json does not define.`);
|
|
continue;
|
|
}
|
|
|
|
const value = brand[field];
|
|
|
|
if (value.length < 8) {
|
|
fail(
|
|
`brand.json's "${field}" is ${JSON.stringify(value)} — under 8 characters.\n` +
|
|
` applyBrand.mjs replaces this string across every built page at boot; a short\n` +
|
|
` value will match unrelated markup and corrupt the output.`
|
|
);
|
|
}
|
|
|
|
if (/[<>]|="/.test(value)) {
|
|
fail(`brand.json's "${field}" contains markup characters, which the boot rewrite cannot survive.`);
|
|
}
|
|
|
|
// A value that occurs inside another value is the subtler failure: replacing the
|
|
// shorter one first leaves the longer one half-rewritten, and which runs first is an
|
|
// accident of declaration order.
|
|
for (const other of rewritable) {
|
|
if (other === field) continue;
|
|
if (typeof brand[other] === 'string' && brand[other].includes(value)) {
|
|
fail(
|
|
`brand.json's "${field}" (${JSON.stringify(value)}) occurs inside "${other}".\n` +
|
|
` The boot rewrite would corrupt one while replacing the other.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// demoUrl is gated by markup rather than replaced as text (see applyBrand.mjs), so it
|
|
// is correct for it NOT to be in TEXT_FIELDS. Saying so out loud, because "the demo URL
|
|
// is missing from the rewrite list" is an easy and wrong thing to conclude.
|
|
if (rewritable.includes('demoUrl')) {
|
|
fail(
|
|
'demoUrl must not be in TEXT_FIELDS: its default is the empty string, which cannot\n' +
|
|
' be string-replaced. It is handled by the data-attribute gate instead.'
|
|
);
|
|
}
|
|
|
|
// betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the
|
|
// only page that reads it renders per request, so it never passes through the boot
|
|
// rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting
|
|
// it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches.
|
|
if (rewritable.includes('betaOptInUrl')) {
|
|
fail(
|
|
'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' +
|
|
' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* =======================================================================================
|
|
4. The demo slot's markup contract (§15 / D12)
|
|
=======================================================================================
|
|
|
|
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
|
|
attributes in the built HTML. That is a contract between a script and a template that
|
|
share no code, and it fails in the quietest possible way: an attribute inserted between
|
|
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
|
|
set in the mount, the boot log says nothing, and the link is simply never there.
|
|
|
|
Both halves are checked, and neither is retyped from memory — the literal is derived from
|
|
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
|
|
|
|
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
|
|
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
|
|
: '';
|
|
|
|
const attrTemplate = applyForCheck.match(
|
|
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
|
|
);
|
|
|
|
if (!attrTemplate) {
|
|
fail(
|
|
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
|
|
' Update the expected pair below to match, and re-check every template that writes it.'
|
|
);
|
|
} else {
|
|
// What the script will look for when the applied value is the stock empty string.
|
|
const EMPTY_PAIR = 'href="" data-demo-url=""';
|
|
|
|
let slots = 0;
|
|
const strays = [];
|
|
|
|
for await (const file of walk(path.join(ROOT, 'src'))) {
|
|
if (path.extname(file) !== '.astro') continue;
|
|
|
|
// Comments discuss the contract at length, including in the template that implements
|
|
// it. Scanning them would make the check fail on its own documentation.
|
|
// Blanked rather than removed: keeping every newline and every offset means the line
|
|
// numbers reported below are the ones in the file, not the ones in a shortened copy.
|
|
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
const source = readFileSync(file, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
.replace(/<!--[\s\S]*?-->/g, blank);
|
|
|
|
const relative = path.relative(ROOT, file);
|
|
|
|
slots += source.split(EMPTY_PAIR).length - 1;
|
|
|
|
for (const match of source.matchAll(/data-demo-url/g)) {
|
|
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
|
|
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
|
|
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!slots) {
|
|
fail(
|
|
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
|
|
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
|
|
' brand.json. Removing it makes that a rebuild.'
|
|
);
|
|
}
|
|
|
|
for (const site of strays) {
|
|
fail(
|
|
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
|
|
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
|
|
' the slot will never appear.'
|
|
);
|
|
}
|
|
}
|
|
|
|
/* =======================================================================================
|
|
5. The demo DEEP-link contract (§15 / D25)
|
|
=======================================================================================
|
|
|
|
`/features/` links individual capabilities into the demo, which the slot in §4 cannot
|
|
express — it swaps a whole URL, so it can only ever produce the demo's root. Those links
|
|
carry a third attribute and `applyBrand.mjs` recomputes all three from it.
|
|
|
|
Same failure mode as §4 and the same reason to check it: a template and a script with no
|
|
shared code, agreeing on an exact byte sequence, where disagreement is silent. This one
|
|
is worse in one respect — a broken deep link is INVISIBLE in a stock build, because the
|
|
stock build hides every demo link. It would first appear on the day the org lead sets
|
|
`demoUrl` and finds the new links pointing at the demo's front page, or at nothing.
|
|
|
|
The regex is not retyped here either: it is lifted out of `applyBrand.mjs` and run
|
|
against the stock literal, so this fails if the script's pattern stops matching what the
|
|
templates write — whichever side moved. */
|
|
|
|
const deepPattern = applyForCheck.match(/const DEEP_LINK = \/(.*)\/g;/);
|
|
const EMPTY_DEEP_PREFIX = 'href="" data-demo-url="" ';
|
|
let deepLinkCount = 0;
|
|
|
|
if (!deepPattern) {
|
|
fail(
|
|
'applyBrand.mjs no longer defines DEEP_LINK as a single /…/g literal.\n' +
|
|
' §15/D25 relies on it to fill the per-capability demo links. Update this check to\n' +
|
|
' match the new shape rather than deleting it.'
|
|
);
|
|
} else {
|
|
// Does the script's own pattern still match what a template writes in a stock build?
|
|
const sample = `${EMPTY_DEEP_PREFIX}data-demo-path="/example"`;
|
|
let matches = false;
|
|
try {
|
|
matches = new RegExp(deepPattern[1]).test(sample);
|
|
} catch (error) {
|
|
fail(`applyBrand.mjs's DEEP_LINK is not a usable pattern: ${error.message}`);
|
|
}
|
|
|
|
if (!matches) {
|
|
fail(
|
|
`applyBrand.mjs's DEEP_LINK no longer matches the stock markup \`${sample}\`.\n` +
|
|
' Every per-capability demo link would be left empty and hidden, on a deployment\n' +
|
|
' that has a demo configured — which is the one place nobody would look.'
|
|
);
|
|
}
|
|
|
|
const deepStrays = [];
|
|
let deepLinks = 0;
|
|
|
|
for await (const file of walk(path.join(ROOT, 'src'))) {
|
|
if (path.extname(file) !== '.astro') continue;
|
|
|
|
// Blanked, not stripped — same reason as §4: the line numbers reported have to be the
|
|
// ones in the file.
|
|
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
const source = readFileSync(file, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
.replace(/<!--[\s\S]*?-->/g, blank);
|
|
|
|
const relative = path.relative(ROOT, file);
|
|
|
|
for (const match of source.matchAll(/data-demo-path/g)) {
|
|
deepLinks++;
|
|
const start = match.index - EMPTY_DEEP_PREFIX.length;
|
|
if (start < 0 || source.slice(start, match.index) !== EMPTY_DEEP_PREFIX) {
|
|
deepStrays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const site of deepStrays) {
|
|
fail(
|
|
`${site} writes data-demo-path without the exact prefix \`${EMPTY_DEEP_PREFIX}\`.\n` +
|
|
' applyBrand.mjs matches all three attributes together and in that order; anything\n' +
|
|
' else is invisible to it and the link will never point anywhere.'
|
|
);
|
|
}
|
|
|
|
deepLinkCount = deepLinks;
|
|
}
|
|
|
|
/* ======================================================================================= */
|
|
|
|
if (failures.length) {
|
|
console.error('\ncheckBrand: the branding pipeline has problems.\n');
|
|
for (const failure of failures) console.error(` - ${failure}`);
|
|
console.error('');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
|
|
`every rewritable string is safe to replace, and the demo slot plus ${deepLinkCount} ` +
|
|
`deep link(s) match their contracts.`
|
|
);
|