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 | |
| 27 | import { readFile, writeFile } from 'node:fs/promises'; |
| 28 | import { join, normalize } from 'node:path'; |
| 29 | import { fileURLToPath } from 'node:url'; |
| 30 | import { hashTree, bundleHash, parseLog, nextEntry } from './lib.mjs'; |
| 31 | |
| 32 | const HERE = normalize(join(fileURLToPath(import.meta.url), '..')); |
| 33 | const args = process.argv.slice(2); |
| 34 | const rootArg = (() => { const i = args.indexOf('--root'); return i >= 0 ? args[i + 1] : null; })(); |
| 35 | const ROOT = rootArg ? normalize(rootArg) : normalize(join(HERE, '..', 'www')); |
| 36 | const LOG = join(HERE, 'transparency.jsonl'); |
| 37 | const noLog = args.includes('--no-log'); |
| 38 | |
| 39 | /// The one-line "what changed" from build.json, if it carries one. |
| 40 | async 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. |
| 50 | async 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. |
| 70 | const NEVER_SHIP = [/^_vtest_/, /\.tmp$/, /(^|\/)\.DS_Store$/]; |
| 71 | |
| 72 | const files = await hashTree(ROOT); |
| 73 | |
| 74 | const strays = Object.keys(files).filter(f => NEVER_SHIP.some(re => re.test(f))); |
| 75 | if (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 | |
| 83 | const bundle = bundleHash(files); |
| 84 | const build = await buildId(bundle); |
| 85 | |
| 86 | const manifest = { algo: 'sha-256', build, bundle, files }; |
| 87 | await writeFile(join(ROOT, 'manifest.json'), JSON.stringify(manifest, null, 0) + '\n'); |
| 88 | console.log(`manifest.json → ${Object.keys(files).length} files, bundle ${bundle.slice(0, 16)}… (build ${build})`); |
| 89 | |
| 90 | if (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. |
| 95 | let text = ''; |
| 96 | try { text = await readFile(LOG, 'utf8'); } catch (e) { text = ''; } |
| 97 | const entries = parseLog(text); |
| 98 | if (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 | } |
| 102 | const 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. |
| 107 | const entry = nextEntry(entries, { ts, build, bundle, note: await buildNote() }); |
| 108 | await writeFile(LOG, text + (text && !text.endsWith('\n') ? '\n' : '') + JSON.stringify(entry) + '\n'); |
| 109 | console.log(`transparency: appended seq ${entry.seq} (entry ${entry.entry.slice(0, 16)}…)`); |