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. |
| 57 | import fs from 'node:fs'; |
| 58 | import os from 'node:os'; |
| 59 | import path from 'node:path'; |
| 60 | import crypto from 'node:crypto'; |
| 61 | import { spawn } from 'node:child_process'; |
| 62 | import { fileURLToPath } from 'node:url'; |
| 63 | import { requireFreshGateway, procLog, GWDIR, GWBIN, openBeta } from './gwbin.mjs'; |
| 64 | |
| 65 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 66 | const 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. |
| 99 | const PORT = Number(process.env.DAIMOND_GW_PORT || 9420); |
| 100 | const GW = `http://127.0.0.1:${PORT}`; |
| 101 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 102 | const WORK = path.join(SCRATCH, 'verify_passcode-gw'); |
| 103 | const 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 |
| 107 | const BREAK = (process.argv.find(a => a.startsWith('--break=')) || '').slice(8); |
| 108 | |
| 109 | /// Build the working directory, and hand back its path. |
| 110 | function 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 | |
| 135 | const 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. |
| 139 | const 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 | }; |
| 145 | const 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. |
| 152 | const 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. |
| 157 | function 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. |
| 172 | function 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. |
| 183 | async 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. |
| 209 | async 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. |
| 218 | async 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. |
| 236 | function 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 | |
| 245 | const procs = []; |
| 246 | function 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. |
| 254 | async 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. |
| 272 | async 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 | |
| 281 | async 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 | }); |