Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_presence.mjs

10.5 KiB, 1 run

created by r2519314175:597, 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_presence.mjs — a presence beat travels off the content parcel, on a
2// path that wakes nobody.
3//
4// The desktop-peer presence beat used to write a live wall-clock timestamp into
5// the SYNC CONTENT PARCEL and re-upload the whole ~163K parcel every 45s, waking
6// every other device forever and making the parcel a moving target that no fixed
7// point could ever match (breaking dev/verify_reloadpush.mjs). The fix moves
8// presence onto the gateway's OWN lightweight, non-waking route:
9//
10// - a beat is `POST /api/sync?presence=1` with `{device_id, name}`, answered
11// with `{ok, now, presence:{ id:{name,last_seen}, ... }}`. It bumps no blob
12// version and wakes nobody.
13// - the ordinary pull (`GET /api/sync`) now also carries `presence` and `now`,
14// adopted for free.
15// - every last_seen is stamped in the SERVER clock; the client converts it into
16// its own frame (DaimondPresence.ingest), so freshness checks that read
17// Date.now() stay skew-immune and unchanged.
18//
19// This proves, with TWO real paired contexts (device a and device b):
20// (a) a beat produces NO content /api/sync POST and does NOT wake device b;
21// (b) device b, after device a beats, sees device a as an awake peer, named;
22// (c) presence is NOT in the content parcel — it is byte-stable across beats;
23// (d) a device that stops beating ages out of `awake` after the fresh window.
24//
25// Needs the dev stack: the app (DAIMOND_PORT), the mock, and a gateway on
26// DAIMOND_GW_PORT that speaks the presence path. Presence rides the Pro-gated
27// /api/sync door, so the account is granted Pro the one way the gateway trusts
28// (dev/pro.mjs).
29
30import { open, chat, signInAs, newChat } from './harness.mjs';
31import { makePagePro } from './pro.mjs';
32import { GW_URL } from './ports.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
40/// Count POSTs to /api/sync on this page, split by whether they are the presence
41/// path (`?presence=1`) or an ordinary content push. A beat must show up only as
42/// the former; a content push waking the other device is the latter.
43const counter = (page) => {
44 const seen = [];
45 page.on('request', (r) => {
46 try {
47 const u = new URL(r.url());
48 if (u.pathname === '/api/sync' && r.method() === 'POST') {
49 let n = 0;
50 try { n = (r.postData() || '').length; } catch (e) { /* unreadable */ }
51 seen.push({ presence: u.searchParams.get('presence') === '1', bytes: n });
52 }
53 } catch (e) { /* not a URL this counts */ }
54 });
55 return seen;
56};
57
58const content = (seen) => seen.filter((x) => !x.presence);
59const presence = (seen) => seen.filter((x) => x.presence);
60
61const quiet = (pg) => pg.evaluate(() => window.DaimondSync.state().quiet);
62const parcel = (pg) => pg.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel()));
63const wakes = (pg) => pg.evaluate(() => window.DaimondSync.wake().wakes);
64const devId = (pg) => pg.evaluate(() => window.DaimondIdentity.deviceId());
65const devName = (pg) => pg.evaluate(() =>
66 (window.DaimondIdentity.displayName && window.DaimondIdentity.displayName()) || '');
67
68/// Wait until nothing is running and nothing is armed on this page.
69const settle = async (pg, ms = 20000) => {
70 const t0 = Date.now();
71 while (Date.now() - t0 < ms) {
72 if (await quiet(pg)) { await pg.waitForTimeout(1500); if (await quiet(pg)) return true; }
73 await pg.waitForTimeout(400);
74 }
75 return false;
76};
77
78/// One presence beat from a page, with this device's own id and name — exactly
79/// what daimond.js's presenceTick passes.
80const beat = (pg) => pg.evaluate(() => {
81 const id = window.DaimondIdentity.deviceId();
82 const nm = (window.DaimondIdentity.displayName && window.DaimondIdentity.displayName()) || '';
83 return window.DaimondSync.beatPresence(id, nm);
84});
85
86const a = await open({ name: 'preslead', signIn: true, connect: true, defaults: false });
87let b = null;
88try {
89 await a.page.waitForFunction(
90 () => !!window.DaimondSync && !!window.DaimondPresence && window.DaimondGateway
91 && DaimondGateway.state().authed,
92 null, { timeout: 15000 }).catch(() => {});
93 const pro = await makePagePro(a.page, new URL('../gateway', import.meta.url).pathname, GW_URL);
94 check('the account holds Pro, so the presence path is not refused before it is measured',
95 pro.pro === true, JSON.stringify(pro));
96
97 await newChat(a);
98 await chat(a, 'one turn, so there is a parcel to compare against at all');
99 await settle(a.page);
100 await a.page.waitForTimeout(2000);
101
102 // ── Pair device b to the SAME account ──────────────────────────────
103 // A second real context of one account, exactly as dev/verify_reloadpush.mjs
104 // check 6 pairs one: a mints a code, b redeems it, reloads onto the account,
105 // signs in and waits for a session. Its per-device id is minted locally and
106 // is DISTINCT from a's, which is what makes "the other device" meaningful.
107 b = await open({ name: 'presmate', signIn: false, connect: false });
108 await b.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 15000 }).catch(() => {});
109 const code = await a.page.evaluate(() => DaimondPairing.create());
110 await b.page.evaluate((c) => DaimondPairing.redeem(c), code.code);
111 await b.page.reload({ waitUntil: 'domcontentloaded' });
112 await signInAs(b, 'preslead');
113 await b.page.waitForFunction(
114 () => !!window.DaimondSync && !!window.DaimondPresence && window.DaimondGateway
115 && DaimondGateway.state().authed,
116 null, { timeout: 20000 }).catch(() => {});
117 await b.page.waitForTimeout(4000);
118 await settle(b.page);
119
120 const idA = await devId(a.page);
121 const idB = await devId(b.page);
122 const nameA = await devName(a.page);
123 check('the two paired devices hold DISTINCT per-device ids',
124 !!idA && !!idB && idA !== idB, JSON.stringify({ idA, idB }));
125
126 // ── (a) A beat sends no content push and wakes nobody ──────────────
127 // The heart of the fix. Watch device a's POSTs while it beats, and device b's
128 // wake tally across those beats. A beat must be a presence POST and only that:
129 // zero content /api/sync POSTs, and device b's wakes unmoved.
130 await settle(a.page);
131 await b.page.waitForTimeout(2000);
132 const seenA = counter(a.page);
133 const wokeBefore = await wakes(b.page);
134 for (let i = 0; i < 3; i++) { await beat(a.page); await a.page.waitForTimeout(600); }
135 await b.page.waitForTimeout(4000); // long enough for a wake to have arrived if one was coming
136 const wokeAfter = await wakes(b.page);
137 check('a presence beat sends NO content sync push (only the presence path)',
138 content(seenA).length === 0,
139 content(seenA).length + ' content push(es), ' + presence(seenA).length + ' presence beat(s)');
140 check('the beats really used the presence path (so the zero above means something)',
141 presence(seenA).length >= 1, presence(seenA).length + ' presence POST(s) seen');
142 check('a presence beat does NOT wake the account\'s other device',
143 wokeAfter === wokeBefore, 'wakes ' + wokeBefore + ' -> ' + wokeAfter);
144
145 // ── (b) Device b sees device a as an awake peer, named ─────────────
146 // After a has beaten, b reads the account's presence map (a plain GET, no
147 // beat) and must find device a among its awake peers, with the name a beat
148 // under. `awake` excludes self, so a — not b — is what should appear.
149 await beat(a.page);
150 await a.page.waitForTimeout(400);
151 await b.page.evaluate(() => window.DaimondSync.refreshPresence());
152 const awakeSeenByB = await b.page.evaluate((self) => {
153 const list = window.DaimondPresence.awake(self, Date.now()) || [];
154 return list.map((x) => ({ deviceId: x.deviceId, name: x.name }));
155 }, idB);
156 check('device b sees device a as an awake peer after a beats',
157 awakeSeenByB.some((x) => x.deviceId === idA), JSON.stringify(awakeSeenByB));
158 check('and the awake peer is named as device a beat',
159 awakeSeenByB.some((x) => x.deviceId === idA && x.name === nameA),
160 JSON.stringify({ want: { deviceId: idA, name: nameA }, got: awakeSeenByB }));
161 check('device b does not list ITSELF among its awake peers',
162 !awakeSeenByB.some((x) => x.deviceId === idB), JSON.stringify(awakeSeenByB));
163
164 // ── (c) Presence is NOT in the content parcel ──────────────────────
165 // The parcel is what a push sends and a pull merges. If presence still rode
166 // it, its moving last_seen would move the parcel on every beat; off it, two
167 // beats a second apart leave the parcel byte-identical.
168 await settle(a.page);
169 const parcelBefore = await parcel(a.page);
170 await beat(a.page);
171 await a.page.waitForTimeout(1000);
172 await beat(a.page);
173 const parcelAfter = await parcel(a.page);
174 check('the content parcel is byte-stable across two beats 1s apart (presence is off it)',
175 parcelBefore === parcelAfter,
176 parcelBefore === parcelAfter ? '' : 'it moved, so presence is still riding the parcel');
177 check('and the parcel carries no presence section at all',
178 parcelAfter.indexOf('"presence"') === -1,
179 parcelAfter.indexOf('"presence"') === -1 ? '' : 'a "presence" key is in the parcel');
180
181 // ── (d) A device that stops beating ages out of awake ──────────────
182 // Simulated honestly on the real freshness logic: ingest an authoritative map
183 // (server clock === now, so no skew) holding one device whose last_seen is
184 // well past the fresh window and one within it. `awake` must drop the stale
185 // one and keep the fresh one — which is exactly what a peer that stopped
186 // beating looks like once its last beat ages out.
187 const aged = await b.page.evaluate(() => {
188 const now = Date.now();
189 const FRESH = window.DaimondPresence.FRESH_MS;
190 window.DaimondPresence.ingest({
191 'stale-dev': { name: 'went-to-sleep', last_seen: now - (FRESH + 60000) },
192 'fresh-dev': { name: 'still-awake', last_seen: now - 1000 },
193 }, now);
194 const list = (window.DaimondPresence.awake('viewer', now) || []).map((x) => x.deviceId);
195 return { hasStale: list.includes('stale-dev'), hasFresh: list.includes('fresh-dev') };
196 });
197 check('a device whose last beat aged past the fresh window is NOT awake', !aged.hasStale,
198 JSON.stringify(aged));
199 check('a device still within the fresh window IS awake (the window discriminates)',
200 aged.hasFresh, JSON.stringify(aged));
201} catch (e) {
202 check('the run finished', false, String((e && e.stack) || e));
203} finally {
204 if (b) await b.close();
205 await a.close();
206}
207
208console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
209if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); }
210process.exit(bad.length ? 1 : 0);