Oregami
Repositories/oxedyne/daimond

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.
31import fs from 'node:fs';
32import { open, errors, signInAs, scratch } from './harness.mjs';
33
34const ok = [], bad = [];
35const check = (name, pass, detail) => {
36 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
37 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
38};
39
40const s = await open({ name: 'backupns' });
41const 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.
45const 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.
51const 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.
57const 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
64const ledger = () => p.evaluate(() => {
65 try { return JSON.parse(localStorage.getItem('daimond-ledger') || '[]'); } catch (e) { return []; }
66});
67const 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.
72async 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.
85async 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
100const primaryId = await p.evaluate(() => window.DaimondAccounts.current());
101await write('primary-secret.md', 'PRIMARY-ONLY-PAYLOAD');
102check('the primary account holds its file', (await read('primary-secret.md')) === 'PRIMARY-ONLY-PAYLOAD');
103
104// ── A second account at the same browser ─────────────────────────────────
105
106await p.evaluate(() => { window.DaimondAccounts.add('Bob'); });
107await p.reload({ waitUntil: 'domcontentloaded' });
108await p.waitForTimeout(1200);
109await signInAs(s, 'Bob');
110await p.waitForTimeout(800);
111
112const nsB = await p.evaluate(() => window.DaimondAccounts.opfsNs());
113check('the second account has an OPFS namespace of its own', /^d~[0-9a-f]{16}$/.test(nsB), nsB);
114await write('second-note.md', 'SECOND-ACCOUNT-PAYLOAD');
115check('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.
118const L1 = { t: 1750000000000, m: 'mock/fast', p: 100, c: 20, ca: 0, u: 0.010, pv: 'custom', r: 1 };
119const L2 = { t: 1750000001000, m: 'mock/fast', p: 200, c: 30, ca: 0, u: 0.020, pv: 'custom', r: 1 };
120await setLedger([L1, L2]);
121
122// ── 1. What the export carries ───────────────────────────────────────────
123
124const file = scratch('backupns-export.json');
125const backup = await exportBackup(file);
126const paths = (backup.workspace || []).map(f => f.path);
127const blob = JSON.stringify(backup);
128
129check('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)');
132check('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)');
135check('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(' '));
137check('and the file says its paths are account-rooted, so a restore knows',
138 backup.workspaceScope === 'account', String(backup.workspaceScope));
139check('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.
146const 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 };
147const L3 = { t: 1750000002000, m: 'mock/fast', p: 300, c: 40, ca: 0, u: 0.030, pv: 'custom', r: 1 };
148await 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.
152const newFile = scratch('backupns-new.json');
153fs.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
161await importBackup(newFile, 'Bob');
162
163check('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
166const merged = await ledger();
167const at = (t) => merged.filter(e => e.t === t);
168check('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`);
170check('and the spend since the backup was taken is still there',
171 at(L3.t).length === 1, `${at(L3.t).length} entries at L3`);
172check('a turn both ledgers hold is held once, not twice',
173 at(L2.t).length === 1, `${at(L2.t).length} entries at L2`);
174check('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));
176check('nothing else was invented', merged.length === 3, `${merged.length} entries`);
177
178// ── The other account is untouched by any of it ──────────────────────────
179
180check('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
188const legacy = scratch('backupns-legacy.json');
189fs.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
213await importBackup(legacy, 'Bob');
214
215check('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')));
217check('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')));
219check('while another account\'s subtree is not written into this workspace',
220 (await read('d~deadbeefdeadbeef/legacy-foreign.md')) === null);
221const names = await rootNames();
222check('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(' '));
224check('and the prefixed file is not restored under its prefix either',
225 (await read(nsB + '/legacy-mine.md')) === null);
226
227const 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});
232check('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
241const L4 = { t: 1750000003000, m: 'mock/fast', p: 400, c: 50, ca: 0, u: 0.040, pv: 'custom', r: 1 };
242const parcelled = await p.evaluate(() => window.DaimondCore.collectSync().then(st => st.ledger || []));
243check('the sync parcel carries this device\'s ledger',
244 parcelled.length === 3 && parcelled[0].t < parcelled[2].t, `${parcelled.length} entries`);
245
246const 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);
253check('a pulled parcel adds the other device\'s turns', pulled.first === 4, String(pulled.first));
254check('and pulling it again adds nothing', pulled.again === 4, String(pulled.again));
255
256const 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);
264check('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.
270const 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));
273check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | '));
274
275await s.close();
276console.log(`\n${ok.length} passed, ${bad.length} failed`);
277if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }