oxedyne/daimond/dev/verify_backupns.mjs
14.7 KiB, 1 run
created by r2519314175:253, 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_backupns.mjs — a backup belongs to ONE account, and a restore adds to |
| 2 | // what is already here. |
| 3 | // |
| 4 | // Three properties, each of which was false: |
| 5 | // |
| 6 | // 1. AN EXPORT CARRIES ONE ACCOUNT'S FILES. `collectOpfsFiles` walked the OPFS |
| 7 | // origin root, which is the PRIMARY account's workspace and the parent of |
| 8 | // every other account's. So a backup taken from any account carried every |
| 9 | // account's files at that browser — one person's private workspace handed |
| 10 | // out in another person's backup file — and the taker's own files came out |
| 11 | // under their internal `d~<id>/` prefix rather than at the paths they use. |
| 12 | // 2. A RESTORE LANDS WHERE THE ACCOUNT CAN SEE IT. `writeOpfsBytes` wrote to |
| 13 | // the same raw root, so restoring into a secondary account put every file |
| 14 | // in the PRIMARY's workspace: invisible to the account that asked for it, |
| 15 | // reported as a success, and mixed into a workspace that was not asked. |
| 16 | // 3. A RESTORE MERGES THE LEDGER. The docstring said "merged"; the code did |
| 17 | // `setItem`, so restoring a backup taken this morning erased every turn |
| 18 | // recorded since. Spend history is a record of money that actually moved: |
| 19 | // the only merge that cannot lose it is the union. |
| 20 | // |
| 21 | // A backup written before the namespace fix holds files from several accounts |
| 22 | // with nothing to say so. What the restore does with them is asserted here too: |
| 23 | // a `d~<id>/` subtree belonging to the CURRENT account is brought home with the |
| 24 | // prefix stripped, one belonging to any other account is skipped (there is no |
| 25 | // destination for it that is not either a fake folder in this workspace or a |
| 26 | // write into a stranger's storage at this browser), and everything un-prefixed |
| 27 | // is restored as it always was. |
| 28 | // |
| 29 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 30 | // (DAIMOND_MOCK_PORT, default 9099). No gateway. |
| 31 | import fs from 'node:fs'; |
| 32 | import { open, errors, signInAs, scratch } from './harness.mjs'; |
| 33 | |
| 34 | const ok = [], bad = []; |
| 35 | const check = (name, pass, detail) => { |
| 36 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 37 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 38 | }; |
| 39 | |
| 40 | const s = await open({ name: 'backupns' }); |
| 41 | const p = s.page; |
| 42 | |
| 43 | /// Write a file through the wasm's own OPFS edge — the door the file tools use, |
| 44 | /// and the one that resolves the account namespace. |
| 45 | const write = (path, body) => p.evaluate(async ([path, body]) => { |
| 46 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 47 | await mod.write_file(path, body); |
| 48 | }, [path, body]); |
| 49 | |
| 50 | /// Read one back through the same door: null when this account cannot see it. |
| 51 | const read = (path) => p.evaluate(async (path) => { |
| 52 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 53 | return await mod.read_file(path).catch(() => null); |
| 54 | }, path); |
| 55 | |
| 56 | /// Every top-level name in the OPFS origin root, whichever account is current. |
| 57 | const rootNames = () => p.evaluate(async () => { |
| 58 | const root = await navigator.storage.getDirectory(); |
| 59 | const out = []; |
| 60 | for await (const ent of root.entries()) out.push(ent[0] + (ent[1].kind === 'directory' ? '/' : '')); |
| 61 | return out.sort(); |
| 62 | }); |
| 63 | |
| 64 | const ledger = () => p.evaluate(() => { |
| 65 | try { return JSON.parse(localStorage.getItem('daimond-ledger') || '[]'); } catch (e) { return []; } |
| 66 | }); |
| 67 | const setLedger = (rows) => p.evaluate((rows) => { |
| 68 | localStorage.setItem('daimond-ledger', JSON.stringify(rows)); |
| 69 | }, rows); |
| 70 | |
| 71 | /// Take a backup the way a user does: the account row, then Export a backup. |
| 72 | async function exportBackup(file) { |
| 73 | await p.click('#user-row'); |
| 74 | await p.waitForTimeout(400); |
| 75 | const dl = p.waitForEvent('download', { timeout: 20000 }); |
| 76 | await p.click('button.admin-item:has-text("Export a backup")'); |
| 77 | await (await dl).saveAs(file); |
| 78 | await p.keyboard.press('Escape'); |
| 79 | await p.waitForTimeout(300); |
| 80 | return JSON.parse(fs.readFileSync(file, 'utf8')); |
| 81 | } |
| 82 | |
| 83 | /// Restore one the way a user does: the account row, Import a backup, the file |
| 84 | /// chooser, then acknowledge the notice and let the app reload. |
| 85 | async function importBackup(file, name) { |
| 86 | await p.click('#user-row'); |
| 87 | await p.waitForTimeout(400); |
| 88 | const chooser = p.waitForEvent('filechooser', { timeout: 20000 }); |
| 89 | await p.click('button.admin-item:has-text("Import a backup")'); |
| 90 | await (await chooser).setFiles(file); |
| 91 | await p.waitForSelector('.dlg-ok', { timeout: 20000 }); |
| 92 | await p.click('.dlg-ok'); |
| 93 | await p.waitForSelector('#id-primary', { timeout: 20000 }); |
| 94 | await signInAs(s, name); |
| 95 | await p.waitForTimeout(600); |
| 96 | } |
| 97 | |
| 98 | // ── The primary account, holding something private ─────────────────────── |
| 99 | |
| 100 | const primaryId = await p.evaluate(() => window.DaimondAccounts.current()); |
| 101 | await write('primary-secret.md', 'PRIMARY-ONLY-PAYLOAD'); |
| 102 | check('the primary account holds its file', (await read('primary-secret.md')) === 'PRIMARY-ONLY-PAYLOAD'); |
| 103 | |
| 104 | // ── A second account at the same browser ───────────────────────────────── |
| 105 | |
| 106 | await p.evaluate(() => { window.DaimondAccounts.add('Bob'); }); |
| 107 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 108 | await p.waitForTimeout(1200); |
| 109 | await signInAs(s, 'Bob'); |
| 110 | await p.waitForTimeout(800); |
| 111 | |
| 112 | const nsB = await p.evaluate(() => window.DaimondAccounts.opfsNs()); |
| 113 | check('the second account has an OPFS namespace of its own', /^d~[0-9a-f]{16}$/.test(nsB), nsB); |
| 114 | await write('second-note.md', 'SECOND-ACCOUNT-PAYLOAD'); |
| 115 | check('and cannot see the primary\'s file', (await read('primary-secret.md')) === null); |
| 116 | |
| 117 | // Two turns of spend, both provider-reported so nothing is re-priced under us. |
| 118 | const L1 = { t: 1750000000000, m: 'mock/fast', p: 100, c: 20, ca: 0, u: 0.010, pv: 'custom', r: 1 }; |
| 119 | const L2 = { t: 1750000001000, m: 'mock/fast', p: 200, c: 30, ca: 0, u: 0.020, pv: 'custom', r: 1 }; |
| 120 | await setLedger([L1, L2]); |
| 121 | |
| 122 | // ── 1. What the export carries ─────────────────────────────────────────── |
| 123 | |
| 124 | const file = scratch('backupns-export.json'); |
| 125 | const backup = await exportBackup(file); |
| 126 | const paths = (backup.workspace || []).map(f => f.path); |
| 127 | const blob = JSON.stringify(backup); |
| 128 | |
| 129 | check('a backup taken in one account carries NO other account\'s files', |
| 130 | !blob.includes('PRIMARY-ONLY-PAYLOAD') && !paths.includes('primary-secret.md'), |
| 131 | paths.filter(x => !/^bulk\//.test(x)).slice(0, 6).join(' ') || '(none)'); |
| 132 | check('and carries its own, at the paths that account actually uses', |
| 133 | paths.includes('second-note.md'), |
| 134 | paths.find(x => /second-note/.test(x)) || '(absent)'); |
| 135 | check('so no file in it is under another account\'s internal prefix', |
| 136 | !paths.some(x => x.indexOf('d~') === 0), paths.filter(x => x.indexOf('d~') === 0).slice(0, 3).join(' ')); |
| 137 | check('and the file says its paths are account-rooted, so a restore knows', |
| 138 | backup.workspaceScope === 'account', String(backup.workspaceScope)); |
| 139 | check('the export carries the ledger it was taken with', |
| 140 | (backup.ledger || []).length === 2, String((backup.ledger || []).length)); |
| 141 | |
| 142 | // ── 3. What a restore does to the ledger ───────────────────────────────── |
| 143 | |
| 144 | // Spend since the backup was taken: L2 re-priced by the correction pass (same |
| 145 | // turn, a different figure), and L3, which the backup has never heard of. |
| 146 | const L2r = { t: L2.t, m: L2.m, p: L2.p, c: L2.c, ca: L2.ca, u: 0.005, pv: L2.pv, r: 1, rp: 1, u0: 0.020 }; |
| 147 | const L3 = { t: 1750000002000, m: 'mock/fast', p: 300, c: 40, ca: 0, u: 0.030, pv: 'custom', r: 1 }; |
| 148 | await setLedger([L2r, L3]); |
| 149 | |
| 150 | // A backup file of the same shape the app writes, holding one workspace file |
| 151 | // under a plain path and the ledger as it stood at L1+L2. |
| 152 | const newFile = scratch('backupns-new.json'); |
| 153 | fs.writeFileSync(newFile, JSON.stringify({ |
| 154 | format: 'daimond-backup', version: 1, exported: new Date().toISOString(), |
| 155 | name: 'Bob', identity: null, chats: [], diamonds: [], |
| 156 | workspaceScope: 'account', |
| 157 | workspace: [{ path: 'restored-here.md', b64: Buffer.from('RESTORED-PAYLOAD').toString('base64') }], |
| 158 | ledger: [L1, L2], |
| 159 | }, null, 2)); |
| 160 | |
| 161 | await importBackup(newFile, 'Bob'); |
| 162 | |
| 163 | check('a restore puts the files where the account that asked for them can see them', |
| 164 | (await read('restored-here.md')) === 'RESTORED-PAYLOAD', String(await read('restored-here.md'))); |
| 165 | |
| 166 | const merged = await ledger(); |
| 167 | const at = (t) => merged.filter(e => e.t === t); |
| 168 | check('a restore MERGES the ledger — the backup\'s older turn comes back', |
| 169 | at(L1.t).length === 1, `${at(L1.t).length} entries at L1`); |
| 170 | check('and the spend since the backup was taken is still there', |
| 171 | at(L3.t).length === 1, `${at(L3.t).length} entries at L3`); |
| 172 | check('a turn both ledgers hold is held once, not twice', |
| 173 | at(L2.t).length === 1, `${at(L2.t).length} entries at L2`); |
| 174 | check('and the local record of it wins, so a re-priced turn is not un-re-priced', |
| 175 | at(L2.t).length === 1 && at(L2.t)[0].u === 0.005, JSON.stringify(at(L2.t)[0] || null)); |
| 176 | check('nothing else was invented', merged.length === 3, `${merged.length} entries`); |
| 177 | |
| 178 | // ── The other account is untouched by any of it ────────────────────────── |
| 179 | |
| 180 | check('the primary\'s workspace did not gain the restored file', |
| 181 | (await p.evaluate(async () => { |
| 182 | const root = await navigator.storage.getDirectory(); |
| 183 | return await root.getFileHandle('restored-here.md').then(() => 'FOUND').catch(() => 'not-found'); |
| 184 | })) === 'not-found'); |
| 185 | |
| 186 | // ── An OLD backup, from before any of this was true ────────────────────── |
| 187 | |
| 188 | const legacy = scratch('backupns-legacy.json'); |
| 189 | fs.writeFileSync(legacy, JSON.stringify({ |
| 190 | format: 'daimond-backup', version: 1, exported: new Date().toISOString(), |
| 191 | name: 'Bob', identity: null, chats: [], ledger: [], |
| 192 | // A Diamond whose raw store is in the part of the backup that belongs to |
| 193 | // somebody else. Its files cannot be restored, so the summary is the only way |
| 194 | // it comes back at all — and the restore must not mistake a path it declined |
| 195 | // to write for a Diamond it has already brought back. |
| 196 | diamonds: [{ id: 'did-foreign-1', name: 'Fallback Diamond', crystal: 'FALLBACK-CRYSTAL', tags: [] }], |
| 197 | // No `workspaceScope`: the raw origin root, exactly as the old export walked |
| 198 | // it — this account's files under its own prefix, a stranger's under theirs, |
| 199 | // and the primary's at the top. |
| 200 | workspace: [ |
| 201 | { path: 'legacy-plain.md', b64: Buffer.from('LEGACY-PLAIN').toString('base64') }, |
| 202 | { path: nsB + '/legacy-mine.md', b64: Buffer.from('LEGACY-MINE').toString('base64') }, |
| 203 | { path: 'd~deadbeefdeadbeef/legacy-foreign.md', b64: Buffer.from('LEGACY-FOREIGN').toString('base64') }, |
| 204 | // `crystal.md`, and it stays that way: this is a `version: 1` backup, and a |
| 205 | // backup of that vintage is exactly where the markdown crystal is still |
| 206 | // found. Nothing here writes it -- it is the foreign subtree the restore |
| 207 | // must decline -- so the format it is in is the fixture's whole point. |
| 208 | { path: 'd~deadbeefdeadbeef/diamonds/did-foreign-1/crystal.md', |
| 209 | b64: Buffer.from('# Fallback Diamond').toString('base64') }, |
| 210 | ], |
| 211 | }, null, 2)); |
| 212 | |
| 213 | await importBackup(legacy, 'Bob'); |
| 214 | |
| 215 | check('an old backup\'s un-prefixed files restore into the account that asked', |
| 216 | (await read('legacy-plain.md')) === 'LEGACY-PLAIN', String(await read('legacy-plain.md'))); |
| 217 | check('and a subtree under THIS account\'s own prefix comes home, prefix stripped', |
| 218 | (await read('legacy-mine.md')) === 'LEGACY-MINE', String(await read('legacy-mine.md'))); |
| 219 | check('while another account\'s subtree is not written into this workspace', |
| 220 | (await read('d~deadbeefdeadbeef/legacy-foreign.md')) === null); |
| 221 | const names = await rootNames(); |
| 222 | check('nor into a namespace at this browser that belongs to nobody here', |
| 223 | !names.includes('d~deadbeefdeadbeef/'), names.filter(n => n.indexOf('d~') === 0).join(' ')); |
| 224 | check('and the prefixed file is not restored under its prefix either', |
| 225 | (await read(nsB + '/legacy-mine.md')) === null); |
| 226 | |
| 227 | const rows = await p.evaluate(async () => { |
| 228 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 229 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 230 | return JSON.parse(await app.list_diamonds()).map(r => r.name); |
| 231 | }); |
| 232 | check('a Diamond whose store was left out still comes back from the summary', |
| 233 | rows.filter(n => n === 'Fallback Diamond').length === 1, rows.join(', ') || '(no diamonds)'); |
| 234 | |
| 235 | // ── The same rule, between two devices ─────────────────────────────────── |
| 236 | // The provider keys and the credit base a user types in already sync, and the |
| 237 | // ledger did not — so "left on this key" was the same base minus a different |
| 238 | // device's spend, and the two devices disagreed about the user's money. The |
| 239 | // parcel carries the ledger now, merged by the same union. |
| 240 | |
| 241 | const L4 = { t: 1750000003000, m: 'mock/fast', p: 400, c: 50, ca: 0, u: 0.040, pv: 'custom', r: 1 }; |
| 242 | const parcelled = await p.evaluate(() => window.DaimondCore.collectSync().then(st => st.ledger || [])); |
| 243 | check('the sync parcel carries this device\'s ledger', |
| 244 | parcelled.length === 3 && parcelled[0].t < parcelled[2].t, `${parcelled.length} entries`); |
| 245 | |
| 246 | const pulled = await p.evaluate(async (L4) => { |
| 247 | await window.DaimondCore.applySync({ v: 2, chats: [], ledger: [L4] }); |
| 248 | const first = JSON.parse(localStorage.getItem('daimond-ledger') || '[]'); |
| 249 | // The same parcel again: a pull that repeats must not charge the user twice. |
| 250 | await window.DaimondCore.applySync({ v: 2, chats: [], ledger: [L4] }); |
| 251 | return { first: first.length, again: JSON.parse(localStorage.getItem('daimond-ledger') || '[]').length }; |
| 252 | }, L4); |
| 253 | check('a pulled parcel adds the other device\'s turns', pulled.first === 4, String(pulled.first)); |
| 254 | check('and pulling it again adds nothing', pulled.again === 4, String(pulled.again)); |
| 255 | |
| 256 | const kept = await p.evaluate((L2t) => { |
| 257 | // The other device's copy of a turn THIS one has already re-priced. |
| 258 | const stale = { t: L2t, m: 'mock/fast', p: 200, c: 30, ca: 0, u: 0.020, pv: 'custom', r: 1 }; |
| 259 | return window.DaimondCore.applySync({ v: 2, chats: [], ledger: [stale] }).then(() => { |
| 260 | const rows = JSON.parse(localStorage.getItem('daimond-ledger') || '[]').filter(e => e.t === L2t); |
| 261 | return { n: rows.length, u: rows.length ? rows[0].u : null }; |
| 262 | }); |
| 263 | }, L2.t); |
| 264 | check('and a remote copy of a turn we already hold does not un-re-price it', |
| 265 | kept.n === 1 && kept.u === 0.005, JSON.stringify(kept)); |
| 266 | |
| 267 | // A gateway is not part of this: a fresh account has no session at one, so its |
| 268 | // calls answer 401 when a gateway happens to be up and 502 when it is not. |
| 269 | // Either is the environment, not the app. |
| 270 | const errs = errors(s).filter(e => |
| 271 | !/Failed to load resource.*\b(401|402|404|426|502|503)\b/.test(e) |
| 272 | && !/favicon|net::ERR|api\/sync/.test(e)); |
| 273 | check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 274 | |
| 275 | await s.close(); |
| 276 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 277 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |