#!/usr/bin/env node /** * checkFacts.mjs — PLAN.md §12 * * The site quotes versions, protocol numbers and capability lists. §1 records why that * needs a mechanism rather than diligence: an earlier draft of the plan confidently stated * the platform was on protocol 3, because every checkout in the workspace sat on a feature * branch whose local `main` ref had never been fetched, and `git show main:` answered * from a months-old blob. * * So: every fact in `src/data/platform.json` is re-read here from its authority over the * Gitea API — never from a working tree — and any disagreement fails the build. When the * platform moves, this repo goes red so someone updates the site. That failure is the * feature, the same as the Integration Kit's checkCoreApi.js. * * It also enforces one rule that is not a version: no email address may appear in the * source outside `brand-default/brand.json` (D13). The published contact is a personal * address that is meant to stay replaceable by a file copy, and that promise survives * exactly as long as nobody types the address into a paragraph. * * GITEA_TOKEN= node scripts/checkFacts.mjs * * Anonymous raw fetches fail on this instance, so the token is required rather than * optional. A check that silently skips itself is worse than no check. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { readdir } from 'node:fs/promises'; import path from 'node:path'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); const BASE = platform.gitea.base; const ORG = platform.gitea.org; const TOKEN = process.env.GITEA_TOKEN?.trim(); const failures = []; const checked = []; function record(label, expected, actual, authority) { const ok = String(expected) === String(actual); (ok ? checked : failures).push({ label, expected, actual, expectedLabel: 'platform.json says', authority: `${authority} says`, ok, }); } async function api(pathname) { const url = `${BASE}/api/v1/repos/${ORG}/${pathname}`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (!res.ok) { throw new Error(`${res.status} ${res.statusText} for ${url}`); } return res; } /** * A file's bytes, read through the `contents` endpoint rather than `raw`. * * `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea * serves a copy for six hours and this check can read a blob most of a working day old. * That is not theoretical: on the day of the engagement cutover it reported website's * MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site * whose number was right. A check that goes red on stale data is a check people learn to * ignore, which is the one failure mode this file exists to avoid. * * `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is * always the ref's current blob. The cost is a JSON parse and a base64 decode. */ async function raw(repo, filePath, ref) { const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`); if (meta.encoding !== 'base64' || typeof meta.content !== 'string') { throw new Error( `${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).` ); } return Buffer.from(meta.content, 'base64').toString('utf8'); } const json = async (pathname) => (await api(pathname)).json(); /** The first capture group of `re` in `text`, or a thrown error naming what was looked for. */ function extract(text, re, what, authority) { const m = text.match(re); if (!m) throw new Error(`Could not find ${what} in ${authority} — the file's shape changed.`); return m[1]; } // --------------------------------------------------------------------------- // 1. The wire protocol, from the sidecar itself // --------------------------------------------------------------------------- async function checkProtocol() { const authority = 'link main:sidecar/src/main.rs'; const src = await raw('link', 'sidecar/src/main.rs', 'main'); const value = extract( src, /pub const PROTOCOL_VERSION:\s*u32\s*=\s*(\d+)\s*;/, 'PROTOCOL_VERSION', authority ); record('protocol (sidecar)', platform.protocol, Number(value), authority); } // --------------------------------------------------------------------------- // 2. The overlay's declared protocol — the third declaration site // // CLAUDE.md: a protocol bump has to land in overlay.toml in the same PR as the emitters, // or the installer refuses to pair the sidecar with the overlay. If these two ever // disagree, the site must not print either number. // --------------------------------------------------------------------------- async function checkOverlayProtocol() { const authority = 'servuo-plugins main:overlay.toml'; const toml = await raw('servuo-plugins', 'overlay.toml', 'main'); const value = extract(toml, /^\s*protocol\s*=\s*(\d+)\s*$/m, 'protocol', authority); record('protocol (overlay)', platform.protocol, Number(value), authority); } // --------------------------------------------------------------------------- // 3. The Module API version // --------------------------------------------------------------------------- async function checkModuleApi() { const authority = 'website main:server/src/modules/version.js'; const src = await raw('website', 'server/src/modules/version.js', 'main'); const value = extract( src, /MODULE_API_VERSION\s*=\s*['"]([^'"]+)['"]/, 'MODULE_API_VERSION', authority ); record('moduleApi', platform.moduleApi, value, authority); } // --------------------------------------------------------------------------- // 4. The capabilities the installed module actually declares // // §12 names "module-uo's capability list" as one of the facts platform.json holds, and it // was the one fact nothing re-read. That mattered from phase 3 onwards, because the // homepage renders the list rather than merely storing it: `src/data/capabilities.mjs` // asserts at build time that every declared slug is claimed by a named capability on the // page and vice versa. Without this check that assertion was anchored to a local copy // nobody was verifying, so the whole chain rested on someone remembering. // // Sorted before comparing: the manifest's order is the module's business, and a reordered // array is not a changed capability set. A slug appearing or disappearing is. // --------------------------------------------------------------------------- async function checkModuleCapabilities() { const authority = 'Module-uo main:module.json'; const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main')); const declared = [...(manifest.capabilities || [])].sort(); const expected = [...platform.moduleUoCapabilities].sort(); record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority); } // --------------------------------------------------------------------------- // 5. The current bundle // // The manifests live at the ROOT of the `bundles` branch — `current.json`, // `bundle-.json` — not under `bundles/`. Fetching the directory 404s. // --------------------------------------------------------------------------- async function checkBundle() { const authority = 'installer bundles:current.json'; const current = JSON.parse(await raw('installer', 'current.json', 'bundles')); record('bundle.tag', platform.bundle.tag, current.bundle, authority); record('bundle.sidecar', platform.bundle.sidecar, current.link?.tag, authority); record('bundle.overlay', platform.bundle.overlay, current.overlay?.tag, authority); record( 'bundle.servuoMin', platform.bundle.servuoMin, current.overlay?.servuo?.min_version, authority ); // The bundle is protocol-checked by CI when it is published, so this is a third // independent read of the same number rather than a duplicate of check 1. record('protocol (bundle)', platform.protocol, current.protocol, authority); } // --------------------------------------------------------------------------- // 6. Release versions, per repo // --------------------------------------------------------------------------- async function checkReleases() { for (const [repo, expected] of Object.entries(platform.releases)) { const authority = `${repo} releases/latest`; const release = await json(`${repo}/releases/latest`); record(`release ${repo}`, expected, release.tag_name, authority); } } // --------------------------------------------------------------------------- // 7. `website` still publishes nothing // // It ships as container images and is never tagged, so the site refers to the platform by // bundle tag and Module API version instead. The day that changes, this repo should notice // rather than keep quietly omitting a version that now exists. // --------------------------------------------------------------------------- async function checkWebsiteHasNoReleases() { const authority = 'website releases (expected empty)'; const releases = await json('website/releases'); record('websiteHasReleases', platform.websiteHasReleases, releases.length > 0, authority); } // --------------------------------------------------------------------------- // 8. The Android APK `/app/` offers, and the Android version it claims to need // // The app is on no store, so the download block on `/app/` links straight at a release // asset — the one kind of link on this site that 404s the moment a filename changes, // because the filename carries the version. Both asset names are asserted against // releases/latest, so a release that renames or drops either turns this repo red before a // visitor finds a dead link. // // `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a // number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising // the minimum in the app would otherwise leave this site telling people with Android 10 // that it works for them. The mapping from API level to the marketing version is a fixed // table, so checking the number is enough to protect the sentence. // // What is NOT checked is `serviceable` — see the comment beside it in platform.json. No // fetch can tell whether a build works, so that value is a person's word, and the site // treats it as the gate on the link rather than the link as the gate on itself. // --------------------------------------------------------------------------- async function checkAndroidApk() { const authority = 'Android-app releases/latest assets'; const release = await json('Android-app/releases/latest'); const names = new Set((release.assets || []).map((asset) => asset.name)); const apk = platform.androidApk; record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`); record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`); // The asset name carries the version, so it has to agree with the release this site // already quotes — a mismatch here means one of the two was updated alone. const tag = String(release.tag_name || '').replace(/^v/, ''); record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`); const gradleAuthority = 'Android-app main:app/build.gradle.kts'; const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main'); const minSdk = Number( extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority) ); record('android minSdk', apk.minSdk, minSdk, gradleAuthority); } // --------------------------------------------------------------------------- // 9. D13 — the contact address lives in exactly one file // --------------------------------------------------------------------------- const CONTACT_CHECK = 'contact address (D13)'; const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; const SCAN_DIRS = ['src', 'scripts']; const SCAN_EXT = new Set([ '.astro', '.css', '.js', '.mjs', '.ts', '.tsx', '.json', '.md', '.mdx', '.svg', '.html', ]); // Addresses that are not a contact route: the AI-disclosure trailer the org requires on // every commit, and the noreply Gitea uses for the bot identity. const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']); /** * Domains reserved by RFC 2606 and RFC 6761 for documentation and examples. * * Phase 5 is what needed this, and the exemption is principled rather than a concession. * The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so * that changing the published address stays a file copy. An `example.com` address cannot be * a contact address — the domain is reserved precisely so that documentation can use it and * it can never route to anybody — so exempting these weakens nothing. * * Without it the rule would have forbidden the signup form's `placeholder="you@example.com"` * and the CLI's usage line, which is the check telling somebody to write a worse page in * order to satisfy a rule about a different problem. A check people have to work around is * one they eventually switch off. * * Matched on the domain, not on the exact address, because these appear with whatever local * part reads best in context. */ const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i; 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; } } } async function checkContactAddressIsIsolated() { const offenders = []; for (const dir of SCAN_DIRS) { for await (const file of walk(path.join(ROOT, dir))) { const text = readFileSync(file, 'utf8'); for (const match of text.matchAll(EMAIL_RE)) { if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue; if (RESERVED_DOMAINS.test(match[0])) continue; const line = text.slice(0, match.index).split('\n').length; offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`); } } } if (offenders.length) { failures.push({ label: CONTACT_CHECK, expectedLabel: 'PLAN.md D13 requires', expected: 'no email address in src/ or scripts/', authority: ' found', actual: `${offenders.length}:\n ` + offenders.join('\n '), ok: false, }); } else { checked.push({ label: CONTACT_CHECK, expected: 'none', actual: 'none', ok: true }); } } // --------------------------------------------------------------------------- async function main() { if (!TOKEN) { console.error( 'checkFacts: GITEA_TOKEN is not set.\n\n' + ' Anonymous raw fetches fail on this Gitea instance, and a fact check that skips\n' + ' itself is worse than no fact check — a stale version would ship silently.\n\n' + ' Locally: GITEA_TOKEN= npm run check:facts\n' + ' In CI: already wired — .gitea/workflows/pr-checks.yml maps the org-level\n' + ' REGISTRY_TOKEN secret into GITEA_TOKEN for this step.\n' ); process.exit(2); } await checkContactAddressIsIsolated(); const network = [ checkProtocol, checkOverlayProtocol, checkModuleApi, checkModuleCapabilities, checkBundle, checkReleases, checkWebsiteHasNoReleases, checkAndroidApk, ]; for (const check of network) { try { await check(); } catch (error) { failures.push({ label: check.name, expected: 'a readable authority', actual: error.message, authority: 'Gitea API', ok: false, }); } } for (const row of checked) { console.log(` ok ${row.label.padEnd(28)} ${row.actual}`); } if (!failures.length) { console.log( `\ncheckFacts: ${checked.length} facts agree with their authorities ` + `(platform.json verified ${platform.verifiedOn}).` ); return; } console.error('\ncheckFacts: the platform has moved, or the site is wrong.\n'); for (const row of failures) { console.error(` FAIL ${row.label}`); console.error(` ${row.expectedLabel} : ${row.expected}`); console.error(` ${row.authority} : ${row.actual}`); console.error(''); } if (failures.some((row) => row.label === CONTACT_CHECK)) { console.error( 'The contact address belongs in brand-default/brand.json and nowhere else. D13\n' + 'publishes a personal address on the promise that replacing it later costs one\n' + 'file copy, and an address typed into a page is a copy no mount can reach.\n' + 'Read it through src/lib/brand.mjs instead.\n' ); } if (failures.some((row) => row.label !== CONTACT_CHECK)) { console.error( 'Update src/data/platform.json AND re-read every page that quotes the changed value.\n' + 'Do not edit a value here to make this pass — §1 exists because that is how the\n' + 'wrong protocol number got written down in the first place.\n' ); } process.exit(1); } await main();