morphit/apps/web/scripts/build-manifest.mjs

244 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* apps/web/scripts/build-manifest.mjs
*
* Walk apps/web/build/ recursively and emit a SHA-256 manifest of
* every emitted file. TWO output formats, for two DISTINCT purposes
* — do not confuse them (cp319 fixed a launch-path mix-up where the
* release op was fed the wrong one):
*
* 1. DEFAULT (no flag) — reproducible-build fingerprint.
* Output format (one line per file, sorted by path):
* <hex sha-256> ./<relative-path-from-build-dir>
* Same shape as `find . -type f | sort | xargs sha256sum`.
* Written to `apps/web/build-manifest.sha256`. This is the
* "did we ship the same bytes?" artifact a user with a local
* clone diffs against (brag #222 reproducibility; STRIDE audit).
* It is NOT the on-chain release manifest.
*
* 2. `--release-json [outfile]` — the on-chain `morphit_release_v1`
* hash_manifest. Output is a JSON OBJECT mapping each asset's
* SERVED URL path → Subresource-Integrity hash:
* { "/_app/immutable/…": "sha256-<base64>", "/index.html": … }
* This is the EXACT shape @morphit/release-schema
* `validateReleasePayload` requires (SHA256_RE =
* /^sha256-[A-Za-z0-9+/]{43}=$/) and the frontend tamper-check
* (apps/web/src/lib/net/releaseHashCheck.ts) compares the running
* bundle against. Feed it to the release-op builder:
* MORPHIT_BUILD_HASH_MANIFEST_FILE=<outfile> \
* tsx apps/indexer/scripts/release-build-payload.ts > release.json
* Written to `apps/web/build-manifest.release.json` by default.
*
* Keys vs. the default format: the release JSON uses `/<rel>` (a
* leading slash, no `./`) because the frontend fetches each key as a
* same-origin URL path; the reproducibility text uses `./<rel>` to
* match `sha256sum`. Both come from the same single file walk + a
* single digest per file (hex and base64 are two encodings of it).
*
* SIZE: the schema caps the serialized hash_manifest at 64 KB
* (~500 entries). A full SvelteKit build (hundreds of prerendered
* per-locale pages) can exceed that, so `--release-json` supports
* `--prefix <p>` (repeatable) to scope to the tamper-critical
* executable surface, e.g. `--prefix _app/ --prefix index.html
* --prefix service-worker.js`. If the manifest still exceeds the
* cap the script exits non-zero with guidance — it never emits an
* over-cap manifest the release op would reject.
*
* Why a script and not just `find | sha256sum`?
* - Cross-platform: works on macOS where `find -print0` and
* `xargs -0` need GNU coreutils.
* - Stable sort key: locale-independent path sort.
* - Single-file output (sha256sum on macOS spits multiple
* tools that disagree on output format).
*
* Usage:
* npm run build -w apps/web && npm run build:manifest -w apps/web
* node scripts/build-manifest.mjs --release-json --prefix _app/ --prefix index.html
*/
import { createHash } from 'node:crypto';
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const HERE = dirname(__filename);
const WEB_ROOT = join(HERE, '..');
const DEFAULT_BUILD_DIR = join(WEB_ROOT, 'build');
const DEFAULT_TEXT_OUT = join(WEB_ROOT, 'build-manifest.sha256');
const DEFAULT_RELEASE_OUT = join(WEB_ROOT, 'build-manifest.release.json');
/** Mirror of @morphit/release-schema MANIFEST_MAX_SERIALIZED_BYTES.
* Kept in sync by build-manifest-release-json-smoke (which also runs
* the real validateReleasePayload, so a drift here is caught).
* cp430: aligned to the indexer's real per-field JSONB cap (4096) —
* the on-chain manifest is a tamper-critical SUBSET (shell + entry +
* service worker); full per-file coverage is the served /verify.json. */
const MANIFEST_MAX_SERIALIZED_BYTES = 4096;
async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
// Skip symlinks, sockets, etc. Build output shouldn't
// contain these; if it does, it's a sign the build is
// non-deterministic and should be investigated.
}
}
/**
* Walk `buildDir` and return sorted entries, each carrying BOTH hash
* encodings derived from a single digest:
* { rel: '<forward-slash path>', hex: '<64 hex>', sri: 'sha256-<base64>' }
* Pure apart from reading the build files; no writes, no process exit.
*/
export async function computeManifest(buildDir) {
const entries = [];
for await (const path of walk(buildDir)) {
const rel = relative(buildDir, path).split('\\').join('/');
const buf = await readFile(path);
const digest = createHash('sha256').update(buf).digest();
entries.push({
rel,
hex: digest.toString('hex'),
sri: `sha256-${digest.toString('base64')}`
});
}
// Stable sort by path — locale-independent (codepoint compare).
entries.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
return entries;
}
/** Reproducible-build fingerprint: `<hex> ./<rel>` per line, sorted.
* Byte-identical to the pre-cp319 default output. */
export function renderSha256sumText(entries) {
return entries.map((e) => `${e.hex} ./${e.rel}`).join('\n') + '\n';
}
/** Normalize a `--prefix` value to a forward-slash, no-leading-slash
* comparison key (so `/_app/`, `_app/`, and `_app` all match files
* under `_app/…`). */
function normalizePrefix(p) {
return p.replace(/^\/+/, '');
}
/**
* On-chain release hash_manifest: a JSON object mapping each asset's
* SERVED URL path (`/<rel>`) → its `sha256-<base64>` SRI hash, sorted
* by key. Optionally scoped to entries whose path starts with one of
* `prefixes`. Returns the manifest OBJECT (caller serializes).
*
* cp474 — the JSDoc types below are load-bearing, not decoration: with
* `prefixes = []` and no annotation, TypeScript infers the default as
* `never[]`, so every typed caller passing real prefixes was an error the
* moment `scripts/**` started being typechecked.
*
* @param {ReadonlyArray<{ rel: string, sri: string }>} entries
* @param {{ prefixes?: readonly string[] }} [options]
* @returns {Record<string, string>}
*/
export function buildReleaseManifest(entries, { prefixes = [] } = {}) {
const norm = prefixes.map(normalizePrefix).filter((p) => p.length > 0);
const obj = {};
for (const e of entries) {
if (norm.length > 0 && !norm.some((p) => e.rel.startsWith(p))) continue;
obj[`/${e.rel}`] = e.sri;
}
return obj;
}
/** UTF-8 byte length of the COMPACT JSON serialization — matches the
* schema validator's `byteLengthOfJson`, so this guard agrees with
* what `validateReleasePayload` will measure. */
export function manifestSerializedBytes(manifestObj) {
return new TextEncoder().encode(JSON.stringify(manifestObj)).length;
}
// ── CLI (only when run directly) ───────────────────────────────────
async function main() {
const argv = process.argv.slice(2);
let releaseJson = false;
let outOverride = null;
const prefixes = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--release-json') releaseJson = true;
else if (a === '--prefix') {
const v = argv[++i];
if (v) prefixes.push(v);
} else if (!a.startsWith('--') && outOverride === null) outOverride = a;
}
let buildExists;
try {
buildExists = (await stat(DEFAULT_BUILD_DIR)).isDirectory();
} catch {
buildExists = false;
}
if (!buildExists) {
console.error(
`build-manifest: ${DEFAULT_BUILD_DIR} does not exist — run \`npm run build -w apps/web\` first.`
);
process.exit(1);
}
const entries = await computeManifest(DEFAULT_BUILD_DIR);
if (releaseJson) {
const obj = buildReleaseManifest(entries, { prefixes });
const count = Object.keys(obj).length;
if (count === 0) {
console.error(
`build-manifest: --release-json produced 0 entries` +
(prefixes.length ? ` (no files matched --prefix ${prefixes.join(' / ')})` : '') +
` — nothing to pin.`
);
process.exit(1);
}
const bytes = manifestSerializedBytes(obj);
if (bytes > MANIFEST_MAX_SERIALIZED_BYTES) {
console.error(
`build-manifest: release manifest is ${bytes} bytes serialized, over the ` +
`${MANIFEST_MAX_SERIALIZED_BYTES}-byte cap (${count} entries) — the indexer's ` +
`per-field JSONB limit; anything larger is filed 'hash_manifest_too_large'.\n` +
`Scope it to the tamper-critical BOOTSTRAP (shell + entry + service worker):\n` +
` node scripts/build-manifest.mjs --release-json OUT --prefix index.html --prefix service-worker --prefix _app/immutable/entry/\n` +
`then drop the redundant .br/.gz duplicates (the browser fetches the plain\n` +
`files and decompresses them). Full per-file coverage lives in /verify.json.`
);
process.exit(1);
}
const out = outOverride ?? DEFAULT_RELEASE_OUT;
await writeFile(out, JSON.stringify(obj, null, 2) + '\n', 'utf8');
const hasCompressed = Object.keys(obj).some((k) => k.endsWith('.br') || k.endsWith('.gz'));
console.log(
`build-manifest: wrote ${count} entries (${bytes} bytes) to ${out}\n` +
` feed it to the release op:\n` +
` MORPHIT_BUILD_HASH_MANIFEST_FILE=${out} \\\n` +
` tsx apps/indexer/scripts/release-build-payload.ts > release.json\n` +
` NOTE: the whole on-chain payload (manifest + endpoints + treasury)\n` +
` must be UNDER 8192 bytes — Blurt's custom_json limit.` +
(hasCompressed
? `\n This manifest still lists .br/.gz duplicates; the browser fetches\n` +
` the plain files and decompresses them, so those are redundant — strip\n` +
` them to reclaim ~2/3 of the byte budget if you're near the limit.`
: '')
);
return;
}
const out = outOverride ?? DEFAULT_TEXT_OUT;
await writeFile(out, renderSha256sumText(entries), 'utf8');
console.log(`build-manifest: wrote ${entries.length} entries to ${out}`);
}
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
main().catch((err) => {
console.error('build-manifest: failed:', err);
process.exit(1);
});
}