Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_redeem.mjs

40.9 KiB, 1 run

created by r2519314175:631, 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_redeem.mjs — the beta passcode, from the browser, against a real
2// gateway with the beta really shut.
3//
4// WHAT THIS DEFENDS. The gateway has had `/api/passcode/redeem` and the
5// registration gate in front of `/api/account` for some time, and until now
6// nothing in the browser called either. A stranger was refused and told nothing
7// -- `bootstrap()` wrapped the whole registration in a try/catch and turned a
8// deliberate 403 into `offline`, so a refused person landed in BYOK-only mode
9// believing the app was broken -- and a person HOLDING a code had nowhere to
10// type it. Four properties come out of that, and each one is a way for this to
11// go quietly wrong again:
12//
13// 1. A REFUSAL IS NOT SILENCE. The gate answers with a machine-readable
14// `reason`; the browser must read it and say which refusal it was. A
15// client that reports a 403 as "the account service is unreachable" points
16// the user at their own network for a decision the server took on purpose.
17// 2. THERE IS A DOOR, AND IT IS REACHABLE FROM THE REFUSAL. Not a screen
18// somewhere that a refused person would have to go looking for.
19// 3. AND IT IS STILL THERE LATER. The code usually arrives after the refusal,
20// not with it, so the way in has to survive the dialog being dismissed.
21// 4. A REFUSED CODE SAYS WHICH REFUSAL IT WAS. The gateway distinguishes a
22// code it never issued from one already spent from one that has run out,
23// because those send a person to three different places. A client that
24// collapses them into one friendly sentence throws that away, and the
25// throwing-away is invisible -- everything still "works".
26//
27// And the decisive one, which is the author's own case end to end: a fresh
28// device, a closed gateway, a valid code, and the app ends up SIGNED IN WITH
29// PRO -- the same state an ordinary registration reaches, not a second one.
30//
31// ── The gateway is real, and closed the way production is ───────────
32//
33// The critical section that spends a code IS the thing being relied on, so a
34// stubbed `/api/passcode/redeem` would prove nothing about it. This starts its
35// own gateway on :9412 with an empty store of its own, and closes the beta by
36// setting `beta_only` in the COPIED `app.jdat`'s route configuration -- which is
37// how jarrah is closed as of today, and a different path through
38// `settings::try_bool` from the console override the gateway's own tests drive.
39// Both paths deserve to be exercised and only one of them was.
40//
41// It does NOT touch :9002. Nothing here may go near the shipped gateway.
42//
43// ── How each check is shown red ─────────────────────────────────────
44//
45// `--break <name>` serves a deliberately damaged copy of a source file to the
46// real page, through `page.route`, so the browser loads it as it loads any other
47// script. A break whose anchor does not appear exactly once aborts the run: a
48// check proved against code that was never broken is not proved at all.
49//
50// node dev/verify_redeem.mjs --break blind # the `reason` is not read: the
51// # refusal reads as offline and
52// # nothing is drawn (1, 2, 3)
53// node dev/verify_redeem.mjs --break offline # the reason IS read and still
54// # reported as offline (1)
55// node dev/verify_redeem.mjs --break onewording # every refused code gets one
56// # sentence (4)
57// node dev/verify_redeem.mjs --break nosignin # the code is spent and the app
58// # never signs in (the decisive one)
59// node dev/verify_redeem.mjs --break drawalways # a passcode field is offered to
60// # a device that already has an
61// # account (the "no control that
62// # would refuse" checks)
63// node dev/verify_redeem.mjs --break nohook # the Credits drawer's entry is
64// # gone (3)
65// node dev/verify_redeem.mjs # and then, clean
66//
67// ── Running it ──────────────────────────────────────────────────────
68//
69// cd gateway && env -u CARGO_TARGET_DIR cargo build --release
70// node dev/verify_redeem.mjs
71//
72// It owns world 9 (the app on :8786) and :9412, and starts both itself: the dev
73// server has to be pointed at THIS gateway rather than at :9002, which
74// `dev/world.sh` does not do. Headless, with DISPLAY dropped by the harness.
75import fs from 'node:fs';
76import os from 'node:os';
77import path from 'node:path';
78import crypto from 'node:crypto';
79import { spawn } from 'node:child_process';
80import { fileURLToPath } from 'node:url';
81import { requireFreshGateway, procLog, GWDIR, GWBIN } from './gwbin.mjs';
82
83const HERE = path.dirname(fileURLToPath(import.meta.url));
84const ROOT = path.join(HERE, '..');
85const WWW = path.join(ROOT, 'www');
86
87// ── The world, fixed before anything reads it ───────────────────────
88//
89// harness.mjs reads these at import time, so they are set here and the harness
90// is imported below with `await import`. World 9, deliberately: 3, 5, 7, 11 and
91// 13 are other lanes'.
92//
93// DELIBERATELY NOT `DAIMOND_PORT`. This file starts a dev server of its own,
94// pointed at its own gateway, and `dev/run_all.sh` is run inside a world that
95// has already exported `DAIMOND_PORT` for the server everything else shares.
96// Reading that would make this verifier try to seize the suite's own port,
97// find it held, and refuse -- so the two knobs it honours are its own, and
98// the world it is run in cannot reach in and move them.
99const APP_PORT = Number(process.env.DAIMOND_REDEEM_PORT || 8786);
100const GW_PORT = Number(process.env.DAIMOND_REDEEM_GW_PORT || 9412);
101const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond/w9');
102process.env.DAIMOND_PORT = String(APP_PORT);
103process.env.DAIMOND_APP = `http://localhost:${APP_PORT}`;
104process.env.DAIMOND_GW_PORT = String(GW_PORT);
105process.env.DAIMOND_SCRATCH = SCRATCH;
106
107const GW = `http://127.0.0.1:${GW_PORT}`;
108const APP = process.env.DAIMOND_APP;
109const WORK = path.join(SCRATCH, 'verify_redeem-gw');
110const LOG = procLog('verify_redeem');
111const SRV = procLog('verify_redeem', 'server');
112
113const BREAK = (() => {
114 const i = process.argv.indexOf('--break');
115 if (i > 0) return String(process.argv[i + 1] || '');
116 const eq = process.argv.find(a => a.startsWith('--break='));
117 return eq ? eq.slice(8) : '';
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.
123const check = (name, pass, detail, why) => {
124 (pass ? ok : bad).push(name);
125 const tail = pass ? (detail ? ' — ' + detail : '')
126 : ' — ' + [why, detail].filter(Boolean).join(' · ');
127 console.log((pass ? ' ok ' : ' FAIL ') + name + tail);
128};
129const sleep = ms => new Promise(r => setTimeout(r, ms));
130
131// ── The breaks ───────────────────────────────────────────────────────
132//
133// Real edits to real files, served in place of them. Several edits may name the
134// same file; they are applied together, so a break needing two lines is still
135// one damaged copy rather than two routes fighting over one URL.
136
137const BREAKS = {
138 // The `reason` field is never read, which is exactly the state the client
139 // shipped in: every refusal reduces to "offline" and the app says nothing
140 // anybody can act on.
141 blind: [{
142 file: 'js/gateway.js',
143 find: "\t\t\treason: (j && j.reason) || '',",
144 with: "\t\t\treason: '',",
145 }],
146 // The reason IS read, and the refusal is still reported as a gateway that
147 // could not be reached. Only the half of check 1 that is about `offline`
148 // should fall; the dialog still appears, so this isolates it.
149 offline: [{
150 file: 'js/gateway.js',
151 find: '\t\t\t\t\tstate.offline = false;\n\t\t\t\t\tstate.refused = reg.reason;',
152 with: '\t\t\t\t\tstate.offline = true;\n\t\t\t\t\tstate.refused = reg.reason;',
153 }],
154 // One friendly sentence for every refused code. The redemption still fails
155 // correctly and the user is still told no; what is lost is WHICH no, which
156 // is the whole reason the gateway tells them apart.
157 onewording: [{
158 file: 'js/gateway.js',
159 find: "\t\t\tcase 'spent': return t('beta.err_spent');",
160 with: "\t\t\tcase 'spent': return t('beta.err_unknown');",
161 }],
162 // The code is spent and the app never takes the account it just bought. The
163 // redemption returns 200, the dialog says "you are in", and the app is not.
164 nosignin: [{
165 file: 'js/gateway.js',
166 find: '\t\tvar authed = await bootstrap();',
167 with: '\t\tvar authed = false;',
168 }],
169 // A passcode field wherever the block is drawn, account or no account.
170 drawalways: [{
171 file: 'js/passcode.js',
172 find: '\t\tif (s.authed) return;',
173 with: '\t\tif (s.authed && false) return;',
174 }],
175 // The Credits drawer's entry, and only that. The hook daimond.js calls is
176 // removed AND the standing redraws are, so nothing but the drawer could
177 // fill the block -- and nothing does. The dialog is untouched, so the
178 // refusal checks stay green and check 3 falls alone.
179 // It CASCADES, and that is worth saying rather than leaving to be
180 // discovered: with the drawer's entry gone the dialog cannot be reopened
181 // from it, so the code-typing checks that drive through it have nothing to
182 // type into and fall behind it. What the break isolates is which check falls
183 // FIRST -- the drawer entry -- and that the refusal itself is untouched:
184 // checks 1 and 2 stay green, so the dialog is demonstrably still working
185 // when the drawer stops carrying it.
186 nohook: [
187 {
188 file: 'js/passcode.js',
189 find: '\twindow.DaimondCredits = { render: render };',
190 with: '\twindow.DaimondCredits = null;',
191 },
192 {
193 file: 'js/passcode.js',
194 find: "\t\twindow.addEventListener('daimond:authed', refresh);",
195 with: "\t\tvoid 0;",
196 },
197 {
198 file: 'js/passcode.js',
199 find: '\t\trefresh();\n\t}\n\n\t// ── Public surface',
200 with: '\t}\n\n\t// ── Public surface',
201 },
202 {
203 file: 'js/passcode.js',
204 find: '\t\trefresh();\n\t\tif (!reason || !canSign()) return;',
205 with: '\t\tif (!reason || !canSign()) return;',
206 },
207 {
208 file: 'js/passcode.js',
209 find: '\t\t\tsetTimeout(refresh, 0);',
210 with: '\t\t\tvoid 0;',
211 },
212 ],
213};
214
215if (BREAK && !BREAKS[BREAK]) {
216 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
217 process.exit(2);
218}
219
220/// Every file a break touches, each with all of its edits applied, or a hard
221/// stop. An anchor that does not appear exactly once means nothing was broken
222/// and the run below would prove the opposite of what it claims.
223function damagedFiles() {
224 const byFile = new Map();
225 for (const spec of BREAKS[BREAK]) {
226 if (!byFile.has(spec.file)) byFile.set(spec.file, fs.readFileSync(path.join(WWW, spec.file), 'utf8'));
227 const src = byFile.get(spec.file);
228 const n = src.split(spec.find).length - 1;
229 if (n !== 1) {
230 console.error(`break '${BREAK}': an anchor appears ${n} times in ${spec.file}, `
231 + 'so nothing was broken and the run below would prove nothing.');
232 process.exit(2);
233 }
234 byFile.set(spec.file, src.replace(spec.find, spec.with));
235 }
236 return byFile;
237}
238
239// ── A gateway of its own, closed the way production is ───────────────
240
241/// Build the working directory, and hand back its path.
242///
243/// `closed` writes `beta_only` into the `/api/account` route's configuration --
244/// the same place jarrah's `app.jdat` now carries it. The gateway's own tests
245/// close the beta through a console override in the store; this exercises the
246/// other half of `settings::try_bool`, which is the half production runs on.
247function buildWorkDir(closed) {
248 fs.rmSync(WORK, { recursive: true, force: true });
249 fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true });
250 // Every key EXCEPT the database's: the store is new, so its at-rest key must
251 // be new too. The licence key matters here -- a gifted Pro has to be signed
252 // with the same key a bought one is, or the client would be believing
253 // something this run invented.
254 for (const k of ['licence', 'stripe', 'openrouter']) {
255 const from = path.join(GWDIR, 'keys', k);
256 if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k));
257 }
258 writeConfig(closed);
259 return WORK;
260}
261
262/// Write `app.jdat` into the working directory with the port moved and the beta
263/// open or shut. Called twice: the owner account has to be minted while the door
264/// is open, because a closed gateway refuses it too.
265function writeConfig(closed) {
266 let cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8')
267 .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${GW_PORT})`);
268 if (!cfg.includes(`(u16|${GW_PORT})`)) {
269 console.log(' FAIL could not set the listen port in the copied app.jdat — '
270 + 'has its shape changed?');
271 process.exit(1);
272 }
273 // Asserted on the KEY being there, not on the text changing. The open pass
274 // writes the value the shipped file already carries, so "nothing moved" is
275 // the correct outcome there and treating it as a failure stopped the first
276 // run of this file before it had started.
277 const key = /"beta_only":\s*"(true|false)"/g;
278 const found = (cfg.match(key) || []).length;
279 if (found !== 1) {
280 console.log(` FAIL the /api/account route in app.jdat carries beta_only ${found} times, `
281 + 'so this run could not close the beta. The gate is the whole subject of this file '
282 + 'and a run that could not set it would measure nothing.');
283 process.exit(1);
284 }
285 const want = `"beta_only": "${closed ? 'true' : 'false'}"`;
286 const next = cfg.replace(/"beta_only":\s*"(true|false)"/, want);
287 if (!next.includes(want)) {
288 console.log(' FAIL could not set beta_only in the copied app.jdat.');
289 process.exit(1);
290 }
291 fs.writeFileSync(path.join(WORK, 'app.jdat'), next);
292}
293
294// ── Device identities, exactly as the app builds them ───────────────
295
296/// A fresh device keypair, and the two things the gateway asks of it. Used for
297/// the owner account and the gatecrasher; the DEVICE UNDER TEST is the browser's
298/// own, minted by identity.js.
299function device() {
300 const kp = crypto.generateKeyPairSync('ed25519');
301 const raw = kp.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
302 const b64url = b => b.toString('base64')
303 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
304 return {
305 pub: b64url(raw),
306 alg: 'Ed25519',
307 sign: s => crypto.sign(null, Buffer.from(s, 'utf8'), kp.privateKey).toString('base64'),
308 };
309}
310
311/// The binding proof `/api/account` and `/api/passcode/redeem` both demand.
312function binding(dev) {
313 const ts = Math.floor(Date.now() / 1000);
314 return { pubkey: dev.pub, alg: dev.alg, ts, sig: dev.sign(`daimond-gw-account:v1:${dev.pub}:${ts}`) };
315}
316
317/// One HTTP call to the gateway, carrying a cookie jar this file owns.
318async function call(jar, method, url, body, xff) {
319 const headers = { 'x-daimond-api': '1' };
320 if (jar && jar.cookie) headers.cookie = jar.cookie;
321 if (body !== undefined) headers['content-type'] = 'application/json';
322 // Talking to the gateway directly there is no Steel in front to append one,
323 // so every unnamed request shares the single "unknown" bucket -- which is
324 // the bucket the BROWSER is in, through the dev server's proxy. Anything
325 // here that could be refused names an address of its own, so it cannot spend
326 // the browser's attempts.
327 if (xff) headers['x-forwarded-for'] = xff;
328 const r = await fetch(GW + url, {
329 method, headers,
330 body: body === undefined ? undefined : JSON.stringify(body),
331 });
332 const set = r.headers.getSetCookie ? r.headers.getSetCookie() : [];
333 if (jar && set.length) jar.cookie = set.map(c => c.split(';')[0]).join('; ');
334 let j = null;
335 try { j = await r.json(); } catch (e) {}
336 return { status: r.status, j };
337}
338
339/// Prove possession of the key and take a session cookie.
340async function session(jar, dev) {
341 const ch = await call(jar, 'POST', '/api/auth/challenge', { pubkey: dev.pub, alg: dev.alg });
342 if (!ch.j || !ch.j.challenge) return false;
343 const v = await call(jar, 'POST', '/api/auth/verify', {
344 challenge_id: ch.j.challenge_id,
345 sig: dev.sign(ch.j.challenge),
346 });
347 return v.status === 200;
348}
349
350const procs = [];
351function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
352
353async function waitFor(fn, ms = 20000, gap = 250) {
354 const t0 = Date.now();
355 for (;;) {
356 try { if (await fn()) return true; } catch (e) {}
357 if (Date.now() - t0 > ms) return false;
358 await sleep(gap);
359 }
360}
361
362/// Start the gateway in the working directory, and wait for it to serve.
363///
364/// Generous, because an empty o3db spends twenty-odd seconds initialising its
365/// zones before anything listens.
366async function startGateway(cwd, ownerAccount) {
367 const gw = spawn(GWBIN, [], {
368 cwd,
369 env: {
370 ...process.env,
371 APP_MODE: 'sandbox',
372 ...(ownerAccount ? { DAIMOND_OWNER_ACCOUNTS: ownerAccount } : {}),
373 },
374 stdio: LOG.stdio,
375 });
376 procs.push(gw);
377 const up = await waitFor(async () => (await fetch(`${GW}/api/health`)).ok, 120000);
378 return { gw, up };
379}
380
381/// Stop it, and wait for the PORT to be free rather than for the process to be
382/// signalled: a second gateway started on the strength of a `kill` returning
383/// meets `AddrInUse` and dies, and the run that follows measures nothing.
384async function stopGateway(gw) {
385 try { gw.kill('SIGKILL'); } catch (e) {}
386 await waitFor(async () => {
387 try { await fetch(`${GW}/api/health`); return false; }
388 catch (e) { return true; }
389 }, 30000, 200);
390 await sleep(500);
391}
392
393/// The dev server, pointed at THIS gateway rather than at :9002.
394async function startServer() {
395 const srv = spawn(process.execPath, [path.join(HERE, 'serve.mjs')], {
396 cwd: ROOT,
397 env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(GW_PORT) },
398 stdio: SRV.stdio,
399 });
400 procs.push(srv);
401 return await waitFor(async () => (await fetch(APP + '/index.html')).ok, 20000);
402}
403
404(async () => {
405 requireFreshGateway();
406
407 for (const [what, url] of [['gateway', `${GW}/api/health`], ['app server', APP + '/index.html']]) {
408 let stray = false;
409 try { stray = (await fetch(url)).ok; } catch (e) {}
410 if (stray) {
411 console.log(` FAIL something is already answering as the ${what} on ${url}. This `
412 + 'suite starts both itself, pins an owner and closes the beta in configuration, '
413 + 'so it cannot share either. Free the port, or set DAIMOND_REDEEM_PORT / '
414 + 'DAIMOND_REDEEM_GW_PORT.');
415 process.exit(1);
416 }
417 }
418
419 // ── The gateway: open, then shut ────────────────────────────
420 //
421 // The owner has to exist before the process that pins them, and before the
422 // door closes -- a closed gateway refuses the owner's own registration just
423 // as it refuses everyone else's. So: open, mint the owner, close, restart.
424 const cwd = buildWorkDir(false);
425 let started = await startGateway(cwd, null);
426 check('gateway starts', started.up);
427 if (!started.up) { LOG.report(); cleanup(); process.exit(1); }
428
429 const bossDev = device();
430 const boss = { dev: bossDev, jar: {} };
431 const made = await call(boss.jar, 'POST', '/api/account', binding(bossDev));
432 boss.id = made.j && made.j.account_id;
433 check('an account to be the owner, made while the beta is still open', !!boss.id, boss.id);
434 if (!boss.id) { LOG.report(); cleanup(); process.exit(1); }
435
436 await stopGateway(started.gw);
437 writeConfig(true);
438 started = await startGateway(cwd, boss.id);
439 check('gateway restarts with the beta CLOSED in app.jdat and that account as owner',
440 started.up);
441 if (!started.up) { LOG.report(); cleanup(); process.exit(1); }
442 await session(boss.jar, bossDev);
443 const who = await call(boss.jar, 'GET', '/api/admin?view=whoami');
444 check('the console recognises the owner', who.j && who.j.role === 'owner',
445 JSON.stringify(who.j));
446
447 // BUILD THE LISTING INDEXES ONCE, because the gateway no longer builds them itself.
448 // The owner's decision: a whole-store walk is off the request path, so an unbuilt
449 // listing answers `needs_build` immediately and walks nothing. The console is the only
450 // thing that builds one, by an operator pressing a button; this run does the same.
451 // Without it the passcode list reads "status 200 · 0 rows" and a working product is
452 // reported as broken. See dev/verify_applications.mjs for the whole reason.
453 for (const view of ['applications', 'passcodes', 'reports']) {
454 await call(boss.jar, 'GET', `/api/admin?view=${view}&build=1`);
455 }
456
457 // The gate bites before a browser is anywhere near it. Asserted here as well
458 // as through the app, because a client-side check that passed against a
459 // gateway which was never shut would be the worst kind of green.
460 const outsider = await call(null, 'POST', '/api/account', binding(device()), '198.51.100.201');
461 check('THE GATE BITES: a fresh device with a real signature is refused an account',
462 outsider.status === 403 && !!outsider.j && outsider.j.reason === 'beta_only',
463 'status ' + outsider.status + ' · ' + JSON.stringify(outsider.j && outsider.j.reason),
464 'the beta is configured closed and a stranger was still registered');
465
466 /// Mint a passcode from the console, as the operator does.
467 ///
468 /// `pro` is the tier the panel's own pulldown sends. Since 2026-08-17 a code
469 /// grants the FREE tier unless somebody asks for Pro, so the author's code
470 /// below asks for it deliberately: the decisive check further down is about
471 /// the GATE and the sign-in, and a free code would have turned it red for a
472 /// reason that has nothing to do with either. The free tier gets a device and
473 /// a phase of its own at the end.
474 async function mint(label, pro) {
475 const r = await call(boss.jar, 'POST', '/api/admin?view=passcodes',
476 { label, wave: 1, pro: pro === true });
477 return (r.j && r.j.passcode && r.j.passcode.code) || '';
478 }
479
480 const spentCode = await mint('Spent before the browser saw it');
481 const goodCode = await mint('The author, on a fresh device', true);
482 const freeCode = await mint('A free tester, on a fresh device');
483 check('three passcodes minted from the console',
484 !!spentCode && !!goodCode && !!freeCode,
485 spentCode + ' / ' + goodCode + ' / ' + freeCode);
486 if (!spentCode || !goodCode || !freeCode) { LOG.report(); cleanup(); process.exit(1); }
487
488 // One of them is spent by somebody else, from an address of its own, so the
489 // browser meets a code that is genuinely gone rather than one this file
490 // merely calls spent.
491 const crasher = device();
492 const took = await call(null, 'POST', '/api/passcode/redeem',
493 Object.assign({ code: spentCode }, binding(crasher)), '198.51.100.202');
494 check('one of them is spent by another device first', took.status === 200,
495 'status ' + took.status);
496
497 // ── The app ─────────────────────────────────────────────────
498
499 const up = await startServer();
500 check('the dev server is up and proxying to this gateway', up, APP);
501 if (!up) { SRV.report(); cleanup(); process.exit(1); }
502
503 const { open, shot, signInAs, errors } = await import('./harness.mjs');
504 const PROFILE = path.join(SCRATCH, 'pw', 'redeem' + (BREAK ? '-' + BREAK : ''));
505 fs.rmSync(PROFILE, { recursive: true, force: true });
506
507 const damaged = BREAK ? damagedFiles() : new Map();
508 const s = await open({
509 name: 'redeem',
510 profile: PROFILE,
511 signIn: false, // the gate itself is the subject; sign in below, watching
512 connect: false,
513 defaults: false,
514 route: async (page) => {
515 for (const [file, body] of damaged) {
516 await page.route('**/' + file, r => r.fulfill({
517 status: 200, contentType: 'application/javascript', body,
518 }));
519 }
520 },
521 });
522 const { page } = s;
523
524 /// The gateway's answer to the registration round, as the browser saw it.
525 const registrations = [];
526 page.on('response', async (r) => {
527 if (!/\/api\/account(\?|$)/.test(r.url()) || r.request().method() !== 'POST') return;
528 let j = null;
529 try { j = await r.json(); } catch (e) {}
530 registrations.push({ status: r.status(), reason: (j && j.reason) || '' });
531 });
532
533 /// What the gateway module holds about this device.
534 const gwState = () => page.evaluate(() => {
535 try { return window.DaimondGateway.state(); } catch (e) { return {}; }
536 });
537 /// The text of an element, or '' when it is not there.
538 const textOf = (sel) => page.evaluate((q) => {
539 const n = document.querySelector(q);
540 return n ? (n.textContent || '').trim() : '';
541 }, sel);
542 /// A catalogue sentence, so a check can compare what is on screen against
543 /// what the app is supposed to be saying rather than against a copy of it
544 /// pasted in here -- which would go stale silently.
545 const says = (key) => page.evaluate(k => window.DaimondI18n.t(k), key);
546
547 try {
548 // ── Nothing is offered where nothing could work ──────
549 //
550 // Before there is an identity there is no key to sign a redemption with,
551 // so a passcode field would be a control that refuses the moment it is
552 // used. Asked before sign-in, which is the only moment this state exists.
553 const beforeAny = await page.evaluate(() => {
554 try { window.DaimondPasscode.render(); } catch (e) { return 'threw: ' + e.message; }
555 const h = document.getElementById('credits-beta');
556 return h ? h.innerHTML.trim() : 'missing';
557 });
558 check('no passcode field before there is a key to sign one with',
559 beforeAny === '', JSON.stringify(beforeAny),
560 'a control was drawn that could only have refused');
561
562 // ── The refusal ─────────────────────────────────────
563 await signInAs(s, 'redeem');
564 await page.waitForTimeout(3000); // the bootstrap, and the dialog behind it
565
566 const reg = registrations[registrations.length - 1] || {};
567 check('the app\'s own registration round is refused by the closed beta',
568 reg.status === 403 && reg.reason === 'beta_only',
569 'status ' + reg.status + ' · reason ' + JSON.stringify(reg.reason));
570
571 let st = await gwState();
572 check('1. the app knows it was REFUSED, and says which refusal',
573 st.refused === 'beta_only', 'refused ' + JSON.stringify(st.refused),
574 'the reason came back on the wire and nothing in the client read it');
575 check('1. and does not report it as being offline',
576 st.offline === false && st.authed === false,
577 'offline ' + st.offline + ' · authed ' + st.authed,
578 'a deliberate refusal was reported as a gateway that could not be reached, '
579 + 'which sends the user to look at their own network');
580
581 // The rail's account row. It must not be claiming the service is
582 // unreachable, because it is not: it answered.
583 const unreachable = await says('astat.service_unreachable');
584 const accountRow = await textOf('#astat-account');
585 check('1. and the status row does not claim the service is unreachable',
586 !!accountRow && accountRow.indexOf(unreachable) < 0,
587 JSON.stringify(accountRow), 'the row is telling the user the wrong thing');
588
589 // ── 2. The refusal carries the way in ───────────────
590 const dialog = await page.$('.beta-scrim');
591 check('2. the refusal puts itself on screen', !!dialog, null,
592 'the user was dropped into browser-only mode and told nothing at all');
593 const dialogText = await textOf('.beta-box');
594 // Asserted by MEANING: the sentence the gateway itself insists on -- that
595 // only the account is closed and the app works without one -- has to
596 // survive into what the user reads, in whatever words the catalogue uses.
597 const honesty = await says('beta.lead_beta_only');
598 check('2. and says only the ACCOUNT is closed, not the app',
599 dialogText.indexOf(honesty) >= 0 && honesty.indexOf('no account at all') >= 0,
600 null, 'the refusal reads as the app being shut');
601 const hasField = await page.$('#beta-code-input');
602 const hasGo = await page.evaluate(() => {
603 const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn'));
604 return b.some(x => x.textContent.trim() === window.DaimondI18n.t('beta.redeem'));
605 });
606 check('2. and carries the code field and the button, from the refusal itself',
607 !!hasField && hasGo, 'field ' + !!hasField + ' · button ' + hasGo);
608 await shot(s, 'redeem-refusal');
609
610 // ── 3. And it is still findable afterwards ──────────
611 //
612 // Dismissed, the way somebody without a code in hand dismisses it. The
613 // code arrives days later, and the way in has to be somewhere they would
614 // look: the Credits drawer, which is where this app answers "what account
615 // have I got". Reached through the SAME call the status row makes.
616 await page.keyboard.press('Escape');
617 await page.waitForTimeout(300);
618 check('3. the dialog can be dismissed', !(await page.$('.beta-scrim')));
619 // The two calls the status row makes, in its order: the rail, then the
620 // Credits view. Driven through the app's own entry points so this is the
621 // path a user takes and not a private one.
622 await page.evaluate(() => {
623 try { window.DaimondPanels.show('rail'); } catch (e) { /* narrow window */ }
624 window.DaimondAdmin.credits('');
625 });
626 await page.waitForTimeout(500);
627 const drawerBtn = await page.$('#beta-open');
628 check('3. the Credits drawer carries the way in afterwards', !!drawerBtn, null,
629 'somebody who gets a code after being refused has nowhere to type it');
630 await shot(s, 'redeem-drawer');
631 // A direct DOM click, as the harness does elsewhere: the drawer animates,
632 // and Playwright's stability check waits for a second frame that a card
633 // mid-transition has not produced yet.
634 await page.evaluate(() => {
635 const b = document.getElementById('beta-open');
636 if (b) b.click();
637 });
638 await page.waitForTimeout(500);
639 check('3. and it opens the same screen', !!(await page.$('#beta-code-input')));
640
641 /// Type a code into the open dialog and press Redeem. Returns the error
642 /// line, or '' when it was accepted.
643 ///
644 /// WAITS FOR AN OUTCOME rather than for a fixed interval. A redemption
645 /// that is accepted goes on to a whole registration round -- register,
646 /// challenge, verify, balance, licence -- and a fixed sleep short of that
647 /// would read an empty error line as a success while the request was
648 /// still in the air, which is the same green whatever happens next.
649 async function tryCode(code) {
650 // The value is set on the element and the button clicked in the DOM,
651 // for the reason the harness gives about forced clicks: a card that
652 // is animating has not produced the second frame Playwright's
653 // stability check waits for. Nothing in this dialog listens for an
654 // `input` event -- the submit reads `input.value` -- so a plain
655 // assignment is the same thing a person's typing leaves behind.
656 await page.evaluate((c) => {
657 const i = document.getElementById('beta-code-input');
658 if (i) i.value = c;
659 const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn'));
660 const go = b.filter(x => !x.classList.contains('ghost')).pop();
661 if (go) go.click();
662 }, code);
663 const settled = await waitFor(async () => await page.evaluate(() => {
664 const box = document.querySelector('.beta-box');
665 if (!box) return true; // dismissed under us
666 const err = box.querySelector('.beta-err');
667 if (err && err.textContent.trim()) return true; // refused, and said so
668 return !err; // the confirmation replaced the form
669 }), 30000, 200);
670 if (!settled) return '(the dialog never answered)';
671 return await textOf('.beta-err');
672 }
673
674 // ── 4. Which refusal it was ─────────────────────────
675 const mistyped = await tryCode('zzzz-zzzz-zzz7');
676 const wantMis = await says('beta.err_unknown');
677 check('4. a mistyped code is told it was not one we issued',
678 !!mistyped && mistyped === wantMis, JSON.stringify(mistyped),
679 'a typo was reported as something else, or as nothing');
680
681 await sleep(800); // under the rate ceiling
682 const spent = await tryCode(spentCode);
683 const wantSpent = await says('beta.err_spent');
684 check('4. a spent code is told it was already used',
685 !!spent && spent === wantSpent, JSON.stringify(spent));
686 check('4. and the two are DIFFERENT sentences, so a tester can tell which happened',
687 !!mistyped && !!spent && mistyped !== spent, null,
688 'both refusals said the same thing, which hides the one fact the person needs');
689
690 // ── The decisive one ────────────────────────────────
691 //
692 // A fresh device, a closed gateway, a valid code -- and the app ends up
693 // signed in with Pro. The same key the gate refused a minute ago, so the
694 // 403 above cannot have been anything but the gate.
695 await sleep(800);
696 const accepted = await tryCode(goodCode);
697 check('a valid code is accepted', accepted === '', JSON.stringify(accepted));
698 await page.waitForTimeout(2500);
699 const doneTitle = await says('beta.done_title');
700 const boxText = await textOf('.beta-box');
701 check('and the screen says so', boxText.indexOf(doneTitle) >= 0, JSON.stringify(boxText.slice(0, 80)));
702 await shot(s, 'redeem-done');
703
704 st = await gwState();
705 check('THE APP IS SIGNED IN', st.authed === true,
706 'authed ' + st.authed + ' · offline ' + st.offline,
707 'the code was spent and the app never took the account it bought');
708 check('with Pro on it', st.pro === true, 'pro ' + JSON.stringify(st.pro),
709 'the beta grant is a five-year Pro licence and the client is not seeing it');
710 check('and the refusal is forgotten', !st.refused && st.offline === false,
711 'refused ' + JSON.stringify(st.refused) + ' · offline ' + st.offline);
712
713 // The oracle: the CONSOLE says which account spent the code, and the
714 // browser says which account it holds a session on. Neither is derived
715 // from the other.
716 const mine = await page.evaluate(() => fetch('/api/account', {
717 credentials: 'same-origin', headers: { 'x-daimond-api': '1' },
718 }).then(r => r.json()).catch(() => ({})));
719 const listed = await call(boss.jar, 'GET', '/api/admin?view=passcodes');
720 const row = ((listed.j && listed.j.passcodes) || [])
721 .find(p => p.label === 'The author, on a fresh device');
722 check('the account the browser holds is the one the console says redeemed the code',
723 !!row && !!mine.account_id && row.redeemed_by === mine.account_id,
724 (row && row.redeemed_by) + ' vs ' + mine.account_id);
725 check('and the console no longer shows a spent code', !!row && row.code === '',
726 JSON.stringify(row && row.code));
727
728 // ── Nothing that would refuse ───────────────────────
729 await page.evaluate(() => {
730 const c = document.querySelector('.beta-scrim');
731 if (c) c.remove();
732 window.DaimondAdmin.credits('');
733 });
734 await page.waitForTimeout(600);
735 // THE SUBJECT IS THE OFFER, NOT THE CONTAINER. This read `textOf('#credits-beta')
736 // === ''` until 2026-08-15 and went red the day the telemetry consent card
737 // started drawing in that host -- which is not an offer of a passcode, and is
738 // where withdrawal deliberately lives (`passcode.js:475-483`). It was also
739 // vacuous in the other direction: `textOf` answers '' for a node that is
740 // MISSING exactly as for one that is empty, so a host that vanished would have
741 // passed it. So: assert the host EXISTS, then assert that neither thing which
742 // offers a code is inside it.
743 const after = await page.evaluate(() => {
744 const h = document.getElementById('credits-beta');
745 if (!h) return { host: false };
746 return {
747 host: true,
748 open: !!h.querySelector('#beta-open'),
749 field: !!h.querySelector('#beta-code-input'),
750 text: (h.textContent || '').trim().slice(0, 120),
751 };
752 });
753 check('the Credits beta block is still on the page to be judged',
754 after.host === true, JSON.stringify(after),
755 'the host went missing, which the old emptiness test would have called a pass');
756 check('nothing offers a passcode to a device that now has an account',
757 after.host === true && !after.open && !after.field, JSON.stringify(after),
758 'a way to enter a code was left on screen for a device with nothing left to redeem');
759
760 // Uncaught exceptions only. Console errors are not the signal here: a
761 // closed gateway refuses several routes on purpose and the modules that
762 // meet those refusals say so on the console, which is correct behaviour
763 // and would make this line red for the very state the file is testing.
764 const errs = errors(s).filter(e => /^pageerror:/.test(e));
765 check('nothing on the page threw', errs.length === 0, errs.slice(0, 3).join(' | '));
766 } finally {
767 await s.close().catch(() => {});
768 }
769
770 // ── The free tier, on a device of its own ───────────────────
771 //
772 // The most-read screen in the beta, and until 2026-08-17 nobody had ever seen
773 // it: every code gifted Pro, so `beta.done_plain` was drawn in the source and
774 // unreachable in fact. A free code now reaches it, which makes what it SAYS a
775 // property worth holding -- so this redeems one and reads the screen.
776 //
777 // A second browser, after the first has closed, because the redemption is
778 // bound to a device key and the device above has spent its code. Sequential
779 // rather than side by side: two Chromiums at once on this box is memory
780 // nobody needs to spend on a phase this short.
781 const FREEPROF = path.join(SCRATCH, 'pw', 'redeemfree' + (BREAK ? '-' + BREAK : ''));
782 fs.rmSync(FREEPROF, { recursive: true, force: true });
783 const f = await open({
784 name: 'redeemfree',
785 profile: FREEPROF,
786 signIn: false,
787 connect: false,
788 defaults: false,
789 });
790 try {
791 const fpage = f.page;
792 const fsays = (key) => fpage.evaluate(k => window.DaimondI18n.t(k), key);
793 const ftext = (sel) => fpage.evaluate((q) => {
794 const n = document.querySelector(q);
795 return n ? (n.textContent || '').trim() : '';
796 }, sel);
797 await signInAs(f, 'redeemfree');
798 await fpage.waitForTimeout(3000); // the bootstrap, and the refusal behind it
799 const gate = await fpage.$('.beta-scrim');
800 check('the free device meets the same closed door', !!gate);
801
802 await fpage.evaluate((c) => {
803 const i = document.getElementById('beta-code-input');
804 if (i) i.value = c;
805 const b = [].slice.call(document.querySelectorAll('.beta-box .beta-btn'));
806 const go = b.filter(x => !x.classList.contains('ghost')).pop();
807 if (go) go.click();
808 }, freeCode);
809 const settled = await waitFor(async () => await fpage.evaluate(() => {
810 const box = document.querySelector('.beta-box');
811 if (!box) return true;
812 const err = box.querySelector('.beta-err');
813 if (err && err.textContent.trim()) return true;
814 return !err;
815 }), 30000, 200);
816 check('a free code is accepted', settled && (await ftext('.beta-err')) === '',
817 JSON.stringify(await ftext('.beta-err')));
818 await fpage.waitForTimeout(2500);
819
820 const fst = await fpage.evaluate(() => {
821 try { return window.DaimondGateway.state(); } catch (e) { return {}; }
822 });
823 check('THE FREE APP IS SIGNED IN TOO', fst.authed === true,
824 'authed ' + fst.authed + ' · offline ' + fst.offline,
825 'a free code spent itself and the app never took the account it made');
826 check('and holds no Pro, which is what free means',
827 fst.pro === false, 'pro ' + JSON.stringify(fst.pro),
828 'a free code granted the licence anyway, so the free tier has no testers again');
829
830 // The SENTENCE, asserted by meaning: the screen must be the plain one and
831 // must not be the Pro one. Both halves, because a panel that drew neither
832 // would pass the first on its own -- and `done_plain` said almost nothing
833 // until today, which is the defect this half exists to keep fixed.
834 const box = await ftext('.beta-box');
835 const plain = await fsays('beta.done_plain');
836 const proly = await fsays('beta.done_pro');
837 check('the confirmation is the free sentence, not the Pro one',
838 box.indexOf(plain) >= 0 && box.indexOf(proly) < 0,
839 JSON.stringify(box.slice(0, 160)));
840 // And that the sentence is worth reading. A free tester has to be told
841 // which tier they are on and what the other one adds, so the sentence must
842 // NAME Pro -- the one word every locale keeps untranslated, which is why
843 // this holds in all eight rather than only in English. Asserted on the
844 // catalogue and not on the screen for the same reason.
845 check('and it names the tier the reader has not got, rather than stopping at "you have an account"',
846 plain.indexOf('Pro') >= 0, JSON.stringify(plain.slice(0, 120)),
847 'the free confirmation says only that an account exists, which is the '
848 + 'screen nobody had ever seen and tells a tester nothing about what they hold');
849 await shot(f, 'redeem-done-free');
850
851 const ferrs = errors(f).filter(e => /^pageerror:/.test(e));
852 check('nothing on the free page threw', ferrs.length === 0, ferrs.slice(0, 3).join(' | '));
853 } finally {
854 await f.close().catch(() => {});
855 }
856
857 if (bad.length) { LOG.report(); SRV.report(); }
858 cleanup();
859 console.log(`\n${ok.length} passed, ${bad.length} failed`);
860 process.exit(bad.length ? 1 : 0);
861})().catch(async (e) => {
862 console.log(' FAIL the run threw — ' + (e && e.stack || e));
863 LOG.report();
864 SRV.report();
865 cleanup();
866 process.exit(1);
867});