Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_handle.mjs

29.0 KiB, 1 run

created by r2519314175:467, 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_handle.mjs — the account has a public name, every device of it agrees,
2// and the name never restamps itself on the way through.
3//
4// A handle is what a shared Diamond will be shared WITH and what a rating will
5// be attributed TO. Four things have to be true before any of that can be built
6// on it, and each one has failed somewhere in this app already:
7//
8// 1. IT IS THE ACCOUNT'S NAME, NOT THE DEVICE'S. `identity.js` already has a
9// `displayName()`, which labels this device's keypair, lives only in this
10// browser and is seen by nobody. Reusing it is the obvious move and the
11// wrong one, so the check is that the two are SEPARATE: adopting a handle
12// must leave the device's own label exactly where it was.
13//
14// 2. IT RIDES THE PARCEL, AND IT IS A FIXED POINT. `push()` skips the wire
15// only while two collects give the same bytes. A field restamped on the way
16// in makes every parcel differ from the last one sent, and two devices then
17// push at each other for ever -- which has happened here, over a pairing
18// name, on a phone freshly paired by QR. So the merge must write what
19// arrived VERBATIM, stamp included, and must move nothing when the record
20// it is handed is one this device already holds.
21//
22// 3. THE MERGE IS THE SAME ON BOTH DEVICES. Later stamp wins; on a tie the
23// lexicographically smaller name wins, because a tie broken by "keep mine"
24// is two devices that never converge.
25//
26// 4. A REFUSAL SAYS WHICH REFUSAL IT IS. A name somebody else holds, a name
27// that is not a name, and a name the operator keeps are three different
28// sentences. One sentence for all three is a user staring at a field that
29// will not take what they typed.
30//
31// And the half that makes it public at all: a handle another account holds must
32// be resolvable, or nothing can ever be attributed to it.
33//
34// THE GATEWAY IS STUBBED HERE, and it is stubbed as a real namespace owner --
35// one name to one account, a 409 for a name that is held. What the REAL gateway
36// does with the namespace is proved in Rust, against a real store, in
37// `gateway/src/schema.rs` and `gateway/src/handlers/account.rs`. This file is
38// about the browser: what it asks for, what it stores, what it sends on.
39//
40// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
41// damaged copy of a real source file to the real page, and the run is expected
42// to FAIL. A break whose anchor does not appear exactly once aborts, because a
43// check proved against code that was never broken is not proved at all.
44//
45// node dev/verify_handle.mjs --break restamp # 2 fails: the stamp is rewritten
46// node dev/verify_handle.mjs --break applystamps # 2 fails: applying its OWN parcel moves it
47// node dev/verify_handle.mjs --break nocarry # 2 fails: the parcel drops it
48// node dev/verify_handle.mjs --break alwaysadopt # 3 fails: an older record wins
49// node dev/verify_handle.mjs --break handledeaf # 8b fails: adopted, and not drawn
50// node dev/verify_handle.mjs --break onemessage # 4 fails: one sentence for three noes
51// node dev/verify_handle.mjs --break devicename # 1 fails: the device label is overwritten
52// node dev/verify_handle.mjs --break nolookup # the public half fails
53// node dev/verify_handle.mjs # and then, clean
54//
55// eval "$(bash dev/world.sh 3 --env)"
56// node dev/verify_handle.mjs
57//
58// Needs dev/serve.mjs only. No gateway on :9002 and no mock LLM: nothing here
59// runs a turn.
60import fs from 'node:fs';
61import path from 'node:path';
62import { fileURLToPath } from 'node:url';
63import { open, scratch, errors, PASS } from './harness.mjs';
64
65const HERE = path.dirname(fileURLToPath(import.meta.url));
66const WWW = path.join(HERE, '..', 'www');
67
68const BREAK = (() => {
69 const i = process.argv.indexOf('--break');
70 return i > 0 ? String(process.argv[i + 1] || '') : '';
71})();
72
73const ok = [], bad = [];
74const check = (name, pass, detail) => {
75 (pass ? ok : bad).push(name);
76 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
77};
78
79// ── The breaks ───────────────────────────────────────────────────────
80const BREAKS = {
81 // The merge stamps what it adopts with this device's clock. This is the
82 // pairing-name defect exactly: the record is right, the stamp is fresh, and
83 // the parcel differs from the last one sent every single time.
84 //
85 // BOTH writes, because there are two -- the merge and the gateway's own
86 // answer -- and a break that damaged one would leave the other doing the job
87 // on the path the check happens to use. The anchors carry the line above
88 // them, which is what tells the two functions apart.
89 restamp: [{
90 file: 'js/identity.js',
91 find: '\t\tif (!handleBeats(incoming, mine)) return false;\n'
92 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
93 with: '\t\tif (!handleBeats(incoming, mine)) return false;\n'
94 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }',
95 }, {
96 file: 'js/identity.js',
97 find: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n'
98 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
99 with: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n'
100 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }',
101 }],
102 // The section restamps itself ON APPLY: it takes every record it is handed,
103 // including one it already holds, and writes the clock over the stamp. This
104 // is the `touchSelfDevice` defect precisely -- a parcel that differs from
105 // the last one sent every time it is packed, on a device that has just been
106 // paired, and two devices pushing at each other about nothing.
107 applystamps: [{
108 file: 'js/identity.js',
109 find: '\t\tif (!handleBeats(incoming, mine)) return false;\n'
110 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
111 with: '\t\tif (!incoming) return false;\n'
112 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: Date.now() })); }',
113 }],
114 // The parcel does not carry the handle at all, so a second device of the
115 // same account never hears the name.
116 nocarry: [{
117 file: 'js/sync.js',
118 find: "\t\ttry { if (window.DaimondIdentity) state.handle = DaimondIdentity.handleSnapshot(); }",
119 with: "\t\ttry { if (false) state.handle = DaimondIdentity.handleSnapshot(); }",
120 }],
121 // Whatever arrives is taken, stamps ignored. Two devices then hand the same
122 // two names back and forth, and an older record undoes a rename.
123 // The drawer is deaf to the event. Everything the STORE does stays exactly
124 // right -- the record is adopted, the parcel carries it, the ties settle --
125 // and only 8b reddens, which is what makes 8b a check about the screen
126 // rather than a second reading of the store.
127 handledeaf: [{
128 file: 'js/daimond.js',
129 find: "\t\t\twindow.addEventListener('daimond:handle', function () {",
130 with: "\t\t\twindow.addEventListener('daimond:handle-nobody-sends-this', function () {",
131 }],
132 alwaysadopt: [{
133 file: 'js/identity.js',
134 find: '\t\tif (!handleBeats(incoming, mine)) return false;',
135 with: '\t\tif (!incoming) return false;',
136 }],
137 // One sentence for every refusal.
138 onemessage: [{
139 file: 'js/sync.js',
140 find: "\t\tif (reason === 'taken') return t('handle.taken');",
141 with: "\t\tif (reason === 'taken') return t('handle.failed');",
142 }],
143 // The account's public name is written over the device's own label -- the
144 // exact confusion this feature is shaped around avoiding.
145 devicename: [{
146 file: 'js/identity.js',
147 find: '\t\tif (!handleBeats(incoming, mine)) return false;\n'
148 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
149 with: '\t\tif (!handleBeats(incoming, mine)) return false;\n'
150 + '\t\tlocalStorage.setItem(K_NAME, incoming.h);\n'
151 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
152 }, {
153 file: 'js/identity.js',
154 find: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n'
155 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
156 with: '\t\tif (mine && mine.h === incoming.h && mine.t === incoming.t) return false;\n'
157 + '\t\tlocalStorage.setItem(K_NAME, incoming.h);\n'
158 + '\t\ttry { localStorage.setItem(K_HDL, JSON.stringify({ h: incoming.h, t: incoming.t })); }',
159 }],
160 // Nobody else's name can be resolved, so nothing can be attributed to one.
161 nolookup: [{
162 file: 'js/sync.js',
163 find: '\t\tif (r.status !== 200 || !j.ok || !j.found) return { found: false };',
164 with: '\t\tif (true) return { found: false };',
165 }],
166};
167
168if (BREAK && !BREAKS[BREAK]) {
169 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
170 process.exit(2);
171}
172
173/// The damaged source of ONE file, with every edit for it applied, or a hard
174/// stop.
175///
176/// All of a file's edits go into one body on purpose. Registering two routes for
177/// the same URL serves only the last one, so a break with two edits in one file
178/// would silently deliver half of itself -- and the half it dropped is the half
179/// the check was written for. That happened here: the merge was left intact, the
180/// run went green, and the break looked like a proof that the code was right.
181function damaged(file, specs) {
182 let src = fs.readFileSync(path.join(WWW, file), 'utf8');
183 for (const spec of specs) {
184 const n = src.split(spec.find).length - 1;
185 if (n !== 1) {
186 console.error(`break '${BREAK}': an anchor appears ${n} times in ${file}, `
187 + 'so it was not applied and the run below would prove nothing.');
188 process.exit(2);
189 }
190 src = src.replace(spec.find, spec.with);
191 }
192 return src;
193}
194
195// ── The English the user is owed ─────────────────────────────────────
196// Read from the CATALOGUE FILE, not from the page: a check that asks the page
197// what it thinks the sentence is, and then asserts the page said it, is a check
198// that would pass with every sentence replaced by the same one.
199const CATALOGUE = (() => {
200 const src = fs.readFileSync(path.join(WWW, 'i18n/en.js'), 'utf8');
201 const out = {};
202 for (const key of ['handle.taken', 'handle.invalid', 'handle.reserved', 'handle.failed']) {
203 const m = src.match(new RegExp(`'${key.replace('.', '\\.')}':\\s*'((?:[^'\\\\]|\\\\.)*)'`));
204 if (!m) {
205 console.error(`i18n/en.js carries no '${key}' -- the check below would compare nothing.`);
206 process.exit(2);
207 }
208 out[key] = m[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\');
209 }
210 return out;
211})();
212
213// ── The stubbed gateway ──────────────────────────────────────────────
214// A real namespace owner, in miniature: one name to one account, and a 409 for a
215// name somebody else holds.
216
217const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
218const json = (body, status = 200) => ({
219 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
220});
221
222const ME = 'acct-under-test';
223const SOMEONE = 'acct-somebody-else';
224const THEIRS = 'quiet-heron-22aa';
225const THEIR_FP = 'aaaa bbbb cccc dddd';
226const MINTED = 'amber-otter-7f3q';
227
228/// handle → account id. The reservation, and the only place a name is held.
229const NS = new Map([[THEIRS, SOMEONE]]);
230let clock = 1_700_000_000; // the gateway's clock, in seconds
231let mine = ''; // what the account under test is called
232let mineTs = 0;
233let asked = 0; // GETs of this account's own record
234
235/// The gateway's own rule, in miniature.
236function normalise(raw) {
237 const h = String(raw || '').trim().toLowerCase();
238 if (h.length < 3 || h.length > 24) return null;
239 if (!/^[a-z0-9-]+$/.test(h)) return null;
240 if (h.startsWith('-') || h.endsWith('-') || h.includes('--')) return null;
241 return h;
242}
243const RESERVED = new Set(['admin', 'daimond', 'support', 'system', 'root', 'operator']);
244
245function mint() {
246 if (mine) return;
247 mine = MINTED;
248 mineTs = ++clock;
249 NS.set(mine, ME);
250}
251
252/// Serve one `/api/account` request. `refuseGet` is device B, whose gateway is
253/// deliberately mute so that only the parcel can tell it the account's name.
254function accountRoute(refuseGet) {
255 return (r) => {
256 const req = r.request();
257 const url = new URL(req.url());
258 const method = req.method();
259
260 if (method === 'POST' && url.searchParams.get('op') === 'handle') {
261 let body = {};
262 try { body = JSON.parse(req.postData() || '{}'); } catch (e) { body = {}; }
263 const want = normalise(body.handle);
264 if (!want) return r.fulfill(json({ ok: false, reason: 'invalid' }, 400));
265 if (RESERVED.has(want)) return r.fulfill(json({ ok: false, reason: 'reserved' }, 400));
266 const holder = NS.get(want);
267 if (holder && holder !== ME) return r.fulfill(json({ ok: false, reason: 'taken' }, 409));
268 if (want === mine) {
269 return r.fulfill(json({ ok: true, reason: 'unchanged', handle: mine, handle_ts: mineTs }));
270 }
271 NS.delete(mine);
272 mine = want;
273 mineTs = ++clock;
274 NS.set(mine, ME);
275 return r.fulfill(json({ ok: true, reason: 'claimed', handle: mine, handle_ts: mineTs }));
276 }
277
278 if (method === 'GET') {
279 if (refuseGet) return r.fulfill(json({ ok: false, error: 'no' }, 500));
280 const wanted = url.searchParams.get('handle');
281 if (wanted !== null) {
282 const holder = NS.get(normalise(wanted) || '');
283 if (!holder) return r.fulfill(json({ ok: true, found: false }));
284 return r.fulfill(json({
285 ok: true, found: true,
286 handle: normalise(wanted),
287 fingerprint: holder === SOMEONE ? THEIR_FP : 'ffff ffff ffff ffff',
288 }));
289 }
290 asked++;
291 return r.fulfill(json({ ok: true, account_id: ME, handle: mine, handle_ts: mineTs }));
292 }
293
294 // Registration. The gateway mints the handle: the client never proposes
295 // one, so there is no collision to explain during sign-in.
296 mint();
297 return r.fulfill(json({
298 ok: true, account_id: ME, created: true, handle: mine, handle_ts: mineTs,
299 }));
300 };
301}
302
303/// Everything a page needs to boot signed in, plus the account endpoint.
304async function stub(page, { refuseGet = false, broken = true } = {}) {
305 if (BREAK && broken) {
306 const byFile = new Map();
307 for (const spec of BREAKS[BREAK]) {
308 if (!byFile.has(spec.file)) byFile.set(spec.file, []);
309 byFile.get(spec.file).push(spec);
310 }
311 for (const [file, specs] of byFile) {
312 const body = damaged(file, specs);
313 await page.route('**/' + file, r => r.fulfill({
314 status: 200, contentType: 'application/javascript', body,
315 }));
316 }
317 }
318 await page.route(/\/api\/account(\?|$)/, accountRoute(refuseGet));
319 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-h', challenge_id: 'cid-h' })));
320 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
321 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] })));
322 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' })));
323 // The sync mailbox is not what this is about, and a device pushing into a
324 // 404 retries. Answer it, emptily.
325 await page.route('**/api/sync**', r => r.fulfill(json({ ok: true, version: 0, blob: null })));
326}
327
328const PROFILE_A = scratch('pw', 'handle-a' + (BREAK ? '-' + BREAK : ''));
329const PROFILE_B = scratch('pw', 'handle-b' + (BREAK ? '-' + BREAK : ''));
330for (const p of [PROFILE_A, PROFILE_B]) fs.rmSync(p, { recursive: true, force: true });
331
332const a = await open({
333 name: 'handlea', profile: PROFILE_A, connect: false,
334 route: (page) => stub(page),
335});
336const page = a.page;
337let b = null;
338
339/// The handle this device is showing.
340const handleOf = (p) => p.evaluate(() => window.DaimondSync.handle());
341/// The handle section of the parcel this device would send.
342const sectionOf = (p) => p.evaluate(async () => {
343 const parcel = await window.DaimondSync.parcel();
344 return JSON.stringify(parcel.handle === undefined ? null : parcel.handle);
345});
346
347try {
348 await page.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.handle),
349 null, { timeout: 15000 });
350
351 // ── 1. The device learns the account's name ──────────────────────
352 await page.waitForFunction(() => !!window.DaimondSync.handle(), null, { timeout: 15000 })
353 .catch(() => { /* asserted below, with the value in the message */ });
354 const learned = await handleOf(page);
355 check('the device learns the handle the gateway minted', learned === MINTED,
356 `showing '${learned}', the gateway minted '${MINTED}'`);
357 check('and it had to ask for it', asked > 0, `${asked} request(s)`);
358
359 // ── 2. It is the ACCOUNT's name, not the device's ────────────────
360 // The trap this whole feature is shaped around: `displayName()` labels this
361 // device's keypair and must be untouched by any of it.
362 const label = await page.evaluate(() => ({
363 device: window.DaimondIdentity.displayName(),
364 raw: localStorage.getItem('daimond-id-name'),
365 }));
366 check('the device\'s own label is left alone', label.device === 'handlea' && label.raw !== learned,
367 `device label '${label.device}', handle '${learned}'`);
368
369 // ── 3. It rides the parcel ───────────────────────────────────────
370 const carried = JSON.parse(await sectionOf(page) || 'null');
371 check('the parcel carries the handle', !!carried && carried.h === learned,
372 `parcel carries ${JSON.stringify(carried)}`);
373 check('and carries the gateway\'s stamp, not a local clock',
374 !!carried && carried.t === mineTs, `parcel stamp ${carried && carried.t}, gateway ${mineTs}`);
375
376 // ── 4. The fixed point ───────────────────────────────────────────
377 // Applying this device's OWN parcel must leave the next one identical.
378 // Compared as BYTES, because that is what the push-skip compares.
379 const before = await sectionOf(page);
380 await page.evaluate(async () => {
381 const p = await window.DaimondSync.parcel();
382 await window.DaimondSync.apply(p);
383 });
384 await page.waitForTimeout(1200);
385 const after = await sectionOf(page);
386 check('applying its own parcel leaves the handle section byte-identical',
387 before === after, `${before} → ${after}`);
388
389 // ── 5. A newer record is adopted VERBATIM ────────────────────────
390 const NEWER = { v: 1, h: 'silver-brolga-4k2m', t: mineTs + 500 };
391 await page.evaluate(async (rec) => {
392 await window.DaimondSync.apply({ handle: rec });
393 }, NEWER);
394 await page.waitForTimeout(600);
395 const adopted = JSON.parse(await sectionOf(page) || 'null');
396 check('a later record is adopted', adopted && adopted.h === NEWER.h,
397 `showing '${adopted && adopted.h}'`);
398 check('and its stamp is copied, not taken from this device\'s clock',
399 adopted && adopted.t === NEWER.t, `stamp ${adopted && adopted.t}, sent ${NEWER.t}`);
400 // The stamp check is only worth anything if a local clock would LOOK
401 // different, so say what one would have been.
402 const nowish = await page.evaluate(() => Date.now());
403 check('and a local clock would have been visibly different',
404 Math.abs(nowish - NEWER.t) > 60_000, `local ${nowish} vs adopted ${NEWER.t}`);
405 // And the device's own label survived a handle arriving by parcel, which is
406 // the OTHER of the two paths that write the record. Asked again here rather
407 // than only after sign-in, because the first check went through the
408 // gateway's answer and this one goes through the merge.
409 const labelAfter = await page.evaluate(() => window.DaimondIdentity.displayName());
410 check('a handle arriving by parcel still leaves the device label alone',
411 labelAfter === 'handlea' && labelAfter !== NEWER.h, `device label '${labelAfter}'`);
412
413 // ── 6. An older record is refused ────────────────────────────────
414 await page.evaluate(async (t) => {
415 await window.DaimondSync.apply({ handle: { v: 1, h: 'olive-quoll-1a1a', t: t } });
416 }, NEWER.t - 100);
417 await page.waitForTimeout(600);
418 const held = await handleOf(page);
419 check('an older record does not undo a rename', held === NEWER.h, `showing '${held}'`);
420
421 // ── 7. A tie is broken the same way on both devices ──────────────
422 // Same stamp, two names: the smaller name wins, whichever order they arrive
423 // in. A tie broken by "keep mine" is two devices that never converge.
424 const TIE = NEWER.t;
425 const settle = async (first, second) => {
426 await page.evaluate(async ([f, s, t]) => {
427 await window.DaimondSync.apply({ handle: { v: 1, h: f, t: t } });
428 await window.DaimondSync.apply({ handle: { v: 1, h: s, t: t } });
429 }, [first, second, TIE]);
430 await page.waitForTimeout(400);
431 return handleOf(page);
432 };
433 const one = await settle('mellow-teal-3x3x', 'amber-wren-9q9q');
434 const two = await settle('amber-wren-9q9q', 'mellow-teal-3x3x');
435 check('a tie settles the same way whichever parcel arrives first',
436 one === two && !!one, `${one} vs ${two}`);
437
438 // ── 8. A second device of the same account shows the same name ───
439 // Its gateway is mute (every GET is a 500), so the ONLY thing that can tell
440 // it the account's name is the parcel.
441 const bundle = await page.evaluate(() => window.DaimondIdentity.exportBundle());
442 check('the pairing bundle carries the handle', !!(bundle && bundle.hdl && bundle.hdl.h),
443 JSON.stringify(bundle && bundle.hdl));
444
445 b = await open({
446 name: 'handleb', profile: PROFILE_B, connect: false,
447 route: (p) => stub(p, { refuseGet: true }),
448 });
449 await b.page.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.parcel),
450 null, { timeout: 15000 });
451 // B becomes the SAME account: it adopts A's identity bundle and unlocks it
452 // with the same passphrase, exactly as a paired device does. The handle is
453 // then wiped from B, so what it shows afterwards can only have come from the
454 // parcel it is about to be handed.
455 const became = await b.page.evaluate(async ([bun, pass]) => {
456 const took = window.DaimondIdentity.importBundle(bun);
457 const un = await window.DaimondIdentity.unlock(pass);
458 localStorage.removeItem('daimond-id-handle');
459 return { took: took, unlocked: !!(un && un.ok), showing: window.DaimondSync.handle() };
460 }, [bundle, PASS]);
461 check('the second device adopted the identity and holds no name yet',
462 became.took && became.unlocked && !became.showing, JSON.stringify(became));
463
464 // ── 8b. AND THE SCREEN SAYS SO, WITHOUT ANOTHER CLICK ────────────
465 //
466 // The store is not the screen. B is holding the account view OPEN before the
467 // parcel arrives and nothing is touched afterwards, so the row that names the
468 // account either redraws itself or it does not. It did not: `identity.js`
469 // dispatched `daimond:handle` on both write paths and nothing anywhere
470 // listened, so the local claim redrew the drawer by calling it directly and
471 // the adopted one -- this path -- left B showing the name it had, or, as
472 // here, no name at all.
473 await b.page.evaluate(() => { window.DaimondAdmin.home(); });
474 await b.page.waitForTimeout(400);
475 /// What the account row on that screen is showing, or '' when it is not drawn.
476 const shownOn = (p) => p.evaluate(() => {
477 const r = document.getElementById('account-handle');
478 const v = r && r.querySelector('.account-fp-val');
479 return v ? (v.textContent || '').trim() : '';
480 });
481 const drawnBefore = await shownOn(b.page);
482 check('the second device has the account view open and no name drawn on it',
483 drawnBefore === '', `showing '${drawnBefore}'`);
484
485 // APPLIED AND READ IN ONE EVALUATE, with no timeout between them. This world
486 // has no gateway, so the home view's console section fails to load and retries
487 // itself on a timer -- `renderHome` again at 1.2s, 2.4s, 3.6s -- which redraws
488 // the handle row for a reason that has nothing to do with the handle. A check
489 // that waited would pass with the listener deleted, which is what it did when
490 // it was written that way. A `setTimeout` callback cannot run between an
491 // `await` resuming and the statements after it, so this reads the DOM at the
492 // only moment nothing else can have touched it.
493 const parcelA = await page.evaluate(() => window.DaimondSync.parcel());
494 const adopted2 = await b.page.evaluate(async (p) => {
495 await window.DaimondSync.apply(p);
496 const r = document.getElementById('account-handle');
497 const v = r && r.querySelector('.account-fp-val');
498 return { drawn: v ? (v.textContent || '').trim() : '', held: window.DaimondSync.handle() };
499 }, parcelA);
500 await b.page.waitForTimeout(800);
501 const onB = await handleOf(b.page);
502 const onA = await handleOf(page);
503 check('and the parcel alone gives it the account\'s name', onB === onA && !!onB,
504 `A shows '${onA}', B shows '${onB}'`);
505
506 check('and the open drawer draws it, with nothing clicked and no timer to help',
507 !!adopted2.held && adopted2.drawn === adopted2.held,
508 `drawn '${adopted2.drawn}', held '${adopted2.held}'`);
509
510 // ── 9. A refusal says WHICH refusal ──────────────────────────────
511 // Back to a name the stub's namespace will accept, so the refusals below are
512 // about the names asked for and not about where this device had got to.
513 await page.evaluate(async () => { await window.DaimondSync.claimHandle('bright-finch-5m5m'); });
514 await page.waitForTimeout(400);
515
516 const refusals = await page.evaluate(async ([taken, reserved]) => {
517 const out = {};
518 out.taken = await window.DaimondSync.claimHandle(taken);
519 out.invalid = await window.DaimondSync.claimHandle('no');
520 out.reserved = await window.DaimondSync.claimHandle(reserved);
521 out.after = window.DaimondSync.handle();
522 return out;
523 }, [THEIRS, 'admin']);
524
525 check('a name somebody else holds is refused as taken',
526 refusals.taken.ok === false && refusals.taken.reason === 'taken',
527 JSON.stringify(refusals.taken));
528 check('and each refusal carries its own sentence from the catalogue',
529 refusals.taken.message === CATALOGUE['handle.taken']
530 && refusals.invalid.message === CATALOGUE['handle.invalid']
531 && refusals.reserved.message === CATALOGUE['handle.reserved'],
532 [refusals.taken.message, refusals.invalid.message, refusals.reserved.message].join(' | '));
533 check('which are three different sentences, not one repeated',
534 new Set([refusals.taken.message, refusals.invalid.message, refusals.reserved.message]).size === 3);
535 check('and a refused rename leaves the name where it was',
536 refusals.after === 'bright-finch-5m5m', `showing '${refusals.after}'`);
537
538 // ── 10. A rename that takes ──────────────────────────────────────
539 const renamed = await page.evaluate(async () => {
540 const r = await window.DaimondSync.claimHandle('Copper-Marten-8P8P');
541 return { r: r, showing: window.DaimondSync.handle() };
542 });
543 check('a rename takes, folded to what the namespace stores',
544 renamed.r.ok === true && renamed.showing === 'copper-marten-8p8p', JSON.stringify(renamed));
545 check('and the namespace agrees the account holds it',
546 NS.get('copper-marten-8p8p') === ME, `held by ${NS.get('copper-marten-8p8p')}`);
547 const afterRename = JSON.parse(await sectionOf(page) || 'null');
548 check('and the parcel carries the new name with the gateway\'s stamp',
549 afterRename && afterRename.h === 'copper-marten-8p8p' && afterRename.t === mineTs,
550 JSON.stringify(afterRename));
551 // The third and last place the record is written, and the device's own label
552 // has to survive that one too.
553 const labelEnd = await page.evaluate(() => window.DaimondIdentity.displayName());
554 check('and a rename still leaves the device label alone',
555 labelEnd === 'handlea', `device label '${labelEnd}'`);
556
557 // ── 11. Somebody else's name resolves ────────────────────────────
558 const found = await page.evaluate(async (h) => window.DaimondSync.lookupHandle(h), THEIRS);
559 check('another account\'s handle is readable', found.found === true && found.handle === THEIRS,
560 JSON.stringify(found));
561 check('and comes with the key fingerprint a share would be checked against',
562 found.fingerprint === THEIR_FP, JSON.stringify(found));
563 const missing = await page.evaluate(async () => window.DaimondSync.lookupHandle('nobody-here-7z'));
564 check('a name nobody holds is a miss, not a crash', missing.found === false,
565 JSON.stringify(missing));
566
567 // And nothing THREW on the way past. Failed loads are excluded on purpose:
568 // this world has no gateway on :9002, so the endpoints that are not stubbed
569 // here -- the wake channel among them -- answer 502, which says nothing about
570 // the handle. An exception does.
571 const threw = errors(a).concat(b ? errors(b) : [])
572 .filter(e => !/Failed to load resource|502|WebSocket/i.test(e));
573 check('nothing threw in either page', threw.length === 0, threw.slice(0, 3).join(' | '));
574
575} finally {
576 await a.close();
577 if (b) await b.close();
578}
579
580console.log(`\n${ok.length} passed, ${bad.length} failed`);
581if (bad.length) console.log('failed: ' + bad.join(', '));
582process.exit(bad.length ? 1 : 0);