Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_passcode.mjs

33.7 KiB, 1 run

created by r2519314175:569, 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_passcode.mjs — the beta passcode, proved at the network rather than in
2// the source.
3//
4// What this file is defending is a one-time credential that opens the only
5// endpoint on this gateway that receives anything about how a user behaves, and
6// that MAY gift a five-year Pro licence -- the same term a bought one runs for.
7// Every one of those words is a property somebody could get wrong quietly:
8//
9// * "one-time" — a code that redeems twice gives the beta away.
10// * "one-time under concurrency" — a check-then-write with no lock is TWO
11// redemptions being told yes, and it only shows up under load.
12// * "beta only" — telemetry from an account nobody let in is telemetry
13// nobody consented to.
14// * "capped" — this is the first unauthenticated write on a box holding a
15// live Stripe key, and there is no other rate limiting in the gateway.
16// * "may gift" — since 2026-08-17 a code grants the FREE tier unless the
17// operator asks for Pro. Both tiers are minted and redeemed in this run,
18// because a file that only ever minted one of them would pass whichever way
19// the flag was wired, and the flag had no caller at all for half a day.
20//
21// ── How each check is shown red ─────────────────────────────────────
22//
23// Two of them are shown red IN THIS RUN, by driving the gateway's own settings
24// rather than by editing anything:
25//
26// * the attempt cap is proved to refuse at N, and then the SAME sequence is
27// proved NOT to refuse with the cap set to 0 — so a green "it refused"
28// cannot have come from something else refusing.
29// * the beta gate on telemetry is exercised from both sides with two real
30// accounts in the same run: one that redeemed and one that did not.
31//
32// The rest were shown red against deliberately broken source, each break one
33// line, and what each one takes down is recorded here so nobody has to guess:
34//
35// delete the `is_redeemed` check in `Store::redeem_passcode`
36// -> six checks here fail, including both halves of the race.
37// stop writing the beta status in the same function
38// -> the three telemetry checks fail and nothing else does.
39// delete `lock_mutex!(self.writes)` from the same function
40// -> NOTHING here fails. See the race section for why, and for which test
41// does catch it.
42// write the account before the code is checked
43// -> caught by `schema::tests::test_a_refused_code_leaves_no_account_behind`.
44// take the FIRST `X-Forwarded-For` instead of the last
45// -> caught by `handlers::passcode::tests::
46// test_the_last_forwarded_address_is_the_one_counted`.
47//
48// ── Running it ──────────────────────────────────────────────────────
49//
50// cd gateway && env -u CARGO_TARGET_DIR cargo build --release
51// node dev/verify_passcode.mjs
52//
53// No browser: every call here is a plain HTTP request with a cookie this file
54// carries itself, so there is no compositor, no DISPLAY and no page to wait on.
55// It starts a gateway of its own on a port of its own, so it does not have to
56// wait for whoever is holding :9002 -- see `buildWorkDir` below.
57import fs from 'node:fs';
58import os from 'node:os';
59import path from 'node:path';
60import crypto from 'node:crypto';
61import { spawn } from 'node:child_process';
62import { fileURLToPath } from 'node:url';
63import { requireFreshGateway, procLog, GWDIR, GWBIN, openBeta } from './gwbin.mjs';
64
65const HERE = path.dirname(fileURLToPath(import.meta.url));
66const ROOT = path.join(HERE, '..');
67
68// ── A gateway of its own, on a port of its own ──────────────────────
69//
70// Six lanes are building this app at once, and one gateway at a time may hold
71// `:9002` — a port is exclusive and that part is real. `gateway/o3db` is NOT:
72// there is no cross-process locking in o3db, data files are opened for append
73// and a live file number is claimed with `create_new`, so a second process
74// opening the same store is refused nothing. What it would get is its own
75// in-memory index and its own garbage collector over the same files, which is a
76// worse arrangement than a refusal because nothing announces it.
77//
78// Either way a verifier that insisted on the shared port and the shared store
79// would be a verifier that can only run when nobody else is working, and the
80// last time that was the arrangement the answer was to kill whatever held the
81// port — which is somebody else's half-finished run.
82//
83// So this builds a working directory of its own: the deployed `app.jdat` with
84// one number changed, the real signing keys symlinked in (a gifted Pro licence
85// has to be signed with the same key a bought one is, or this proves nothing),
86// and an EMPTY store. The store being empty is not only politeness: the checks
87// below count passcodes, and counting them in a store somebody else has been
88// writing to would measure their afternoon.
89// 9420, deliberately clear of everything the harness already numbers: the dev
90// servers run at 8777+N and the mock providers at 9099+N, so a "spare" port in
91// the nine-thousand-one-hundreds is somebody's world, and picking one answers a
92// health probe with a mock LLM's 404 rather than with nothing.
93//
94// IT WAS 9402, WHICH IS INSIDE `DAIMOND_REDEEM_GW_PORT`'s 9400 + N. Under
95// GATE_WORLD=2 that is verify_redeem's gateway and this one, on one port, and it
96// survived only because `dev/run_all.sh` runs a verifier at a time. The register
97// in `dev/world.sh` held both rows and neither reader saw the overlap; the check
98// in `dev/verify_worldports.mjs` reads the register and found it on its first run.
99const PORT = Number(process.env.DAIMOND_GW_PORT || 9420);
100const GW = `http://127.0.0.1:${PORT}`;
101const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
102const WORK = path.join(SCRATCH, 'verify_passcode-gw');
103const LOG = procLog('verify_passcode');
104/// A fault injected on purpose, so a guard can be shown going red:
105///
106/// --break=knob the knob is never written, only read back
107const BREAK = (process.argv.find(a => a.startsWith('--break=')) || '').slice(8);
108
109/// Build the working directory, and hand back its path.
110function buildWorkDir() {
111 fs.rmSync(WORK, { recursive: true, force: true });
112 fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true });
113 // Every key EXCEPT the database's: the store is new, so its at-rest key must
114 // be new too, and pointing a fresh store at the live key would either fail
115 // or -- worse -- succeed against the live store.
116 for (const k of ['licence', 'stripe', 'openrouter']) {
117 const from = path.join(GWDIR, 'keys', k);
118 if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k));
119 }
120 const cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8')
121 .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${PORT})`);
122 if (!cfg.includes(`(u16|${PORT})`)) {
123 console.log(' FAIL could not set the listen port in the copied app.jdat — '
124 + 'has its shape changed?');
125 process.exit(1);
126 }
127 // And opened for registration. This file mints passcodes and redeems them,
128 // which means it makes fresh keypairs, and the deployed config's `beta_only`
129 // answers a fresh keypair `403 the beta is closed` -- so with it left shut
130 // the whole minting path never ran at all.
131 fs.writeFileSync(path.join(WORK, 'app.jdat'), openBeta(cfg, 'verify_passcode'));
132 return WORK;
133}
134
135const ok = [], bad = [];
136/// Record a check. `detail` is the evidence, printed either way; `why` is what
137/// went wrong and is printed only when it did -- so a passing line cannot read
138/// like a failure, which the first draft of this file managed twice.
139const check = (name, pass, detail, why) => {
140 (pass ? ok : bad).push(name);
141 const tail = pass ? (detail ? ' — ' + detail : '')
142 : ' — ' + [why, detail].filter(Boolean).join(' · ');
143 console.log((pass ? ' ok ' : ' FAIL ') + name + tail);
144};
145const sleep = ms => new Promise(r => setTimeout(r, ms));
146
147// ── The guard's ring is eight requests long and its rate ceiling is two a
148// second, so eight requests spanning less than 3.5 s read as a flood. Every
149// phase below is separated by more than that, on purpose: a 429 arriving in the
150// middle of a functional check would be the cap doing its job and would still
151// look exactly like the check failing. The one phase that WANTS a flood is last.
152const PHASE_GAP = 1400;
153
154// ── Device identities, exactly as the app builds them ───────────────
155
156/// A fresh device keypair, and the two things the gateway asks of it.
157function device() {
158 const kp = crypto.generateKeyPairSync('ed25519');
159 // The raw 32 bytes, which is what WebCrypto's `exportKey('raw')` gives and
160 // what the gateway decodes: the last 32 of the 44-byte SPKI wrapper.
161 const raw = kp.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32);
162 const b64url = b => b.toString('base64')
163 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
164 return {
165 pub: b64url(raw),
166 alg: 'Ed25519',
167 sign: s => crypto.sign(null, Buffer.from(s, 'utf8'), kp.privateKey).toString('base64'),
168 };
169}
170
171/// The binding proof `/api/account` and `/api/passcode/redeem` both demand.
172function binding(dev) {
173 const ts = Math.floor(Date.now() / 1000);
174 return { pubkey: dev.pub, alg: dev.alg, ts, sig: dev.sign(`daimond-gw-account:v1:${dev.pub}:${ts}`) };
175}
176
177/// One HTTP call, carrying a cookie jar this file owns.
178///
179/// Node's fetch keeps no cookies, so the session is moved by hand. That is a
180/// feature here: it makes it impossible to accidentally reuse one account's
181/// session for another's request, which two accounts in one browser context
182/// would do silently.
183async function call(jar, method, url, body, xff) {
184 const headers = { 'x-daimond-api': '1' };
185 if (jar && jar.cookie) headers.cookie = jar.cookie;
186 if (body !== undefined) headers['content-type'] = 'application/json';
187 // Talking to the gateway directly, as development does, there is no Steel in
188 // front to append one -- so every request otherwise shares the single
189 // "unknown" bucket and twenty at once read as a flood. Naming an address
190 // puts a racer in a bucket of its own, which is what lets the race be about
191 // the store rather than about the rate limit.
192 if (xff) headers['x-forwarded-for'] = xff;
193 const r = await fetch(GW + url, {
194 method,
195 headers,
196 body: body === undefined ? undefined : JSON.stringify(body),
197 });
198 const set = r.headers.getSetCookie ? r.headers.getSetCookie() : [];
199 if (jar && set.length) {
200 jar.cookie = set.map(c => c.split(';')[0]).join('; ');
201 }
202 let j = null;
203 try { j = await r.json(); } catch (e) {}
204 return { status: r.status, j };
205}
206
207/// Register a device the ordinary way and take a session on it. This is the
208/// account a stranger gets today, and the one telemetry must refuse.
209async function plainAccount() {
210 const dev = device();
211 const jar = {};
212 const acct = await call(jar, 'POST', '/api/account', binding(dev));
213 await session(jar, dev);
214 return { dev, jar, id: acct.j && acct.j.account_id };
215}
216
217/// Prove possession of the key and take a session cookie.
218async function session(jar, dev) {
219 const ch = await call(jar, 'POST', '/api/auth/challenge', { pubkey: dev.pub, alg: dev.alg });
220 if (!ch.j || !ch.j.challenge) return false;
221 const v = await call(jar, 'POST', '/api/auth/verify', {
222 challenge_id: ch.j.challenge_id,
223 sig: dev.sign(ch.j.challenge),
224 });
225 return v.status === 200;
226}
227
228/// One telemetry batch, in the shape the endpoint accepts. Seven integer fields
229/// and one event, because the point of the call is WHO may make it, not what it
230/// carries.
231///
232/// `t` is SECONDS, matching `pack()` in `www/js/telemetry.js`. Milliseconds
233/// exceed the endpoint's ceiling on any integer and the batch is refused for
234/// being out of range -- a 400 that looks exactly like the beta gate having
235/// nothing to do with it, and did, for the first run of this file.
236function batch(wave) {
237 return {
238 v: 1, b: 0, l: 1, w: wave,
239 t: Math.floor(Date.now() / 1000),
240 d: 0,
241 e: [[1, 12, 340]],
242 };
243}
244
245const procs = [];
246function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
247/// Start the gateway in the working directory, and wait for it to serve.
248///
249/// The wait is generous because it is not waiting on the gateway: an empty
250/// o3db spends twenty-odd seconds initialising its zones before anything
251/// listens, and a wait sized for a warm store reports "the gateway did not
252/// start" about a gateway that was starting perfectly well -- which is exactly
253/// the misdiagnosis this file is meant not to produce.
254async function startGateway(cwd, ownerAccount) {
255 const gw = spawn(GWBIN, [], {
256 cwd,
257 env: {
258 ...process.env,
259 APP_MODE: 'sandbox',
260 ...(ownerAccount ? { DAIMOND_OWNER_ACCOUNTS: ownerAccount } : {}),
261 },
262 stdio: LOG.stdio,
263 });
264 procs.push(gw);
265 const up = await waitFor(async () => (await fetch(`${GW}/api/health`)).ok, 120000);
266 return { gw, up };
267}
268
269/// Stop it, and wait for the PORT to be free rather than for the process to be
270/// signalled. A second gateway started on the strength of a `kill` returning
271/// meets `AddrInUse` and dies, and the run that follows measures nothing.
272async function stopGateway(gw) {
273 try { gw.kill('SIGKILL'); } catch (e) {}
274 await waitFor(async () => {
275 try { await fetch(`${GW}/api/health`); return false; }
276 catch (e) { return true; }
277 }, 30000, 200);
278 await sleep(500);
279}
280
281async function waitFor(fn, ms = 20000, gap = 250) {
282 const t0 = Date.now();
283 for (;;) {
284 try { if (await fn()) return true; } catch (e) {}
285 if (Date.now() - t0 > ms) return false;
286 await sleep(gap);
287 }
288}
289
290(async () => {
291 requireFreshGateway();
292
293 let stray = false;
294 try { stray = (await fetch(`${GW}/api/health`)).ok; } catch (e) {}
295 if (stray) {
296 console.log(` FAIL something is already answering on :${PORT}. This suite `
297 + 'pins an owner in configuration and must start its own gateway; set '
298 + 'DAIMOND_GW_PORT to a free port.');
299 process.exit(1);
300 }
301 const cwd = buildWorkDir();
302
303 // The owner has to exist before the gateway that pins them, so the first
304 // gateway is started with nobody pinned, an account is made, and the second
305 // is started naming it. Exactly what verify_operators does, and for the same
306 // reason.
307 let started = await startGateway(cwd, null);
308 check('gateway starts', started.up);
309 if (!started.up) { LOG.report(); cleanup(); process.exit(1); }
310
311 const boss = await plainAccount();
312 check('an account to be the owner', !!boss.id, boss.id);
313
314 // An owner is pinned in CONFIGURATION, which is what makes a console lockout
315 // impossible -- so the account has to exist before the process that names it.
316 await stopGateway(started.gw);
317 started = await startGateway(cwd, boss.id);
318 check('gateway restarts with that account pinned as owner', started.up);
319 if (!started.up) { LOG.report(); cleanup(); process.exit(1); }
320 // The session was taken against the first process, and the store is the
321 // same one, so it survives the restart. Re-taken anyway, because a session
322 // that did not survive would fail every console call below with a 401 that
323 // says nothing about passcodes.
324 await session(boss.jar, boss.dev);
325 const who = await call(boss.jar, 'GET', '/api/admin?view=whoami');
326 check('the console recognises the owner', who.j && who.j.role === 'owner',
327 JSON.stringify(who.j));
328
329 // BUILD THE LISTING INDEXES ONCE, because the gateway no longer builds them itself.
330 // The owner's decision: a whole-store walk is off the request path, so an unbuilt
331 // listing answers `needs_build` immediately and walks nothing. The console is the only
332 // thing that builds one, by an operator pressing a button; this run does the same.
333 // Without it the passcode list reads "status 200 · 0 rows" and a working product is
334 // reported as broken. See dev/verify_applications.mjs for the whole reason.
335 for (const view of ['applications', 'passcodes', 'reports']) {
336 await call(boss.jar, 'GET', `/api/admin?view=${view}&build=1`);
337 }
338
339 /// Mint a passcode from the console, as the operator does.
340 ///
341 /// `pro` is the tier, and it is passed exactly as the panel's own pulldown
342 /// passes it: omitted or false mints the free tier, true asks for Pro. A code
343 /// minted here with no third argument is therefore the ORDINARY code an
344 /// operator hands out, which is what makes the free checks below worth
345 /// anything.
346 async function mint(label, wave, pro) {
347 const r = await call(boss.jar, 'POST', '/api/admin?view=passcodes',
348 { label, wave: wave || 1, pro: pro === true });
349 return r;
350 }
351 /// Set one of the gateway's own knobs, which is how the cap is driven.
352 ///
353 /// AND CONFIRM IT READS BACK, because the alternative to confirming is
354 /// measuring the wrong number without being told. `settings::str` answers a
355 /// store read that returns nothing exactly as it answers one that errors:
356 /// it falls through to what `app.jdat` declares. So a knob that has been
357 /// POSTed but is not yet readable leaves the handler running the CONFIGURED
358 /// value -- `redeem_max_fails` = 10 rather than the 3 this drives it to --
359 /// and the run then measures a cap that was never in force while reporting
360 /// on the one it thought it set. That is a silent mis-measurement, and it is
361 /// one of the two mechanisms that could explain the single unreproduced
362 /// failure of "the fourth is refused by the cap" in the 5a0bcbf gate (the
363 /// other is the lost-count path `passcode.rs::note` documents). This does not
364 /// decide which it was; it removes this one from the field for good, and
365 /// turns it into a named failure if it ever does happen.
366 ///
367 /// The read goes through `view=settings`, which reads `store.get_setting` --
368 /// the same store read the handler's own `settings::str` makes.
369 async function setKnob(route, key, value) {
370 const r = BREAK === 'knob' ? { status: 0, j: null }
371 : await call(boss.jar, 'POST', '/api/admin?view=settings',
372 { route, key, value: String(value) });
373 for (let i = 0; i < 10; i++) {
374 const seen = await call(boss.jar, 'GET', '/api/admin?view=settings');
375 const grp = ((seen.j && seen.j.groups) || []).find(g => g.route === route);
376 const knob = ((grp && grp.knobs) || []).find(k => k.key === key);
377 if (knob && String(knob.value) === String(value)) return r;
378 await sleep(200);
379 }
380 check(`the ${key} knob was set and reads back`, false,
381 'it did not, so everything below it would have measured the configured '
382 + 'value instead of the one this set');
383 return r;
384 }
385 /// Redeem, from a device that has never been seen here.
386 async function redeem(dev, code, xff) {
387 return await call(null, 'POST', '/api/passcode/redeem',
388 Object.assign({ code }, binding(dev)), xff);
389 }
390
391 // ── Minting ─────────────────────────────────────────────────
392
393 const noLabel = await mint(' ', 1);
394 check('a passcode with nothing to say whose it is refused',
395 noLabel.status === 400, 'status ' + noLabel.status);
396
397 // Sam's code is a PRO one, asked for in words, because everything below it
398 // tests what a gifted licence does. The free tier -- what an operator gets
399 // when they say nothing -- is minted and redeemed in its own section further
400 // down, against a device of its own.
401 const minted = await mint('Sam, Perth meetup', 2, true);
402 const code = minted.j && minted.j.passcode && minted.j.passcode.code;
403 check('an owner mints a labelled passcode',
404 minted.status === 200 && !!code, 'status ' + minted.status + ' · ' + code);
405 check('the code comes back grouped for somebody to type',
406 typeof code === 'string' && code.includes('-'), code);
407 check('the label the operator typed is what comes back',
408 !!minted.j && minted.j.passcode.label === 'Sam, Perth meetup',
409 JSON.stringify(minted.j && minted.j.passcode));
410 // The tier the console asked for is the tier on the record. Without this the
411 // mint route could ignore the flag entirely and every Pro check below would
412 // still pass, because a grandfathered record reads `pro` true.
413 check('a code minted as Pro says so on the row',
414 !!minted.j && minted.j.passcode.pro === true,
415 JSON.stringify(minted.j && minted.j.passcode && minted.j.passcode.pro));
416
417 // The cap must not be the thing that decides any functional check below, so
418 // it is opened wide first and closed deliberately at the end.
419 const wideOpen = await setKnob('/api/passcode/redeem', 'redeem_max_fails', 1000);
420 check('the attempt cap is a knob the owner can move',
421 wideOpen.status === 200, 'status ' + wideOpen.status);
422
423 // ── Redemption ──────────────────────────────────────────────
424
425 await sleep(PHASE_GAP);
426 const sam = device();
427 const first = await redeem(sam, code);
428 check('a fresh device redeems the code', first.status === 200 && first.j && first.j.ok === true,
429 'status ' + first.status + ' · ' + JSON.stringify(first.j));
430 check('redemption created the account rather than needing one first',
431 !!first.j && first.j.created === true);
432 check('redemption answers the wave the passcode was minted into',
433 !!first.j && first.j.wave === 2, JSON.stringify(first.j && first.j.wave));
434 check('redemption gifted Pro', !!first.j && first.j.pro === true);
435 check('the new account was given a public handle',
436 !!first.j && typeof first.j.handle === 'string' && first.j.handle.length > 0,
437 first.j && first.j.handle);
438
439 // The licence, asked for the way the client asks for it: a gifted Pro must
440 // be the same signed artefact a bought one is, or the client would need a
441 // second way to believe in it.
442 const samJar = {};
443 await session(samJar, sam);
444 const lic = await call(samJar, 'GET', '/api/licence');
445 check('the client is served a real signed Pro licence',
446 lic.status === 200 && !!lic.j && lic.j.held === true
447 && !!lic.j.licence && typeof lic.j.licence.sig === 'string'
448 && lic.j.licence.sig.length > 40,
449 'status ' + lic.status + ' · held ' + (lic.j && lic.j.held));
450
451 // ── The tier, from both sides, in one run ───────────────────
452 //
453 // A beta code grants the FREE tier unless the operator asks for Pro, which is
454 // the whole point of the change: every code ever issued gifted Pro, so the
455 // free surface had never had a tester. What that surface actually is gets
456 // measured here rather than described -- an account that redeemed an ordinary
457 // code keeps the beta and the telemetry it consented to, and is refused the
458 // two things Pro bundles.
459 //
460 // Both sides in one run, for the reason the telemetry pair below gives: a
461 // gateway with no licence key configured would answer "no Pro" to everybody
462 // and pass the free checks on their own, and Sam above proves it does not.
463
464 await sleep(PHASE_GAP);
465 const freeMint = await mint('Sam, free cohort', 1);
466 const freeCode = freeMint.j && freeMint.j.passcode && freeMint.j.passcode.code;
467 check('an operator who says nothing about the tier mints a free code',
468 freeMint.status === 200 && !!freeCode && freeMint.j.passcode.pro === false,
469 'status ' + freeMint.status + ' · pro '
470 + JSON.stringify(freeMint.j && freeMint.j.passcode && freeMint.j.passcode.pro));
471
472 const tester = device();
473 const freeRedeem = await redeem(tester, freeCode);
474 check('a fresh device redeems the free code',
475 freeRedeem.status === 200 && !!freeRedeem.j && freeRedeem.j.ok === true,
476 'status ' + freeRedeem.status + ' · ' + JSON.stringify(freeRedeem.j));
477 check('and is told plainly that it has no Pro',
478 !!freeRedeem.j && freeRedeem.j.pro === false,
479 JSON.stringify(freeRedeem.j && freeRedeem.j.pro));
480
481 const freeJar = {};
482 await session(freeJar, tester);
483 const freeLic = await call(freeJar, 'GET', '/api/licence');
484 check('no licence was written for it either',
485 freeLic.status === 200 && !!freeLic.j && freeLic.j.held === false,
486 'status ' + freeLic.status + ' · held ' + (freeLic.j && freeLic.j.held));
487
488 // What the free tier KEEPS. The account is in the beta, so the telemetry it
489 // was asked about still goes -- which is the half of the free tier the copy
490 // at the door has to be able to promise.
491 const freeTel = await call(freeJar, 'POST', '/api/telemetry', batch(1));
492 check('a free tester is in the beta and its telemetry is taken',
493 freeTel.status === 200 && !!freeTel.j && freeTel.j.stored === 1,
494 'status ' + freeTel.status + ' · ' + JSON.stringify(freeTel.j));
495
496 // What it LOSES. Cloud storage rides the sync unlock (`chunk.rs`, `"sync"`),
497 // and the gate is checked before the batch is read -- so an empty put reaches
498 // the gate and nothing else. The Pro account gets past it and is refused for
499 // the body instead, which is what tells the two apart: a 402 for everybody
500 // would pass the first line on its own, and so would an endpoint that is
501 // simply broken.
502 const freePut = await call(freeJar, 'POST', '/api/chunk', { op: 'put' });
503 check('cloud upload is refused to a free tester',
504 freePut.status === 402 && !!freePut.j && freePut.j.tool === 'pro',
505 'status ' + freePut.status + ' · ' + JSON.stringify(freePut.j));
506 const proPut = await call(samJar, 'POST', '/api/chunk', { op: 'put' });
507 check('and opened to the Pro one, so the licence is what decides it',
508 proPut.status === 400, 'status ' + proPut.status + ' · ' + JSON.stringify(proPut.j));
509
510 // ── Telemetry: the gate, from both sides, in one run ────────
511
512 await sleep(PHASE_GAP);
513 const stranger = await plainAccount();
514 const strangerTel = await call(stranger.jar, 'POST', '/api/telemetry', batch(2));
515 check('an account that redeemed nothing cannot reach telemetry',
516 strangerTel.status === 403, 'status ' + strangerTel.status);
517
518 const samTel = await call(samJar, 'POST', '/api/telemetry', batch(2));
519 check('the redeemed account can', samTel.status === 200 && !!samTel.j && samTel.j.stored === 1,
520 'status ' + samTel.status + ' · ' + JSON.stringify(samTel.j));
521 // Both sides in one run is what makes the pair worth anything: a 403 for
522 // everybody would pass the first check on its own, and a 200 for everybody
523 // would pass the second.
524 check('so the beta status is what decides it, not the endpoint being open or shut',
525 strangerTel.status === 403 && samTel.status === 200);
526
527 // ── Single use ──────────────────────────────────────────────
528
529 await sleep(PHASE_GAP);
530 const gatecrasher = device();
531 const second = await redeem(gatecrasher, code);
532 check('the same code cannot be redeemed twice',
533 second.status === 409 && !!second.j && second.j.reason === 'spent',
534 'status ' + second.status + ' · ' + JSON.stringify(second.j));
535
536 // And the refusal has to mean something: the second device must be OUT. A
537 // 409 that nonetheless wrote the beta status would pass the check above.
538 const crashJar = {};
539 const crashHasAccount = await session(crashJar, gatecrasher);
540 check('a refused redemption left no account behind for that device',
541 crashHasAccount === false, null,
542 'the device took a session, so an account was written for it anyway');
543
544 // ── The race ────────────────────────────────────────────────
545
546 await sleep(PHASE_GAP);
547 const raceMint = await mint('Race, six devices at once', 1);
548 const raceCode = raceMint.j && raceMint.j.passcode && raceMint.j.passcode.code;
549 check('a second passcode for the race', raceMint.status === 200 && !!raceCode);
550
551 // Each racer arrives from an address of its own, so all of them reach the
552 // store: the rate limit is per address, and twenty-four from one would be a
553 // flood and would be refused as one — correct behaviour, and it would make
554 // this measure the cap instead of the critical section.
555 //
556 // WHAT THIS CHECK DOES AND DOES NOT PROVE, because a check nobody has shown
557 // red is a check nobody should trust:
558 //
559 // It catches a MISSING SINGLE-USE RULE outright — a build with the
560 // `is_redeemed` guard deleted answers twenty-four 200s and this line fails
561 // loudly. It does NOT catch a missing LOCK: a build with
562 // `lock_mutex!(self.writes)` taken out of `Store::redeem_passcode` passes
563 // this file thirty-five out of thirty-five, at six racers and again at
564 // twenty-four. The window a missing lock opens is the microseconds between
565 // the store read and the store write, and requests arriving over separate
566 // sockets do not land inside it however many of them there are.
567 //
568 // The lock is proved red by `schema::tests::
569 // test_two_racing_redemptions_produce_exactly_one_winner`, which holds eight
570 // threads on a barrier and hits the store directly: that one fails 8-of-8 on
571 // an unlocked build, three runs out of three, with no artificial widening.
572 // So this line is the end-to-end smoke check and that one is the authority.
573 // Do not delete that test on the strength of this one passing.
574 const RACERS = 24;
575 const racers = Array.from({ length: RACERS }, () => device());
576 const results = await Promise.all(racers.map(
577 (d, i) => redeem(d, raceCode, `198.51.100.${100 + i}`)));
578 const won = results.filter(r => r.status === 200);
579 const spent = results.filter(r => r.status === 409);
580 check(`exactly one of ${RACERS} simultaneous redemptions wins`,
581 won.length === 1, won.length + ' won, ' + spent.length + ' told the code was spent',
582 'statuses ' + results.map(r => r.status).join(','));
583 check('the rest were refused by the code being spent, not by the cap',
584 spent.length === RACERS - 1, null, results.map(r => r.status).join(','));
585
586 // Asserted through the gateway as well as through the replies: five 409s
587 // and six beta accounts would pass the count above and still have given the
588 // beta away six times.
589 let inBeta = 0;
590 for (const d of racers) {
591 const jar = {};
592 if (!(await session(jar, d))) continue;
593 const t = await call(jar, 'POST', '/api/telemetry', batch(1));
594 if (t.status === 200) inBeta += 1;
595 }
596 check('and exactly one of them is actually in the beta', inBeta === 1,
597 inBeta + ' of ' + RACERS + ' reached the telemetry endpoint');
598
599 // ── What the console shows afterwards ───────────────────────
600
601 await sleep(PHASE_GAP);
602 const listed = await call(boss.jar, 'GET', '/api/admin?view=passcodes');
603 const rows = (listed.j && listed.j.passcodes) || [];
604 const sams = rows.find(p => p.label === 'Sam, Perth meetup');
605 check('the console lists the passcodes', listed.status === 200 && rows.length >= 2,
606 'status ' + listed.status + ' · ' + rows.length + ' rows');
607 check('a spent code is no longer shown', !!sams && sams.code === '',
608 JSON.stringify(sams && sams.code));
609 check('but its label survives it, which is what a telemetry row is traced back to',
610 !!sams && sams.label === 'Sam, Perth meetup' && !!sams.redeemed_by,
611 JSON.stringify(sams));
612 check('and the account it names is the one that redeemed it',
613 !!sams && sams.redeemed_by === first.j.account_id,
614 (sams && sams.redeemed_by) + ' vs ' + (first.j && first.j.account_id));
615 check('the console counts what is still to be used',
616 listed.j && listed.j.redeemed >= 2 && listed.j.minted >= 2,
617 JSON.stringify({ minted: listed.j && listed.j.minted, unused: listed.j && listed.j.unused }));
618 // The panel that hands out codes says whether the door they gate is shut.
619 check('the console says registration is still open on a gateway nobody closed',
620 listed.j && listed.j.closed === false, JSON.stringify(listed.j && listed.j.closed));
621
622 // ── The attempt cap ─────────────────────────────────────────
623 //
624 // Proved from both sides in this run. A successful redemption clears the
625 // address's failure count, so the sequence below starts from zero however
626 // many refusals came before it.
627
628 await sleep(PHASE_GAP);
629 const resetMint = await mint('Cap, resetting the count', 1);
630 const resetCode = resetMint.j && resetMint.j.passcode && resetMint.j.passcode.code;
631 const reset = await redeem(device(), resetCode);
632 check('a success clears the failures counted before it', reset.status === 200,
633 'status ' + reset.status);
634
635 await setKnob('/api/passcode/redeem', 'redeem_max_fails', 3);
636 const capped = [];
637 for (let i = 0; i < 4; i++) {
638 capped.push(await redeem(device(), 'zzzz-zzzz-zzz' + i));
639 await sleep(600); // under the rate ceiling, so only the ATTEMPT cap can refuse.
640 }
641 check('three wrong codes are admitted and answered honestly',
642 capped.slice(0, 3).every(r => r.status === 404),
643 capped.map(r => r.status).join(','));
644 check('the fourth is refused by the cap',
645 capped[3].status === 429 && !!capped[3].j && capped[3].j.reason === 'throttled',
646 'status ' + capped[3].status + ' · ' + JSON.stringify(capped[3].j));
647
648 // THE RED PROOF, in this run and against this gateway: with the cap turned
649 // off the very same request is answered rather than refused. Without this a
650 // green "the fourth is refused" could have come from anything at all
651 // refusing — a bad body, a dead route, a signature the gateway disliked.
652 await setKnob('/api/passcode/redeem', 'redeem_max_fails', 0);
653 await sleep(600);
654 const uncapped = await redeem(device(), 'zzzz-zzzz-zzz9');
655 check('with the cap set to zero the same attempt is answered, not refused',
656 uncapped.status === 404, 'status ' + uncapped.status + ' · ' + JSON.stringify(uncapped.j));
657
658 // ── The rate limit, which is the other half of the cap ──────
659 //
660 // Last, because a throttled address stays throttled for a couple of minutes
661 // and everything above would then measure the throttle instead of itself.
662
663 await sleep(PHASE_GAP);
664 const flood = await Promise.all(Array.from({ length: 16 },
665 () => redeem(device(), 'yyyy-yyyy-yyyy')));
666 check('a flood is throttled even with the attempt cap turned off',
667 flood.some(r => r.status === 429),
668 'statuses ' + flood.map(r => r.status).join(','));
669
670 if (bad.length) LOG.report();
671 cleanup();
672 console.log(`\n${ok.length} passed, ${bad.length} failed`);
673 process.exit(bad.length ? 1 : 0);
674})().catch(async (e) => {
675 console.log(' FAIL the run threw — ' + (e && e.stack || e));
676 LOG.report();
677 cleanup();
678 process.exit(1);
679});