oxedyne/daimond/dev/verify_release.mjs
11.8 KiB, 1 run
created by r2519314175:641, 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_release.mjs — which Daimond you are running, and what came before it. |
| 2 | // |
| 3 | // The guarantees worth holding, rather than the pixels: |
| 4 | // |
| 5 | // * a note can be added to a sealed entry WITHOUT moving its hash, which is |
| 6 | // the whole reason the changelog can live in the transparency log at all, |
| 7 | // * the status row names the release, not a hex build id, |
| 8 | // * every sealed build appears, newest first, exactly one marked "you are here", |
| 9 | // * the planned entry is never mistakable for history: no build, no seal |
| 10 | // number, and said in words, |
| 11 | // * there is no way back to an old version, deliberately. |
| 12 | // |
| 13 | // Run with dev/serve.mjs up. No gateway needed. The log is fed in over a data: |
| 14 | // URL so this does not depend on the network or on what is published today. |
| 15 | import { open, signInAs, errors, APP } from './harness.mjs'; |
| 16 | import { readFile } from 'node:fs/promises'; |
| 17 | import { parseLog, verifyChain, entryHash, nextEntry } from '../verify/lib.mjs'; |
| 18 | |
| 19 | const ok = [], bad = []; |
| 20 | const check = (name, pass, detail) => { |
| 21 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 22 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 23 | }; |
| 24 | |
| 25 | // ── The chain tolerates notes, which is what makes this possible ──────── |
| 26 | { |
| 27 | const entries = parseLog(await readFile('verify/transparency.jsonl', 'utf8')); |
| 28 | const chain = verifyChain(entries); |
| 29 | check('the real log still verifies with notes on every entry', chain.ok, |
| 30 | chain.error || `${entries.length} entries`); |
| 31 | check('every entry carries a note', entries.every(e => e.note && e.note.length), |
| 32 | `${entries.filter(e => e.note).length}/${entries.length}`); |
| 33 | |
| 34 | // The load-bearing property: the note is outside the hashed preimage. |
| 35 | const e = entries[entries.length - 1]; |
| 36 | const recomputed = entryHash({ seq: e.seq, ts: e.ts, build: e.build, bundle: e.bundle, prev: e.prev }); |
| 37 | check('the hash is computed without the note', recomputed === e.entry, e.entry.slice(0, 16) + '…'); |
| 38 | |
| 39 | const withNote = nextEntry([], { ts: 't', build: 'b', bundle: 'x', note: 'hello' }); |
| 40 | const without = nextEntry([], { ts: 't', build: 'b', bundle: 'x' }); |
| 41 | check('so adding one to a sealed entry cannot invalidate it', |
| 42 | withNote.entry === without.entry, withNote.entry.slice(0, 16) + '…'); |
| 43 | check('and an absent note leaves no empty field behind', !('note' in without)); |
| 44 | } |
| 45 | |
| 46 | // ── A log of our own, so this test does not depend on the network ─────── |
| 47 | // The build this tab is running has to appear in the log, because the surface |
| 48 | // reports what you are RUNNING rather than what has most recently been |
| 49 | // published -- reporting the latter told a stale tab it was current. |
| 50 | const running = await (async () => { |
| 51 | const r = await fetch(`${APP}/build.json`).catch(() => null); |
| 52 | const j = r && r.ok ? await r.json() : null; |
| 53 | return (j && j.build) || 'cccccccccccc'; |
| 54 | })(); |
| 55 | |
| 56 | const mk = (seq, ts, build, note) => ({ |
| 57 | seq, ts, build, note, bundle: 'x'.repeat(64), prev: '0'.repeat(64), entry: 'e'.repeat(64), |
| 58 | }); |
| 59 | const LOG = [ |
| 60 | mk(0, '2026-01-02T00:00:00.000Z', 'aaaaaaaaaaaa', 'The first one'), |
| 61 | mk(1, '2026-02-03T00:00:00.000Z', 'bbbbbbbbbbbb', 'The second one'), |
| 62 | mk(2, '2026-03-04T00:00:00.000Z', running, 'The newest one'), |
| 63 | ]; |
| 64 | const asUrl = (rows) => 'data:text/plain,' + encodeURIComponent(rows.map(e => JSON.stringify(e)).join('\n')); |
| 65 | const LOG_URL = asUrl(LOG); |
| 66 | |
| 67 | const s = await open({ name: 'release', connect: false }); |
| 68 | const p = s.page; |
| 69 | await p.waitForTimeout(2500); |
| 70 | |
| 71 | await p.evaluate((url) => { |
| 72 | const m = document.createElement('meta'); |
| 73 | m.name = 'daimond-log'; |
| 74 | m.content = url; |
| 75 | document.head.appendChild(m); |
| 76 | window.DaimondRelease.reset(); |
| 77 | }, LOG_URL); |
| 78 | |
| 79 | // ── Before a release is declared, nothing is announced ────────────────── |
| 80 | // A deployment is not a release. Daimond seals several builds a day, and none |
| 81 | // of them is an announcement; until one is declared the row has to say so |
| 82 | // rather than dress a build up as a version. |
| 83 | { |
| 84 | await p.evaluate((url) => { |
| 85 | const m = document.createElement('meta'); |
| 86 | m.name = 'daimond-releases'; |
| 87 | m.content = url; |
| 88 | document.head.appendChild(m); |
| 89 | window.DaimondRelease.reset(); |
| 90 | }, 'data:application/json,' + encodeURIComponent(JSON.stringify( |
| 91 | { milestones: [], planned: { name: 'Albany', blurb: 'The first release.' } }))); |
| 92 | await p.evaluate(() => window.DaimondRelease.paintRow()); |
| 93 | await p.waitForTimeout(500); |
| 94 | const txt = await p.$eval('#astat-release', e => e.textContent); |
| 95 | check('with no release declared, the row says pre-release', /Pre-release/i.test(txt), txt.trim()); |
| 96 | check('and names the build rather than inventing a version name', |
| 97 | txt.includes(running), txt.trim()); |
| 98 | check('it does not present the planned release as the current one', |
| 99 | !/Albany/.test(txt), txt.trim()); |
| 100 | } |
| 101 | |
| 102 | // ── Once one IS declared, the row names it ────────────────────────────── |
| 103 | { |
| 104 | await p.evaluate((url) => { |
| 105 | document.querySelector('meta[name="daimond-releases"]').content = url; |
| 106 | window.DaimondRelease.reset(); |
| 107 | }, 'data:application/json,' + encodeURIComponent(JSON.stringify({ |
| 108 | milestones: [{ name: 'Albany', from: 0, blurb: 'The first release.' }], |
| 109 | planned: { name: 'Broome', blurb: 'What comes next.' }, |
| 110 | }))); |
| 111 | await p.evaluate(() => window.DaimondRelease.paintRow()); |
| 112 | await p.waitForTimeout(500); |
| 113 | const txt = await p.$eval('#astat-release', e => e.textContent); |
| 114 | check('the row names the release, not a hex build id', /Albany/.test(txt), txt.trim()); |
| 115 | check('and says how long you have been on it', |
| 116 | /(today|yesterday|days ago|months ago|years ago)/.test(txt), txt.trim()); |
| 117 | const title = await p.$eval('#astat-release', e => e.title); |
| 118 | check('the build id is still available, in the tooltip', title.includes(running), title); |
| 119 | } |
| 120 | |
| 121 | // ── The history ───────────────────────────────────────────────────────── |
| 122 | { |
| 123 | await p.click('#astat-release'); |
| 124 | await p.waitForTimeout(700); |
| 125 | const rows = await p.$$eval('#rel-list .rel-row', els => els.map(e => ({ |
| 126 | planned: e.classList.contains('rel-planned'), |
| 127 | current: e.classList.contains('rel-current'), |
| 128 | text: e.textContent, |
| 129 | }))); |
| 130 | // The list is RELEASES, not deployments: one declared release plus what is |
| 131 | // planned, however many builds have been sealed underneath. |
| 132 | check('the list shows releases rather than every build', rows.length === 2, |
| 133 | `${rows.length} rows for ${LOG.length} builds`); |
| 134 | check('the planned one is first', rows[0] && rows[0].planned, rows[0] && rows[0].text.slice(0, 30)); |
| 135 | check('exactly one row says you are here', |
| 136 | rows.filter(r => /you are here/i.test(r.text)).length === 1); |
| 137 | check('and it is the declared release', rows[1] && rows[1].current && /Albany/.test(rows[1].text)); |
| 138 | |
| 139 | // The builds stay reachable, because they are the verifiable part. |
| 140 | const builds = await p.$$eval('#rel-list .rel-builds .rel-build', els => els.map(e => e.textContent)); |
| 141 | check('every sealed build is still there, behind a disclosure', |
| 142 | builds.length === LOG.length, `${builds.length} builds`); |
| 143 | check('each build carries its own note', |
| 144 | builds.some(t => /The second one/.test(t)), builds[1] && builds[1].slice(0, 44)); |
| 145 | const summary = await p.$eval('#rel-list .rel-builds summary', e => e.textContent); |
| 146 | check('the disclosure says how many there are', /3 sealed builds/.test(summary), summary); |
| 147 | } |
| 148 | |
| 149 | // ── A tab running an OLDER build is told so, not flattered ───────────── |
| 150 | // This is the defect the surface was built with: it reported the newest |
| 151 | // PUBLISHED release as "you are here", so a tab open since before a deploy was |
| 152 | // told it was current on the same screen where the update chip said otherwise. |
| 153 | { |
| 154 | const ahead = LOG.concat([mk(3, '2026-04-05T00:00:00.000Z', 'ffffffffffff', 'Published after this tab loaded')]); |
| 155 | await p.evaluate((url) => { |
| 156 | document.querySelector('meta[name="daimond-log"]').content = url; |
| 157 | window.DaimondRelease.reset(); |
| 158 | }, asUrl(ahead)); |
| 159 | await p.evaluate(() => window.DaimondRelease.paintRow()); |
| 160 | await p.waitForTimeout(400); |
| 161 | const txt = await p.$eval('#astat-release', e => e.textContent); |
| 162 | const tip = await p.$eval('#astat-release', e => e.title); |
| 163 | check('a tab behind the newest release says so', /update ready/i.test(txt), txt.trim()); |
| 164 | check('and the tooltip says a newer build exists', /newer build/i.test(tip), tip); |
| 165 | |
| 166 | await p.evaluate(() => window.DaimondRelease.render(document.getElementById('rel-list'))); |
| 167 | await p.waitForTimeout(400); |
| 168 | const marked = await p.$$eval('#rel-list .rel-row', els => |
| 169 | els.filter(e => /you are here/i.test(e.textContent)).map(e => e.textContent.slice(0, 60))); |
| 170 | check('"you are here" marks where the running build sits, not the newest published', |
| 171 | marked.length >= 1 && !marked.some(t => t.includes('ffffffffffff')), marked.join(' | ')); |
| 172 | |
| 173 | // Put the original log back for the checks below. |
| 174 | await p.evaluate((url) => { |
| 175 | document.querySelector('meta[name="daimond-log"]').content = url; |
| 176 | window.DaimondRelease.reset(); |
| 177 | }, LOG_URL); |
| 178 | await p.evaluate(() => window.DaimondRelease.render(document.getElementById('rel-list'))); |
| 179 | await p.waitForTimeout(400); |
| 180 | } |
| 181 | |
| 182 | // ── A promise must not read as a record ───────────────────────────────── |
| 183 | // This is the one honesty requirement of the whole surface: everything below |
| 184 | // the first row is sealed and checkable, and the first row is neither. |
| 185 | { |
| 186 | const planned = await p.$eval('#rel-list .rel-planned', e => ({ |
| 187 | text: e.textContent, |
| 188 | dashed: getComputedStyle(e).borderStyle, |
| 189 | hasCode: !!e.querySelector('code'), |
| 190 | })); |
| 191 | check('the planned entry names no build id', !planned.hasCode); |
| 192 | check('it carries no seal number', !/sealed #/.test(planned.text)); |
| 193 | check('it is drawn differently from a sealed one', planned.dashed === 'dashed', planned.dashed); |
| 194 | check('and it says so in words, not only in styling', |
| 195 | /not released yet/i.test(planned.text), planned.text.slice(-46).trim()); |
| 196 | check('it is labelled planned', /planned/i.test(planned.text)); |
| 197 | } |
| 198 | |
| 199 | // ── No way back, on purpose ───────────────────────────────────────────── |
| 200 | // Reverting fights the gateway's own "too old" refusal, lets a user pin a build |
| 201 | // with a since-fixed security fault, and risks an old build meeting newer local |
| 202 | // data. If a control for it ever appears, that was a decision, not a drive-by. |
| 203 | { |
| 204 | const controls = await p.$$eval('#rel-list button, #rel-list a', els => |
| 205 | els.map(e => (e.textContent || '').trim()).filter(Boolean)); |
| 206 | check('the history offers no way to go back to an old build', |
| 207 | !controls.some(t => /revert|roll ?back|downgrade|switch to|install/i.test(t)), |
| 208 | controls.length ? controls.join(', ') : 'no controls at all'); |
| 209 | } |
| 210 | |
| 211 | // ── It survives a log it cannot read ──────────────────────────────────── |
| 212 | // Not knowing the version is a smaller problem than a blank panel. |
| 213 | { |
| 214 | await p.evaluate(() => { |
| 215 | document.querySelector('meta[name="daimond-log"]').content = 'data:text/plain,not%20json%20at%20all'; |
| 216 | window.DaimondRelease.reset(); |
| 217 | }); |
| 218 | await p.evaluate(() => window.DaimondRelease.render(document.getElementById('rel-list'))); |
| 219 | await p.waitForTimeout(500); |
| 220 | const txt = await p.$eval('#rel-list', e => e.textContent); |
| 221 | check('an unreadable log says so rather than showing nothing', |
| 222 | /no published history/i.test(txt), txt.trim().slice(0, 60)); |
| 223 | await p.evaluate(() => window.DaimondRelease.paintRow()); |
| 224 | await p.waitForTimeout(300); |
| 225 | check('and the status strip still stands', await p.isVisible('#astat-release')); |
| 226 | } |
| 227 | |
| 228 | { |
| 229 | const errs = errors(s).filter(e => !/502|Bad Gateway|Failed to load resource/.test(e)); |
| 230 | check('no unexpected console errors', errs.length === 0, errs.join(' | ') || 'clean'); |
| 231 | } |
| 232 | |
| 233 | await s.close(); |
| 234 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 235 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |