Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_look.mjs

16.7 KiB, 1 run

created by r2519314175:515, 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_look.mjs — a device that joins an account by any route arrives wearing
2// the account's look, once, and never argues about it afterwards.
3//
4// The pairing bundle carried theme, skin, language, currency, reading size and
5// layout to a device linked by a QR code, and `applyLook` had exactly one caller.
6// A device brought across by a passkey — which the catalogue advertises as
7// bringing the account over "without a pairing code or a passphrase" — or one
8// that simply holds the identity and is unlocked by typing it, got none of it. A
9// user who works in French on a 125% reading size signed in on a new machine and
10// was met by an English app at 100%.
11//
12// So the look now rides the sync parcel, which is the only channel every route
13// ends at. Two things have to be true of it at once, and they pull in opposite
14// directions:
15//
16// IT MUST ARRIVE. A device that has never had a look of its own puts on the
17// account's, live, through the same services the appearance menu calls.
18//
19// IT MUST NOT LOOP. The parcel is a FIXED POINT or two devices push at each
20// other for ever — reported from a freshly paired iPhone as "the syncing
21// seemed to go into an endless loop". A device that has a look of its own
22// RECORDS the account's without wearing it, and reports that same record back
23// unchanged. Nothing on the receiving path may stamp.
24//
25// The second device here is a genuinely separate browser profile that adopts the
26// identity the way `adoptWithPasskey` does — `importBundle` of the exported
27// bundle, then an ordinary passphrase unlock — and the file asserts that it
28// never went near /api/pair. That is the route the bug was about.
29//
30// node dev/verify_look.mjs
31//
32// Needs dev/serve.mjs (DAIMOND_PORT) AND the gateway on :9002.
33import { open, signInAs } from './harness.mjs';
34import { makePagePro } from './pro.mjs';
35import path from 'node:path';
36import { fileURLToPath } from 'node:url';
37
38const ok = [], bad = [];
39const check = (name, pass, detail) => {
40 (pass ? ok : bad).push(name);
41 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
42};
43const sleep = ms => new Promise(r => setTimeout(r, ms));
44
45/// Push until THIS device's own parcel is what the mailbox holds. See
46/// verify_sync, where the same helper explains why the version advancing is not
47/// evidence that this device's work went anywhere.
48async function pushLanded(pg) {
49 return await pg.evaluate(async (ms) => {
50 const mailbox = async () => {
51 const res = await fetch('/api/sync', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } });
52 const j = await res.json();
53 if (!j.present) return null;
54 try { return await window.DaimondIdentity.unwrap(j.blob); } catch (e) { return null; }
55 };
56 const mine = new Set();
57 const t0 = Date.now();
58 while (Date.now() - t0 < ms) {
59 await window.DaimondSync.push();
60 mine.add(JSON.stringify(await window.DaimondSync.parcel()));
61 const held = await mailbox();
62 if (held !== null && mine.has(held)) return true;
63 await new Promise(r => setTimeout(r, 200));
64 }
65 return false;
66 }, 25000);
67}
68
69/// How a device looks, read from the places that decide it rather than from the
70/// keys that were written. The DOM attributes are what the palette and the skin
71/// actually hang off; `--fs-scale` is what the type is sized by; and the sample
72/// string is the app speaking, which is the only proof a language arrived.
73const looksLike = (pg) => pg.evaluate(() => ({
74 theme: document.documentElement.getAttribute('data-theme'),
75 tone: document.documentElement.getAttribute('data-tone'),
76 skin: document.documentElement.getAttribute('data-skin'),
77 lang: document.documentElement.lang,
78 scale: getComputedStyle(document.documentElement).getPropertyValue('--fs-scale').trim(),
79 locale: window.DaimondI18n ? DaimondI18n.locale() : '',
80 currency: window.DaimondI18n ? DaimondI18n.currency() : '',
81 sample: window.DaimondI18n ? DaimondI18n.t('sync.synced') : '',
82 layout: localStorage.getItem('daimond-layout') || '',
83 stored: {
84 theme: localStorage.getItem('daimond-theme'),
85 skin: localStorage.getItem('daimond-skin'),
86 locale: localStorage.getItem('daimond-locale'),
87 currency: localStorage.getItem('daimond-currency'),
88 scale: localStorage.getItem('daimond-fs-scale'),
89 },
90}));
91
92const GWDIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'gateway');
93const s = await open({ name: 'look', signIn: true, connect: false, defaults: false });
94const { page } = s;
95let child = null;
96
97try {
98 await page.waitForFunction(
99 () => !!window.DaimondSync && !!window.DaimondGateway && DaimondGateway.state().authed,
100 null, { timeout: 20000 }).catch(() => {});
101 const lic = await makePagePro(page, GWDIR);
102 check('the account holds Pro, so a parcel can travel at all',
103 lic.pro === true, `webhook ${lic.status}, pro=${lic.pro}`);
104
105 // ── (1) The first device is dressed by its user ───────────────────
106 // Through the app's own services, which is what a person pressing the
107 // controls does. A test that wrote the keys directly would be asserting
108 // against a state the app never produces.
109 await page.evaluate(async () => {
110 DaimondTheme.set('amber');
111 DaimondSkin.set('sharp');
112 DaimondI18n.setCurrency('EUR');
113 DaimondWorkspace.setScale(1.3);
114 await DaimondI18n.setLocale('fr');
115 });
116 await sleep(600);
117 const dressed = await looksLike(page);
118 check('the first device is wearing a look of its own',
119 dressed.theme === 'amber' && dressed.skin === 'sharp' && dressed.locale === 'fr'
120 && dressed.currency === 'EUR' && dressed.stored.scale === '1.3',
121 JSON.stringify(dressed).slice(0, 160));
122
123 check('the first device gets its parcel into the mailbox', await pushLanded(page));
124 const sent = await page.evaluate(() => window.DaimondSync.parcel());
125 check('the parcel carries the look as a stamped record',
126 !!(sent.look && sent.look.t > 0 && sent.look.v
127 && sent.look.v['daimond-theme'] === 'amber'),
128 JSON.stringify(sent.look));
129 check('and it carries the five that travel and NOT the layout, which is the screen’s',
130 !!sent.look && Object.keys(sent.look.v).sort().join(',')
131 === 'daimond-currency,daimond-fs-scale,daimond-locale,daimond-skin,daimond-theme',
132 Object.keys((sent.look || {}).v || {}).sort().join(','));
133
134 // ── (2) A second device, by a route that is NOT a pairing bundle ──
135 // The identity is transplanted exactly as `adoptWithPasskey` transplants it:
136 // `importBundle` of the bundle, then an ordinary passphrase unlock. What is
137 // deliberately absent is `DaimondPairing.redeem`, which is the one caller
138 // `applyLook` ever had.
139 child = await open({ name: 'lookmate', signIn: false, connect: false });
140 const pairCalls = [];
141 child.page.on('request', (r) => { if (r.url().includes('/api/pair')) pairCalls.push(r.url()); });
142 await child.page.waitForFunction(() => !!window.DaimondIdentity, null, { timeout: 20000 });
143 const virgin = await looksLike(child.page);
144 check('the new device starts on the defaults, in English, looking nothing like it',
145 virgin.theme !== 'amber' && virgin.skin !== 'sharp' && virgin.locale === 'en',
146 `${virgin.theme}/${virgin.skin}/${virgin.locale} "${virgin.sample}"`);
147
148 const bundle = await page.evaluate(() => DaimondIdentity.exportBundle());
149 const took = await child.page.evaluate(b => DaimondIdentity.importBundle(b), bundle);
150 check('the new device takes the identity without a pairing code', took === true);
151 await child.page.reload({ waitUntil: 'domcontentloaded' });
152 await signInAs(child, 'look');
153 await child.page.waitForFunction(
154 () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed,
155 null, { timeout: 20000 }).catch(() => {});
156 const same = await child.page.evaluate(() => window.DaimondIdentity.publicKeyB64url());
157 const mine = await page.evaluate(() => window.DaimondIdentity.publicKeyB64url());
158 check('and it is the SAME account, unlocked with the passphrase', same === mine, same.slice(0, 12));
159 check('with no pairing bundle anywhere in it — this is the route that got nothing',
160 pairCalls.length === 0, pairCalls.join(' | ') || 'no /api/pair request was made');
161 // This device's own dock arrangement, as it stands before anything arrives.
162 // The layout is the one setting that must NOT travel, and "did not change"
163 // is the exact form of that -- two fresh profiles may well have byte-identical
164 // default layouts, so comparing the two devices would prove nothing.
165 const preLook = await looksLike(child.page);
166
167 // ── (3) The look arrives, and is WORN ─────────────────────────────
168 // Nothing is done to the device but wait: the first pull after signing in is
169 // what carries it.
170 const worn = await (async () => {
171 const t0 = Date.now();
172 let seen = null;
173 while (Date.now() - t0 < 30000) {
174 seen = await looksLike(child.page);
175 if (seen.theme === 'amber' && seen.locale === 'fr') return seen;
176 await sleep(400);
177 }
178 return seen;
179 })();
180 check('the new device puts on the account’s palette, with nothing asked of it',
181 worn.theme === 'amber' && worn.tone === 'dark', `${worn.theme}/${worn.tone}`);
182 check('and its skin', worn.skin === 'sharp', worn.skin);
183 check('and it speaks the account’s language — the app itself, not the stored key',
184 worn.locale === 'fr' && worn.lang === 'fr' && worn.sample !== virgin.sample
185 && worn.sample === dressed.sample,
186 `${worn.lang} "${worn.sample}"`);
187 check('and shows money in the account’s currency', worn.currency === 'EUR', worn.currency);
188 check('and reads at the account’s size, in the type itself',
189 worn.stored.scale === '1.3' && parseFloat(worn.scale) === 1.3,
190 `stored ${worn.stored.scale}, --fs-scale ${worn.scale}`);
191 check('but NOT the other device’s dock layout — its own arrangement is untouched',
192 !!worn.layout && worn.layout === preLook.layout,
193 (worn.layout || '(none)').slice(0, 70));
194
195 // ── (4) It is worn ONCE ───────────────────────────────────────────
196 // The second device now chooses for itself. Whatever the account does after
197 // that is recorded and not imposed: a phone and a desk may differ.
198 await child.page.evaluate(() => DaimondTheme.set('forest'));
199 await sleep(400);
200 await pushLanded(child.page);
201 await page.evaluate(() => window.DaimondSync.pull());
202 await page.evaluate(() => DaimondTheme.set('midnight'));
203 await sleep(400);
204 check('the first device pushes a later look', await pushLanded(page));
205 const held = await (async () => {
206 const t0 = Date.now();
207 let seen = null;
208 while (Date.now() - t0 < 20000) {
209 await child.page.evaluate(() => window.DaimondSync.pull());
210 seen = await child.page.evaluate(async () => ({
211 look: (await window.DaimondSync.parcel()).look,
212 theme: document.documentElement.getAttribute('data-theme'),
213 }));
214 if (seen.look && seen.look.v['daimond-theme'] === 'midnight') return seen;
215 await sleep(500);
216 }
217 return seen;
218 })();
219 check('a device that has a look of its own RECORDS the account’s later one',
220 !!(held.look && held.look.v['daimond-theme'] === 'midnight'),
221 JSON.stringify(held.look));
222 check('and does not put it on — the look arrives once, at first login, and never again',
223 !!(held.look && held.look.v['daimond-theme'] === 'midnight') && held.theme === 'forest',
224 `holding ${held.look && held.look.v['daimond-theme']}, wearing ${held.theme}`);
225
226 // ── (4b) And a device that was here before the record was ─────────
227 // The migration, which is the case that decides whether shipping this is
228 // safe. Every device in an existing account wakes up one morning with no
229 // record of its own look and a mailbox that may already hold somebody else's
230 // -- and if "has this device got a look" were answered by looking at the keys,
231 // the answer would be no, because they are only there at all thanks to the
232 // default theme and skin the app writes on every boot. A whole cohort would be
233 // redressed by whichever device published first.
234 //
235 // What is asked instead is whether this device has ever read this account's
236 // mailbox, which nothing but this device having been here can produce. So:
237 // wipe what the feature knows about this device, keep the sync cursor, and
238 // come back.
239 const cursor = await child.page.evaluate(() => {
240 localStorage.removeItem('daimond-look');
241 localStorage.removeItem('daimond-look-base');
242 return localStorage.getItem('daimond-sync-version');
243 });
244 check('the established device still has the one thing that says it was here',
245 (cursor | 0) > 0, `sync cursor ${cursor}`);
246 await child.page.reload({ waitUntil: 'domcontentloaded' });
247 await signInAs(child, 'look');
248 await child.page.waitForFunction(
249 () => !!window.DaimondSync && window.DaimondGateway && DaimondGateway.state().authed,
250 null, { timeout: 20000 }).catch(() => {});
251 const migrated = await (async () => {
252 const t0 = Date.now();
253 let seen = null;
254 while (Date.now() - t0 < 20000) {
255 await child.page.evaluate(() => window.DaimondSync.pull());
256 seen = await child.page.evaluate(async () => ({
257 look: (await window.DaimondSync.parcel()).look,
258 theme: document.documentElement.getAttribute('data-theme'),
259 }));
260 if (seen.look && seen.look.v['daimond-theme']) return seen;
261 await sleep(500);
262 }
263 return seen;
264 })();
265 check('a device that has synced before is NOT redressed by the record arriving',
266 migrated.theme === 'forest', migrated.theme);
267 check('and it carries the account’s record all the same',
268 !!(migrated.look && migrated.look.v['daimond-theme'] === 'midnight'),
269 JSON.stringify(migrated.look));
270
271 // ── (5) The fixed point, which is what stops the endless loop ─────
272 // Measured on the device that DISAGREES with the record it holds, because
273 // that is the state a restamp would show up in: collect, apply what you just
274 // collected, collect again. And then apply the OTHER device's parcel, which
275 // is the cross-device shape the iPhone was caught in.
276 const stable = await child.page.evaluate(async () => {
277 const a = JSON.stringify(await window.DaimondSync.parcel());
278 await window.DaimondSync.apply(JSON.parse(a));
279 const b = JSON.stringify(await window.DaimondSync.parcel());
280 return { a, b };
281 });
282 check('applying its own parcel leaves the next one byte-identical',
283 stable.a === stable.b,
284 stable.a === stable.b ? `${stable.a.length} bytes` : 'the parcel moved under an apply');
285 const theirs = await page.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel()));
286 const crossed = await child.page.evaluate(async (p) => {
287 const before = (await window.DaimondSync.parcel()).look;
288 await window.DaimondSync.apply(JSON.parse(p));
289 const one = (await window.DaimondSync.parcel()).look;
290 await window.DaimondSync.apply(JSON.parse(p));
291 const two = (await window.DaimondSync.parcel()).look;
292 return { sent: JSON.parse(p).look, before, one, two,
293 theme: document.documentElement.getAttribute('data-theme') };
294 }, theirs);
295 check('and applying the OTHER device’s parcel gives that record straight back, unstamped',
296 !!(crossed.sent && crossed.sent.t) && JSON.stringify(crossed.one) === JSON.stringify(crossed.sent),
297 `sent ${JSON.stringify(crossed.sent)} → would send ${JSON.stringify(crossed.one)}`);
298 check('twice over, so nothing is drifting a stamp at a time',
299 !!(crossed.one && crossed.one.t) && JSON.stringify(crossed.one) === JSON.stringify(crossed.two),
300 `${JSON.stringify(crossed.one)} then ${JSON.stringify(crossed.two)}`);
301 check('and merging a look it will not wear does not change how it looks',
302 !!(crossed.sent && crossed.sent.v['daimond-theme'] === 'midnight') && crossed.theme === 'forest',
303 `merged ${crossed.sent && crossed.sent.v['daimond-theme']}, wearing ${crossed.theme}`);
304
305 // And the same on the first device, which is the other half of the loop.
306 const stableA = await page.evaluate(async () => {
307 const a = JSON.stringify(await window.DaimondSync.parcel());
308 await window.DaimondSync.apply(JSON.parse(a));
309 const b = JSON.stringify(await window.DaimondSync.parcel());
310 return a === b;
311 });
312 check('the first device’s parcel is a fixed point too', stableA === true);
313
314 const clean = (errs) => errs.filter(e =>
315 !/favicon|ERR_|Failed to load resource|401|402|409|426|502|Unauthorized/.test(e));
316 check('no unexpected console errors on the new device', clean(child.errs).length === 0,
317 clean(child.errs).slice(0, 3).join(' | '));
318 check('no unexpected console errors on the first one', clean(s.errs).length === 0,
319 clean(s.errs).slice(0, 3).join(' | '));
320} catch (e) {
321 check('verify_look ran without throwing', false, String(e && e.message || e));
322} finally {
323 await child?.close?.().catch?.(() => {});
324 await s.close?.().catch?.(() => {});
325}
326
327console.log('\n' + (bad.length ? `FAIL: ${bad.length} failed, ${ok.length} passed` : `ok: all ${ok.length} passed`));
328process.exit(bad.length ? 1 : 0);