Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/verify/manifest.mjs

5.5 KiB, 1 run

created by r2519314175:1027, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify/manifest.mjs — seal a built bundle so it can be verified.
2//
3// Run at deploy time, AFTER `wasm-pack build --target web --out-dir www/pkg`
4// and `dev/stamp-build.mjs`, and BEFORE the files leave for the server:
5//
6// wasm-pack build --target web --out-dir www/pkg
7// node dev/stamp-build.mjs "what changed" # www/build.json — the staleness id
8// # and the note; the note is REQUIRED
9// # since 2026-08-21, no commit-subject fallback
10// node verify/manifest.mjs # www/manifest.json + transparency entry
11//
12// It writes `www/manifest.json` — a SHA-256 for every served file and one bundle
13// hash over them all — and appends a chained entry to `verify/transparency.jsonl`,
14// the public, tamper-evident history of what has been shipped. A verifier
15// (verify/check.mjs) then confirms a served site matches the manifest, and that
16// the manifest's bundle hash is in the chain. The browser confirms the same of
17// itself (www/js/verify.js).
18//
19// The manifest is a pure function of the bundle: no timestamps, so an identical
20// build seals to an identical manifest, and two people who build the source get
21// the byte-for-byte same file. When was a build shipped lives in the log's `ts`,
22// where it belongs.
23//
24// No dependencies. `--root <dir>` seals a directory other than www/; `--no-log`
25// writes the manifest without touching the chain (for a dry run).
26
27import { readFile, writeFile } from 'node:fs/promises';
28import { join, normalize } from 'node:path';
29import { fileURLToPath } from 'node:url';
30import { hashTree, bundleHash, parseLog, nextEntry } from './lib.mjs';
31
32const HERE = normalize(join(fileURLToPath(import.meta.url), '..'));
33const args = process.argv.slice(2);
34const rootArg = (() => { const i = args.indexOf('--root'); return i >= 0 ? args[i + 1] : null; })();
35const ROOT = rootArg ? normalize(rootArg) : normalize(join(HERE, '..', 'www'));
36const LOG = join(HERE, 'transparency.jsonl');
37const noLog = args.includes('--no-log');
38
39/// The one-line "what changed" from build.json, if it carries one.
40async function buildNote() {
41 try {
42 const j = JSON.parse(await readFile(join(ROOT, 'build.json'), 'utf8'));
43 return typeof j.note === 'string' ? j.note.trim() : '';
44 } catch (e) { return ''; }
45}
46
47/// The staleness id from build.json, so the manifest and the update chip name
48/// the same build. Falls back to the head of the bundle hash if it is absent —
49/// a seal without a stamp is still a valid seal.
50async function buildId(bundle) {
51 try {
52 const j = JSON.parse(await readFile(join(ROOT, 'build.json'), 'utf8'));
53 if (j && typeof j.build === 'string' && j.build) return j.build;
54 } catch (e) { /* no stamp: derive one */ }
55 return bundle.slice(0, 12);
56}
57
58/// Files that must never reach a served tree, matched by name.
59///
60/// A seal is not the place to quietly leave something out. If one of these is
61/// sitting in the bundle directory at seal time then `rsync` is about to ship
62/// it to production whatever the manifest says, so the only safe answer is to
63/// stop and say so.
64///
65/// `_vtest_*` are fixtures written into www/ by dev/verify_ext.mjs, including a
66/// SYNTHETIC TRANSPARENCY LOG. A run that dies before its cleanup leaves them
67/// behind. They were caught once by a review, gitignored, and sealed again the
68/// next day -- because gitignore governs what git tracks, and this reads the
69/// filesystem. Refusing is what actually stops it.
70const NEVER_SHIP = [/^_vtest_/, /\.tmp$/, /(^|\/)\.DS_Store$/];
71
72const files = await hashTree(ROOT);
73
74const strays = Object.keys(files).filter(f => NEVER_SHIP.some(re => re.test(f)));
75if (strays.length) {
76 console.error('REFUSING TO SEAL — these must not be served:');
77 for (const f of strays) console.error(` ${ROOT}/${f}`);
78 console.error('\nDelete them and seal again. They are test fixtures, and one of them is a\n'
79 + 'synthetic transparency log; sealing them would ship both to production.');
80 process.exit(1);
81}
82
83const bundle = bundleHash(files);
84const build = await buildId(bundle);
85
86const manifest = { algo: 'sha-256', build, bundle, files };
87await writeFile(join(ROOT, 'manifest.json'), JSON.stringify(manifest, null, 0) + '\n');
88console.log(`manifest.json → ${Object.keys(files).length} files, bundle ${bundle.slice(0, 16)}… (build ${build})`);
89
90if (noLog) { console.log('--no-log: chain untouched.'); process.exit(0); }
91
92// Append to the transparency chain, unless this exact bundle is already its tip
93// — redeploying an identical bundle is not a new release and must not pad the
94// log with a duplicate a verifier would then expect to see served.
95let text = '';
96try { text = await readFile(LOG, 'utf8'); } catch (e) { text = ''; }
97const entries = parseLog(text);
98if (entries.length && entries[entries.length - 1].bundle === bundle) {
99 console.log(`transparency: bundle already at tip (seq ${entries.length - 1}); nothing appended.`);
100 process.exit(0);
101}
102const ts = new Date().toISOString();
103// The "what changed" line comes from build.json, where dev/stamp-build.mjs put
104// it. build.json is overwritten by the next deploy, so the log is where that
105// line has to end up if it is to survive -- and the log being the changelog is
106// what keeps there from being a second file to maintain and forget.
107const entry = nextEntry(entries, { ts, build, bundle, note: await buildNote() });
108await writeFile(LOG, text + (text && !text.endsWith('\n') ? '\n' : '') + JSON.stringify(entry) + '\n');
109console.log(`transparency: appended seq ${entry.seq} (entry ${entry.entry.slice(0, 16)}…)`);