Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_relay_e2e.mjs

33.4 KiB, 3 runs

created by r2519314175:639, 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// dev/verify_relay_e2e.mjs -- the two halves meeting: a message sealed in one
2// browser, carried by a RUNNING GATEWAY, and opened in another.
3//
4// WHY THIS FILE EXISTS. Every other verifier of the messaging client carries the
5// bytes itself. `verify_post.mjs` says so at the top -- "no server in the path at
6// all" -- and `verify_trust.mjs` and `verify_group.mjs` both assert that no
7// gateway was even running. That is the right shape for what they prove: the
8// seal must not need a server, and a group that only works with one has put the
9// server inside the cryptography. But it leaves one thing untested, and it is
10// the thing that breaks: the CLIENT's idea of `/api/post` and the GATEWAY's idea
11// of `/api/post` were written from the contract by different lanes, each was
12// exercised only against itself, and each was honestly correct in isolation. Two
13// halves that have never spoken cannot be said to fit.
14//
15// So the property here is not the seal and not the roster. It is: THE WIRE.
16// Every request below goes through the real front door to a real gateway process
17// with a real store, and the answers are read by the real client.
18//
19// WHAT THIS RUN OWNS. Its own gateway, on its own port, in its own directory,
20// with an EMPTY store; and its own dev server pointed at that gateway. Nothing
21// is shared, because the checks below count rows in a postbox and counting them
22// in a store somebody else is writing to would measure their afternoon. The
23// browser-only worlds (`dev/world.sh N`) number the app at 8777+N and the mock
24// provider at 9099+N; this takes world 5's app port and a gateway port well
25// clear of both, so a run here does not collide with anybody's browser work.
26//
27// bash dev/world.sh 5 --down # this file starts its own server
28// node dev/verify_relay_e2e.mjs
29// DAIMOND_RELAY_PORT=8920 DAIMOND_RELAY_GW_PORT=9509 node dev/verify_relay_e2e.mjs
30//
31// Those two are the ONLY ports this file honours, and `dev/gate.sh` derives them
32// from the world number so a suite run never collides with the world's own
33// server. See the constants below for what went wrong when it read the world's.
34//
35// A fault injected on purpose, so a check can be shown going red. Each break
36// names the checks it MUST redden, and a run where those stayed green fails --
37// "something went red" is not enough when the run already has a standing failure
38// in it, which this one does (§5). A break credited with catching a defect it
39// never saw is the exact shape `dev/verify_conformance.mjs` was rewritten around.
40//
41// --break=echo the plaintext rides along beside the envelope -> §2 one check
42// --break=anyone any key opens any envelope -> §3 one check
43// --break=noack no collect-and-ack round is run -> §4 TWO checks,
44// and it cannot be otherwise: one behaviour violates both
45// sentences -- the relay was not told, and the relay is still
46// holding it.
47//
48// WHAT THIS FILE DOES NOT PROVE, said here rather than left to be assumed:
49//
50// * Nothing about the seal's strength. §1 of `verify_post.mjs` owns that, with
51// no server in the path, which is where it belongs. What is asserted here is
52// that the SAME seal survives a round trip through the relay -- a different
53// claim, and the one nobody had made.
54// * Nothing about parking or the doorbell. Those are `verify_post.mjs` §7 and
55// `verify_doorbell.mjs`, both against a routed browser rather than a gateway.
56// * Nothing about production. This is loopback with `dev_insecure` on and the
57// beta opened, which is what `dev/gwbin.mjs` builds for every gateway-driving
58// verifier in the tree.
59
60import fs from 'node:fs';
61import os from 'node:os';
62import path from 'node:path';
63import { spawn } from 'node:child_process';
64import { fileURLToPath } from 'node:url';
65import { requireFreshGateway, GWBIN, GWDIR, openBeta, procLog } from './gwbin.mjs';
66
67// `harness.mjs` reads the app's URL into a module CONST at import time, so this
68// file cannot set `DAIMOND_APP` in its own body and be believed: an `import`
69// statement is evaluated before any of it runs. The first draft did exactly that
70// and every browser went to :8777 -- world 0's server, proxying to a gateway on
71// :9002 that this run never started -- while the refusal check above happily
72// confirmed :8782 was free. The whole run then measured the wrong relay and said
73// so only in one URL, in one passing line's detail. So the harness is imported
74// BELOW, dynamically, after the environment it reads is set.
75let open, errors;
76
77const HERE = path.dirname(fileURLToPath(import.meta.url));
78const ROOT = path.join(HERE, '..');
79
80// 9502: clear of the dev servers (8777+N), the mock providers (9099+N), the
81// shared gateway (9002) and verify_passcode's own (9420). A "spare" port inside
82// one of those ranges answers a health probe with somebody else's process.
83//
84// DELIBERATELY NOT `DAIMOND_PORT` OR `DAIMOND_GW_PORT`, the same rule and for the
85// same reason as dev/verify_redeem.mjs. This file starts a dev server of its own
86// pointed at a gateway of its own, and `dev/run_all.sh` runs inside a world that
87// has already exported `DAIMOND_PORT` for the server everything else shares.
88// Reading it made this verifier try to seize the suite's own port, find it held,
89// and refuse -- correctly, and every single time. Under `dev/gate.sh`'s default
90// world 9 that port is 8786, and the whole of this file's output on the
91// 2026-08-17 gate was one refusal naming two variables it had not been given,
92// while the same file passes 32 checks standalone. A collision by construction,
93// not a flake. So the knobs are its own, and the world it runs in cannot reach in
94// and move them; `gate.sh` derives them from the world number.
95const GW_PORT = Number(process.env.DAIMOND_RELAY_GW_PORT || 9502);
96const GW_URL = `http://127.0.0.1:${GW_PORT}`;
97const APP_PORT = Number(process.env.DAIMOND_RELAY_PORT || 8782); // world 5
98const APP_URL = `http://localhost:${APP_PORT}`;
99const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
100const WORK = path.join(SCRATCH, 'verify_relay_e2e-gw');
101const GW_LOG = procLog('verify_relay_e2e');
102const SRV_LOG = procLog('verify_relay_e2e', 'server');
103
104const BREAK = (process.argv.find(a => a.startsWith('--break=')) || '').slice(8);
105/// What each break must turn red, by the leading words of the check's name.
106///
107/// Named rather than counted. This file has a standing failure in §5 -- a real
108/// defect in the app, reported below -- so a run with ANY break already ends with
109/// something red, and "the run failed" would credit every break with catching it.
110const AIMS = {
111 echo: ['and the words are nowhere'],
112 anyone: ['a third identity holding the same bytes'],
113 noack: ['the relay is told it may let go', 'and the relay is then holding nothing'],
114};
115if (BREAK && !(BREAK in AIMS)) {
116 console.log(` --break=${BREAK} is not one of: ${Object.keys(AIMS).join(', ')}`);
117 process.exit(2);
118}
119
120const ok = [], bad = [];
121/// Record a check. `detail` is the evidence and is printed either way; `why` is
122/// what went wrong and is printed only when it did, so a passing line can never
123/// be read as a failure.
124const check = (name, pass, detail, why) => {
125 (pass ? ok : bad).push(name);
126 const tail = pass ? (detail ? ' — ' + detail : '')
127 : ' — ' + [why, detail].filter(Boolean).join(' · ');
128 console.log((pass ? ' ok ' : ' FAIL ') + name + tail);
129};
130const sleep = ms => new Promise(r => setTimeout(r, ms));
131
132/// Poll until `fn` answers true, or give up. Returns whether it did.
133async function waitFor(fn, ms = 12000, gap = 150) {
134 const t0 = Date.now();
135 for (;;) {
136 try { if (await fn()) return true; } catch (e) { /* not up yet */ }
137 if (Date.now() - t0 > ms) return false;
138 await sleep(gap);
139 }
140}
141
142/// Is anything listening on a loopback port?
143async function held(port) {
144 return await waitFor(async () => {
145 const r = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
146 return !!r;
147 }, 600, 200);
148}
149
150
151// ┌───────────────────────────────────────────────────────────────────────────┐
152// │ THIS RUN'S OWN GATEWAY AND ITS OWN FRONT DOOR │
153// └───────────────────────────────────────────────────────────────────────────┘
154
155/// Build the gateway's working directory: the deployed config with the port
156/// changed and the beta opened, the real signing keys symlinked in, and an empty
157/// store.
158///
159/// The database key is NOT symlinked. The store here is new, so its at-rest key
160/// must be new too; pointing a fresh store at the live key either fails or --
161/// far worse -- succeeds against the live store.
162function buildWorkDir() {
163 fs.rmSync(WORK, { recursive: true, force: true });
164 fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true });
165 for (const k of ['licence', 'stripe', 'openrouter']) {
166 const from = path.join(GWDIR, 'keys', k);
167 if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k));
168 }
169 let cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8')
170 .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${GW_PORT})`);
171 if (!cfg.includes(`(u16|${GW_PORT})`)) {
172 console.log(' FAIL could not set the listen port in the copied app.jdat — '
173 + 'has its shape changed? A gateway left on the deployed port would be '
174 + 'measured instead of this one.');
175 process.exit(1);
176 }
177 // Registration must be open: both browsers mint a fresh keypair, and the
178 // deployed config answers a fresh keypair `403 the beta is closed`, which
179 // would leave this run measuring a shut door.
180 cfg = openBeta(cfg, 'verify_relay_e2e');
181 fs.writeFileSync(path.join(WORK, 'app.jdat'), cfg);
182 return WORK;
183}
184
185let gw = null, srv = null;
186
187/// Stop everything this run started, and only what this run started.
188///
189/// The browser profiles go too. `harness.mjs` names one `pw/<name>-<pid>` and
190/// leaves it on disk, which is right for a fixed profile a run means to reuse and
191/// pure litter for a pid-named one: three browsers a run, and ten runs of this
192/// file in one afternoon left thirty directories in a `pw/` already holding 1.2 GB
193/// across a hundred. Removed by exact name rather than by pattern, so a run cannot
194/// delete another agent's profile while they are driving it.
195function cleanup() {
196 for (const p of [gw, srv]) { try { if (p) p.kill(); } catch (e) { /* already gone */ } }
197 gw = null; srv = null;
198 for (const n of ['relay-a', 'relay-b', 'relay-c']) {
199 try { fs.rmSync(path.join(SCRATCH, 'pw', `${n}-${process.pid}`), { recursive: true, force: true }); }
200 catch (e) { /* never made, or already gone */ }
201 }
202}
203process.on('exit', cleanup);
204for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { cleanup(); process.exit(1); });
205
206
207// ┌───────────────────────────────────────────────────────────────────────────┐
208// │ A DEVICE │
209// └───────────────────────────────────────────────────────────────────────────┘
210
211/// Bring up one browser on its own profile, signed in, with an identity, a
212/// sealing key and a card -- and an account on THIS run's gateway.
213///
214/// `connect: false` because the mock LLM provider has nothing to do with the
215/// relay, and asking for one would make this file fail when a mock is not up.
216/// Nothing is injected: post.js, trust.js and group.js are asserted in §0 and
217/// come off the page as the browser assembled it.
218async function device(name) {
219 const s = await open({ name, signIn: true, connect: false });
220 // Every request the page makes, with its body, so §3 can read what actually
221 // left the browser rather than what the client says it sent.
222 s.seen = [];
223 s.page.on('request', r => {
224 let body = '';
225 try { body = r.postData() || ''; } catch (e) { /* not a body we can read */ }
226 s.seen.push({ url: r.url(), method: r.method(), body });
227 });
228 await s.page.waitForFunction(
229 () => !!window.DaimondPost && !!window.DaimondTrust && !!window.DaimondGroup
230 && !!window.DaimondIdentity && !!window.DaimondGateway,
231 null, { timeout: 20000 },
232 ).catch(() => { throw new Error(
233 `${name}: the page did not assemble — post.js, trust.js or group.js is missing `
234 + 'from www/index.html. Nothing here injects them; see §0.'); });
235 await s.page.evaluate(async () => {
236 await window.DaimondIdentity.ensureSealingKey();
237 await window.DaimondIdentity.mintCard();
238 });
239 // The account is taken through the app's own bootstrap, which is the round a
240 // real device makes, rather than by posting to /api/account from here.
241 const authed = await s.page.evaluate(async () => {
242 try { await window.DaimondGateway.bootstrap(); } catch (e) { /* read the state */ }
243 return window.DaimondGateway.state().authed === true;
244 });
245 s.authed = authed;
246 // BOTH SPELLINGS, because the app uses both and they are not interchangeable:
247 // `DaimondPost.send({to})` takes base64url (it is the account key the relay
248 // looks up), and `DaimondGroup.create(name, keys)` takes lower-case hex.
249 //
250 // This comment used to say the wrong spelling was dropped silently, because it
251 // was: `if (!isHex(k, 32) || seen[k]) continue;`, with neither case added to
252 // `missing`, so the first run of this file made a group of one and could not
253 // tell. FIXED at www/js/group.js:653 -- a key that is not a key is carried out
254 // in `bad` and named. The note is corrected rather than left standing: a
255 // comment that outlives its defect tells the next reader a gap is open, which
256 // is the species this lane's own audit caught in share.js:133.
257 s.pub = await s.page.evaluate(() => window.DaimondIdentity.publicKeyB64url());
258 s.hex = await s.page.evaluate(async () => {
259 const raw = await window.DaimondIdentity.publicKeyRaw();
260 return Array.from(raw).map(b => (b + 256).toString(16).slice(1)).join('');
261 });
262 return s;
263}
264
265/// The one place a plaintext word is chosen, so §3 and §4 cannot drift apart.
266const WORDS = 'pelican semaphore ' + Math.random().toString(36).slice(2, 10);
267const GWORDS = 'thimble cartography ' + Math.random().toString(36).slice(2, 10);
268
269
270(async () => {
271 // The gateway must be the one the code under test describes. This refuses on
272 // a stale binary, which is the whole reason `dev/gwbin.mjs` exists: a
273 // verifier measuring a build nobody is shipping produces numbers that are
274 // harder to disbelieve than an absent result.
275 requireFreshGateway();
276
277 if (await held(GW_PORT)) {
278 console.log(` FAIL something is already answering on :${GW_PORT}. This run needs its `
279 + 'OWN gateway with an EMPTY store — it counts rows in a postbox, and counting '
280 + 'them in somebody else\'s store would measure their afternoon. Set '
281 + 'DAIMOND_RELAY_GW_PORT to a free port.');
282 process.exit(1);
283 }
284 if (await held(APP_PORT)) {
285 console.log(` FAIL something is already serving on :${APP_PORT}. This run starts its own `
286 + `dev server so it can point /api at :${GW_PORT}; a server started by `
287 + `dev/world.sh proxies to that world's OWN gateway port instead, and the `
288 + `browser would then be `
289 + 'talking to a gateway this file did not start. Run `bash dev/world.sh 5 --down` '
290 + 'first, or set DAIMOND_RELAY_PORT to a free port.');
291 process.exit(1);
292 }
293
294 const cwd = buildWorkDir();
295 gw = spawn(GWBIN, [], { cwd, env: { ...process.env, APP_MODE: 'sandbox' }, stdio: GW_LOG.stdio });
296 const gwUp = await waitFor(async () => (await fetch(`${GW_URL}/api/health`)).ok);
297 check('this run\'s own gateway is up, on an empty store', gwUp, `${GW_URL} — ${cwd}`);
298 if (!gwUp) { GW_LOG.report(); cleanup(); process.exit(1); }
299
300 srv = spawn(process.execPath, [path.join(HERE, 'serve.mjs')], {
301 cwd: ROOT,
302 env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(GW_PORT) },
303 stdio: SRV_LOG.stdio,
304 });
305 const srvUp = await waitFor(async () => (await fetch(`${APP_URL}/index.html`)).ok);
306 check('and its own front door, proxying /api to it', srvUp, `${APP_URL} → :${GW_PORT}`);
307 if (!srvUp) { SRV_LOG.report(); cleanup(); process.exit(1); }
308
309 // The browsers must reach the app through THIS server, whatever the ambient
310 // world variables say. The harness is loaded HERE, after the variable is set,
311 // because it reads it once at import time -- see the note beside the import.
312 process.env.DAIMOND_APP = APP_URL;
313 ({ open, errors } = await import('./harness.mjs'));
314
315 let A = null, B = null, C = null;
316 try {
317 // ── 0. The seams are in the app ────────────────────────────────
318 console.log('\n0. the client is the shipped one, and the relay is this run\'s');
319 const html = fs.readFileSync(path.join(ROOT, 'www', 'index.html'), 'utf8');
320 for (const f of ['post', 'trust', 'group']) {
321 check(`www/index.html carries a script tag for js/${f}.js`,
322 new RegExp('<script[^>]+src=["\']js/' + f + '\\.js["\']').test(html),
323 '', 'nothing here injects it, so every section below would be untestable');
324 }
325 // The front door really is proxying, and to the gateway this run started.
326 // A dev server whose proxy target were 9002 would answer the same shape
327 // from a DIFFERENT process, which is exactly the confusion being closed.
328 const viaDoor = await fetch(`${APP_URL}/api/health`);
329 const direct = await fetch(`${GW_URL}/api/health`);
330 check('/api through the front door reaches this run\'s gateway',
331 viaDoor.ok && direct.ok && (await viaDoor.text()) === (await direct.text()),
332 `door ${viaDoor.status}, direct ${direct.status}`);
333
334 A = await device('relay-a');
335 B = await device('relay-b');
336 C = await device('relay-c'); // the third identity, who must never open it
337 check('three devices, three identities, three accounts on ONE relay',
338 A.authed && B.authed && C.authed,
339 `A ${A.authed} B ${B.authed} C ${C.authed}`,
340 'a device with no gateway session cannot send or collect');
341 check('and the three keys really are different',
342 A.pub && B.pub && C.pub && new Set([A.pub, B.pub, C.pub]).size === 3);
343
344 // ── 1. First contact, carried by this file and by nothing else ──
345 console.log('\n1. first contact — the cards cross the table, not the network');
346 const cardOf = s => s.page.evaluate(() => window.DaimondTrust.cardText());
347 const readCard = (s, text) => s.page.evaluate(async (t) => {
348 const card = window.DaimondTrust.parse(t);
349 if (!card) return null;
350 await window.DaimondTrust.record(card, window.DaimondTrust.ROUTE.QR);
351 await window.DaimondPost.refreshPeople();
352 return { key: card.key };
353 }, text);
354
355 const [aCard, bCard] = [await cardOf(A), await cardOf(B)];
356 const aSees = await readCard(A, bCard);
357 const bSees = await readCard(B, aCard);
358 check('each device read the other\'s card', !!aSees && !!bSees);
359 // The safety numbers are computed on each device and compared HERE.
360 const aNum = await A.page.evaluate(() => window.DaimondIdentity.publicKeyB64url());
361 check('and each now holds a sealing key for the other',
362 await A.page.evaluate(p => (window.DaimondPost.people() || [])
363 .some(x => x.pub === p), B.pub)
364 && await B.page.evaluate(p => (window.DaimondPost.people() || [])
365 .some(x => x.pub === p), A.pub),
366 '', 'without a card there is no key to seal to and §2 would test nothing');
367
368 // ── 2. A message, through the real relay ───────────────────────
369 console.log('\n2. A seals, the gateway carries, B opens');
370 // STOP B'S BACKGROUND AUTO-COLLECT for this raw-delivery measurement. The
371 // errand listener (daimond.js `startErrandListener`) opens a long-poll park
372 // on unlock; when A's message lands, that park's own `round()` collects and
373 // FILES it before B's manual `collect()` below is ever called -- so the
374 // manual collect, the path a person's browser takes, would find the box
375 // already emptied (got=0) and `takeRow` would dedup the re-collect to
376 // nothing. Parking is the peer-errand door, not this person-to-person
377 // delivery path. Stopped BEFORE A sends, so nothing collects the row ahead of
378 // the manual collect. This does not weaken the check: if delivery or filing
379 // actually broke, the manual collect still returns got=0 (or files the wrong
380 // body) and the two assertions below still go red.
381 await B.page.evaluate(() => { try { window.DaimondPost.parkStop(); } catch (e) {} });
382 A.seen.length = 0;
383 const sent = await A.page.evaluate(async ({ to, body }) => {
384 const r = await window.DaimondPost.send({ to, body });
385 return { ok: r.ok === true, why: r.why || '', addr: r.addr || '' };
386 }, { to: B.pub, body: WORDS });
387 check('A\'s send is accepted by the relay', sent.ok, sent.addr, sent.why);
388
389 // What actually left the browser. The plaintext must be in NONE of it:
390 // a client that sent the words alongside the envelope would pass every
391 // open-it-again check in this file and every one in verify_post.
392 const posts = A.seen.filter(r => r.method === 'POST' && /\/api\/post(\?|$)/.test(r.url));
393 check('exactly one POST /api/post left the browser', posts.length === 1,
394 `${posts.length} — ` + posts.map(p => p.url.replace(APP_URL, '')).join(' '));
395 const wire = posts.map(p => p.url + ' ' + p.body).join('\n');
396 const leaked = BREAK === 'echo' ? wire + ' ' + WORDS : wire;
397 check('and the words are nowhere in what it sent',
398 !leaked.includes(WORDS) && !leaked.includes(WORDS.split(' ')[0]),
399 `${wire.length} bytes on the wire`,
400 'the plaintext rode along beside the envelope');
401
402 // WHAT THE RELAY ITSELF IS HOLDING, read raw rather than through the
403 // client's bookkeeping. `DaimondPost.collect()` answers
404 // `{ok, got, notes, unreadable, more}` and CONSUMES what it reads -- it
405 // advances `through`, so a second call answers zero and a check that
406 // counted rows off it would report an empty box for a message that had
407 // arrived perfectly. So the box is read with a bare GET, which is what the
408 // client's own `call('GET')` makes, and the client's collect is measured
409 // separately by what it returns and what it stores.
410 const boxOf = (s) => s.page.evaluate(async () => {
411 const r = await fetch('/api/post?since=0', {
412 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
413 });
414 const j = await r.json().catch(() => null);
415 return { status: r.status, rows: (j && j.rows) || [], seq: (j && j.seq) || 0 };
416 });
417
418 const bBox = await boxOf(B);
419 check('the relay is holding exactly one row for B', bBox.rows.length === 1,
420 `status=${bBox.status} rows=${bBox.rows.length} seq=${bBox.seq}`,
421 'the envelope did not reach the recipient\'s postbox');
422 const cBox = await boxOf(C);
423 check('and nothing at all for C', cBox.rows.length === 0, `rows=${cBox.rows.length}`);
424
425 // ── 3. B opens it, C cannot ────────────────────────────────────
426 console.log('\n3. the seal survives the round trip');
427 const row = bBox.rows[0] || null;
428 check('the row the relay handed back names the sender and carries an envelope',
429 !!row && row.from_pub === A.pub && row.kind === 'post'
430 && typeof row.envelope === 'string' && row.envelope.length > 0,
431 row ? `kind=${row.kind} from=${String(row.from_pub).slice(0, 10)}…` : 'no row');
432
433 // Through the client's own collect, which is the path a person's browser
434 // takes: it reads the box, opens what it finds and files it.
435 const took = await B.page.evaluate(() => window.DaimondPost.collect());
436 check('B\'s own collect takes exactly one message off the relay',
437 took.ok === true && took.got === 1,
438 `got=${took.got} notes=${took.notes} unreadable=${took.unreadable}`,
439 took.why || 'the client did not file the message it was handed');
440 const filed = await B.page.evaluate(async (from) => {
441 const st = await window.DaimondPost.read();
442 const msgs = (st && st.msgs) || {};
443 const k = Object.keys(msgs).find(k => msgs[k].dir === 'in' && msgs[k].from === from);
444 return k ? { body: msgs[k].body, read: msgs[k].read, seq: msgs[k].seq } : null;
445 }, A.pub);
446 check('and what it filed is the exact words A typed',
447 !!filed && filed.body === WORDS,
448 filed ? JSON.stringify(filed.body).slice(0, 60) : 'nothing was filed',
449 'the message did not survive the relay');
450
451 // The negative that makes the positive mean something. C holds every
452 // module B holds and a perfectly good identity; what C does not hold is
453 // the key, and the relay cannot supply one.
454 const byC = row ? await C.page.evaluate(async (r) => {
455 try {
456 const got = await window.DaimondPost.open(r.envelope, r.addr);
457 return { opened: true, body: (got && got.post && got.post.body) || '' };
458 } catch (e) { return { opened: false, why: String((e && e.message) || e) }; }
459 }, row) : { opened: false, why: 'no row' };
460 const cOpened = BREAK === 'anyone' ? { opened: true, body: WORDS } : byC;
461 check('a third identity holding the same bytes cannot open it',
462 cOpened.opened !== true, cOpened.opened ? 'C READ IT: ' + cOpened.body : cOpened.why,
463 'the seal is decoration');
464
465 // ── 4. The ack, and what it does to the box ────────────────────
466 console.log('\n4. ack — the relay lets go only after the message is safe');
467 // A collect is not a commit. Until something is acked the relay keeps the
468 // row, so that a device which read a message and then lost it has not lost
469 // the only copy. Asserted BEFORE the ack, because "the box is empty at the
470 // end" is equally true of a relay that dropped it on the collect.
471 const stillThere = await boxOf(B);
472 check('a collect on its own does not release the row', stillThere.rows.length === 1,
473 `rows=${stillThere.rows.length}`,
474 'the relay let go of a message before being told it was safe elsewhere');
475
476 // THROUGH `round()`, which is collect-then-ack and is what the app itself
477 // calls -- on a park wake (post.js:1626) and on a panel refresh (:2072).
478 // Calling `ack()` once instead measured a transient and flapped: `entitled`
479 // in sync.js starts OPTIMISTICALLY TRUE and is only cleared by a 402 seen on
480 // a push, so a free account's first ack takes the committed-parcel path,
481 // the gateway refuses the push 402 (gateway/src/handlers/sync.rs:297), the
482 // version does not move and `ackThrough` answers `not_committed`. The second
483 // round has `entitled` false and solo-acks. It self-heals and it fails in the
484 // safe direction -- the relay KEEPS the row -- so this walks the app's path
485 // and REPORTS the number of rounds rather than hiding it in a retry loop. A
486 // count above one is information, and a count that never releases is a wedge.
487 const rounds = [];
488 if (BREAK !== 'noack') {
489 for (let i = 0; i < 3; i++) {
490 const r = await B.page.evaluate(() => window.DaimondPost.round()
491 .then(x => ({ acked: (x && x.acked) | 0, why: (x && x.why) || '' }))
492 .catch(e => ({ acked: 0, why: String(e && e.message || e) })));
493 rounds.push(r);
494 if (r.acked >= 1) break;
495 }
496 }
497 const released = rounds.some(r => r.acked >= 1);
498 check('the relay is told it may let go, by the app\'s own collect-and-ack round',
499 released,
500 `${rounds.length} round(s): ` + rounds.map(r => `acked=${r.acked}${r.why ? '/' + r.why : ''}`).join(' → '),
501 BREAK === 'noack' ? 'no round was run' : 'the relay was never told it could let go');
502 const after = await boxOf(B);
503 check('and the relay is then holding nothing', after.rows.length === 0,
504 `rows=${after.rows.length}`,
505 'the relay is still holding a message it has been told is safe');
506
507 // ── 5. A group, through the same relay ─────────────────────────
508 console.log('\n5. one group message, one envelope, delivered per member');
509 const made = await A.page.evaluate(async (keys) => {
510 const r = await window.DaimondGroup.create('relay e2e', keys);
511 return { ok: r.ok === true, gid: r.gid || '', sent: r.sent | 0,
512 members: r.members | 0, why: r.why || '' };
513 }, [B.hex]);
514 check('A makes a group naming B, and the roster reaches B',
515 made.ok && !!made.gid && made.members === 2 && made.sent === 1,
516 `members=${made.members} sent=${made.sent} ${String(made.gid).slice(0, 12)}…`,
517 made.why || 'the roster named fewer people than it was given');
518
519 // THE CREATOR'S OWN STATE. `create` applies its own roster through
520 // `consume`, which files an unknown group as `invited` -- so the creator
521 // comes out of `create` NOT joined, and `sealTo` refuses them with "Join
522 // this group before writing to it." Nothing in the app then calls `join`:
523 // the panel's Make button (www/js/group.js, the `group-make` branch) says
524 // "Made, and N people have been told" and stops. `dev/verify_group.mjs`
525 // only passes because IT calls `DaimondGroup.join(gid)` on the creator's
526 // own page straight after `create` -- a line the app does not have.
527 const canWrite = made.gid ? await A.page.evaluate(async ({ gid, body }) => {
528 const r = await window.DaimondPost.send({ group: gid, body });
529 return { ok: r.ok === true, sent: r.sent | 0, why: r.why || '' };
530 }, { gid: made.gid, body: GWORDS }) : { ok: false, why: 'no group' };
531 check('A can write to the group A just made', canWrite.ok, `sent=${canWrite.sent}`,
532 canWrite.why + ' — the creator is left `invited` in their own group; '
533 + 'www/js/group.js\'s create() never joins and neither does the panel');
534
535 // Joined by hand so the REST of this section still measures the wire. The
536 // step above is the app's to make and its absence is reported, not papered
537 // over: this line is the workaround and is labelled as one.
538 if (made.gid && !canWrite.ok) {
539 await A.page.evaluate(g => window.DaimondGroup.join(g), made.gid);
540 }
541 const gsent = made.gid ? (canWrite.ok ? canWrite
542 : await A.page.evaluate(async ({ gid, body }) => {
543 const r = await window.DaimondPost.send({ group: gid, body });
544 return { ok: r.ok === true, sent: r.sent | 0, why: r.why || '' };
545 }, { gid: made.gid, body: GWORDS })) : { ok: false, why: 'no group' };
546 check('one group message is sealed once and carried by the relay',
547 gsent.ok && gsent.sent >= 1, `sent=${gsent.sent}`, gsent.why);
548
549 // B has to hear the roster before it can hear the message, and both come
550 // down the same box in the order they were delivered.
551 const bGroup = await B.page.evaluate(async () => {
552 const r = await window.DaimondPost.collect();
553 const st = await window.DaimondPost.read();
554 const msgs = (st && st.msgs) || {};
555 const gs = await window.DaimondGroup.list();
556 return {
557 got: r.got | 0, notes: r.notes | 0,
558 bodies: Object.keys(msgs).map(k => ({ body: msgs[k].body, gid: msgs[k].gid || '' })),
559 groups: gs.map(g => ({ gid: g.gid, state: g.state, n: (g.members || []).length })),
560 };
561 });
562 check('B receives the roster and knows the group',
563 bGroup.groups.some(g => g.gid === made.gid),
564 JSON.stringify(bGroup.groups).slice(0, 120),
565 'the roster did not reach B through the relay');
566 check('B opens the group message, and it is marked as the group\'s',
567 bGroup.bodies.some(x => x.body === GWORDS && x.gid === made.gid),
568 JSON.stringify(bGroup.bodies).slice(0, 160),
569 'a group message drawn as a one-to-one is a wrong sender on screen');
570
571 // C was never in the roster and never held a slot.
572 const cAfter = await boxOf(C);
573 check('C, who is not in the group, was sent nothing at all',
574 cAfter.rows.length === 0, `rows=${cAfter.rows.length}`);
575
576 // ── 6. Arrival ─────────────────────────────────────────────────
577 console.log('\n6. B is told something arrived');
578 const unread = await B.page.evaluate(async () => {
579 const st = await window.DaimondPost.read();
580 if (!st || !st.msgs) return -1;
581 return Object.keys(st.msgs)
582 .filter(k => st.msgs[k].dir === 'in' && !st.msgs[k].read).length;
583 });
584 // Both of them: the one-to-one and the group message. With Web Push
585 // declined this count IS the app's only notification, so a message that
586 // arrived and left the count at zero would arrive invisibly.
587 check('B\'s record holds both arrivals, unread', unread >= 2,
588 `unread=${unread}`,
589 'nothing would draw a badge, and with push declined the badge is the only notice');
590
591 // Console errors the PAGE is responsible for. A failed fetch to a
592 // service this fixture never started is the fixture's, not the app's.
593 for (const s of [A, B, C]) {
594 const thrown = errors(s).filter(e => !/Failed to load resource/.test(e));
595 check(`${s.name} threw nothing`, thrown.length === 0, thrown.slice(0, 2).join(' | '));
596 }
597 } catch (e) {
598 check('the run completed', false, String((e && e.stack) || e));
599 } finally {
600 for (const s of [A, B, C]) { try { if (s) await s.close(); } catch (e) { /* gone */ } }
601 }
602
603 console.log(`\n${ok.length} ok, ${bad.length} failed`);
604 if (bad.length) { GW_LOG.report(); SRV_LOG.report(8); }
605 cleanup();
606 // A `--break` run EXPECTS to fail: it exits 0 when something reddened and 1
607 // when nothing did, so "the break changed nothing" is itself a failing run.
608 if (BREAK) {
609 const missed = AIMS[BREAK].filter(a => !bad.some(n => n.startsWith(a)));
610 console.log(missed.length
611 ? `\n--break=${BREAK}: these stayed GREEN and should not have — ${missed.join('; ')}. `
612 + 'The check aimed at is not testing what it claims.'
613 : `\n--break=${BREAK}: every check it aims at went red, which is the point.`);
614 process.exit(missed.length ? 1 : 0);
615 }
616 process.exit(bad.length ? 1 : 0);
617})();