Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_creditage.mjs

23.0 KiB, 1 run

created by r2519314175:321, 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_creditage.mjs — the provider credit figure, kept honest instead of stale.
2//
3// THE COMPLAINT. The author added $100 to his OpenRouter account and Daimond went on showing
4// the old number for hours: "if my Daimond is just sitting there for hours after I added OR
5// credit, I feel like it should be showing an honest credit level." The figure was probed at
6// exactly three moments — unlock, a pasted key, and the "Ask again" button — and nothing else
7// ever asked again.
8//
9// A displayed balance goes wrong two ways and they want opposite treatments:
10//
11// THE USER SPENT. Daimond watched them do it; the turn and its cost are in the ledger
12// already. So the figure is walked down locally, with NO REQUEST AT ALL, and the next probe
13// replaces it outright. That is the frequent case and it must cost nothing.
14//
15// THE USER TOPPED UP. Nothing in the browser can know that; only a probe finds it. So the
16// probe happens when the tab comes back to the front, on a beat while it is in front, and
17// when the Models panel is opened — every one of them behind ONE floor, because the user's
18// own rate limit is not free and the way to find out what OpenRouter allows is not to
19// hammer it.
20//
21// AND THE FIGURE CARRIES ITS AGE, because a probe that fails writes nothing and keeps the
22// old number — which is right, no balance beats a wrong one — and silence and freshness
23// look identical on screen without it.
24//
25// HOW THIS IS DRIVEN, and why you can believe it.
26//
27// * EVERY PROBE ASSERTION IS AT THE NETWORK. `openrouter.ai/api/v1/key` and `/credits` are
28// intercepted and counted. A check that counted internal calls could not see a request
29// that never went, nor one that went twice.
30// * EVERY CHECK IS PROVED RED. The same scenario is run five more times against a patched
31// `models.js` served from the dev server — the floor removed, the beat left running while
32// hidden, the age line silenced, the decrement disabled, the "who can answer" test made to
33// say everyone — and the check each patch breaks is asserted to FAIL in that run. A check
34// that passes on the broken build is reported as a broken check.
35// * TIME is Playwright's fake clock, installed before the page loads, so the five-minute
36// floor and a three-hour age are exercised in seconds rather than approximated.
37// * VISIBILITY is the one thing stood in for. Headless Chrome reports every page visible,
38// whatever is in front of it (measured: a second tab brought to front leaves
39// `visibilityState` at "visible", and neither `Emulation.setPageVisibilityOverride` nor
40// `Page.setWebLifecycleState` moves it), so the SIGNAL is faked in an init script and the
41// real `visibilitychange` event is dispatched. Everything downstream of it — the handler,
42// the gate, the request — is the shipped code, and what is measured is the request.
43//
44// node dev/verify_creditage.mjs
45//
46// Needs a world: `bash dev/world.sh N --up` then `eval "$(bash dev/world.sh N --env)"`.
47import fs from 'node:fs';
48import path from 'node:path';
49import { fileURLToPath } from 'node:url';
50import { open, signInAs, shot } from './harness.mjs';
51
52const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
53const SRC = fs.readFileSync(path.join(ROOT, 'www/js/models.js'), 'utf8');
54
55const ok = [], bad = [];
56const check = (name, pass, detail) => {
57 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
58 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
59};
60
61const OR = 'https://openrouter.ai/api/v1';
62const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
63const json = (body, status = 200) => ({
64 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
65});
66const MIN = 60 * 1000;
67/// The floor in models.js. Named here so the waits below say what they are relative to; the
68/// checks assert the PROPERTY (a probe, or none) and never a count of milliseconds.
69const FLOOR = 5 * MIN;
70
71// ── The patches that prove each check can fail ─────────────────────────
72// Each is [anchor, replacement] against the shipped `www/js/models.js`, applied to the file the
73// browser fetches. An anchor that no longer appears is a broken proof, not a passing one, so
74// every anchor is checked against the file on disk before a browser is opened.
75const BREAK = {
76 floor: {
77 what: 'the shared floor removed, so every trigger probes',
78 // TWO PATCHES, AND THE SECOND ONE IS NOT PART OF THE CLAIM. Removing the floor also
79 // removes the only thing standing between `render()` and itself: models.js:1987 calls
80 // `refreshCredits()` from a redraw, a probe that answers calls `render()`, and with no
81 // floor that loop feeds itself. MEASURED with the one patch (2026-08-25): 8,238 probes
82 // after five round trips and 20,790 by the end of the scenario, every one of them a
83 // route round trip across the Playwright channel -- 73 seconds against the 16 every
84 // other scenario takes, and enough to expire a click's own 30-second actionability
85 // wait on a busy box. The second patch cuts the success -> redraw edge, so what is
86 // left is exactly the claim: one probe per trigger, five round trips making five
87 // requests where the shipped build makes none. A sharper proof and a cheaper one.
88 patches: [
89 ['if (!probeDue(id)) continue;', '/* floor removed */'],
90 ['if (got && document.getElementById(\'models-list\')) render();\n\t\t\t\t\telse ageLines();',
91 'if (got) ageLines();'],
92 ],
93 },
94 hidden: {
95 what: 'the beat left running while the tab is hidden',
96 patches: [['if (document.visibilityState === \'hidden\') { beatOff(); return; }',
97 'if (document.visibilityState === \'hidden\') { return; }']],
98 },
99 age: {
100 what: 'the age line silenced',
101 patches: [['function ageSentence(id, c) {', 'function ageSentence(id, c) { if (1) return \'\';']],
102 },
103 decrement: {
104 what: 'the local decrement disabled',
105 patches: [['var gone = spentSince(id, c.asOf);', 'var gone = 0;']],
106 },
107 idle: {
108 what: 'the finished-turn repaint removed',
109 patches: [['window.addEventListener(\'daimond:idle\', function () { ageLines(); });',
110 '/* no repaint when a turn finishes */']],
111 },
112 headsync: {
113 // The defect this check was written from: the block below the head counted the turns
114 // off and the head went on quoting the provider, so the panel held two balances.
115 what: 'the closed row left behind by the open one',
116 patches: [['if (bal) paintBal(bal, id, creditFor(id));', '/* head left behind */']],
117 },
118 anyone: {
119 what: 'every provider treated as one that will answer',
120 patches: [['return String(url || \'\').indexOf(\'openrouter.ai\') !== -1;', 'return !!url;']],
121 },
122};
123
124for (const [name, b] of Object.entries(BREAK)) {
125 for (const [anchor] of b.patches) {
126 const n = SRC.split(anchor).length - 1;
127 if (n !== 1) {
128 console.log(` FAIL the "${name}" proof cannot be applied — its anchor appears ${n} times in models.js`);
129 console.log(` anchor: ${anchor}`);
130 process.exit(1);
131 }
132 }
133}
134
135/// Run the whole scenario once, against the shipped file or a patched one.
136///
137/// Returns the observations; the checks are made afterwards, once for the green run and once
138/// per patched run, so the same sentence is asserted true here and false there.
139async function scenario(label, patches) {
140 // What the provider says, and the levers the scenario pulls on it.
141 const or = { key: 0, credits: 0, total: 40, usage: 0, fail: false };
142 const fw = { hits: 0 };
143 let patched = 0;
144
145 const s = await open({
146 name: `creditage-${label}`, signIn: false, connect: false, defaults: false,
147 route: async (page) => {
148 // Fake time, and a faked visibility SIGNAL. Both before the first byte of the app.
149 await page.clock.install();
150 await page.addInitScript(() => {
151 var vis = 'visible';
152 Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { return vis; } });
153 Object.defineProperty(document, 'hidden', { configurable: true, get: function () { return vis === 'hidden'; } });
154 window.__vis = function (v) { vis = v; document.dispatchEvent(new Event('visibilitychange')); };
155 });
156 if (patches) {
157 await page.route('**/js/models.js', async (r) => {
158 const res = await r.fetch();
159 let body = await res.text();
160 for (const [from, to] of patches) {
161 if (body.includes(from)) { patched++; body = body.split(from).join(to); }
162 }
163 await r.fulfill({ status: 200, contentType: 'application/javascript', body });
164 });
165 }
166 // The two probe endpoints, counted. `limit: null` is an UNCAPPED key, which is what
167 // a user's own pasted key normally is, so both requests are exercised.
168 await page.route(`${OR}/key`, (r) => {
169 or.key++;
170 if (or.fail) return r.fulfill(json({ error: 'upstream' }, 500));
171 return r.fulfill(json({ data: { label: 'test', limit: null, limit_remaining: null, usage: or.usage } }));
172 });
173 await page.route(`${OR}/credits`, (r) => {
174 or.credits++;
175 if (or.fail) return r.fulfill(json({ error: 'upstream' }, 500));
176 return r.fulfill(json({ data: { total_credits: or.total, total_usage: or.usage } }));
177 });
178 await page.route(`${OR}/models`, (r) => r.fulfill(json({ data: [{ id: 'z-ai/glm-5p2' }] })));
179 // A provider that has no CREDIT endpoint. A request for one is a request to
180 // nowhere, so everything but the catalogue is counted -- the whole host used to
181 // be, and `/models` stopped being nowhere on 2026-08-20, when a stale catalogue
182 // began asking for itself. The route still covers the host so that nothing can
183 // leave this machine; only the counting narrowed.
184 await page.route('https://api.fireworks.ai/**', (r) => {
185 if (!/\/models(\?|$)/.test(new URL(r.request().url()).pathname + '')) fw.hits++;
186 return r.fulfill(json({}, 404));
187 });
188 },
189 });
190 const p = s.page;
191 // A BROKEN BUILD IS ALLOWED TO BE SLOW, and the plumbing waits for it rather than the
192 // assertions being loosened -- nothing in this file asserts a duration, so a longer wait
193 // costs a slow run time and cannot buy a wrong answer. Playwright's default is 30 seconds
194 // per action, and on a busy box a patched build that is deliberately making requests can
195 // expire it inside a click that has already happened; the run then dies with an uncaught
196 // TimeoutError, which reads as the app being broken and is not.
197 p.setDefaultTimeout(120000);
198 await signInAs(s, `creditage-${label}`);
199 await p.waitForTimeout(1500);
200
201 const ff = async (ms) => { await p.clock.fastForward(ms); await p.waitForTimeout(250); };
202 const vis = async (v) => { await p.evaluate((x) => window.__vis(x), v); await p.waitForTimeout(250); };
203 /// What the expanded OpenRouter row says: the figure sentence, its age line, and the
204 /// balance on the head. This is what the user reads, so it is what is asserted.
205 const said = () => p.evaluate(() => {
206 const w = document.querySelector('.models-credit[data-prov="openrouter"]');
207 const heads = Array.prototype.slice.call(document.querySelectorAll('.models-prov-head'));
208 const head = heads.find((h) => /OpenRouter/.test(h.textContent || ''));
209 const bal = head && head.querySelector('.models-bal');
210 const ageEl = w && w.querySelector('.models-credit-age');
211 return {
212 line: w ? (w.querySelector('.models-credit-line') || {}).textContent || '' : '',
213 age: ageEl ? ageEl.textContent || '' : '',
214 // The loud colour on the age line, and the mark on the closed row's balance. Both
215 // have to keep up, or the staleness warning is itself stale.
216 ageStale: !!(ageEl && ageEl.classList.contains('stale')),
217 bal: bal ? bal.textContent || '' : '',
218 tip: bal ? bal.getAttribute('title') || '' : '',
219 stale: !!(bal && bal.hasAttribute('data-stale')),
220 };
221 });
222
223 // ── A provider with a key, the way the settings form adds one ──
224 await p.evaluate(async () => {
225 DaimondModels.addProvider('openrouter', { url: 'https://openrouter.ai/api/v1/chat/completions' });
226 DaimondModels.addProvider('fireworks', { url: 'https://api.fireworks.ai/inference/v1/chat/completions' });
227 await DaimondModels.setKey('fireworks', 'fw-test-key');
228 await DaimondModels.setKey('openrouter', 'sk-or-v1-testkey0000');
229 });
230 await p.waitForTimeout(1200);
231 const paste = { key: or.key, credits: or.credits };
232
233 // The panel, opened the way a user opens it, and the row expanded.
234 await p.click('#astat-model');
235 await p.waitForTimeout(600);
236 await p.evaluate(() => {
237 const heads = Array.prototype.slice.call(document.querySelectorAll('.models-prov-head'));
238 const h = heads.find((x) => /OpenRouter/.test(x.textContent || ''));
239 if (h) h.click();
240 });
241 await p.waitForTimeout(400);
242 const opened = { key: or.key, said: await said() };
243 await shot(s, `creditage-${label}-opened`);
244
245 // ── The floor, against the trigger that fires most often ──
246 // Away and back five times in a row, well inside the floor.
247 for (let i = 0; i < 5; i++) { await vis('hidden'); await vis('visible'); }
248 const toggled = { key: or.key };
249
250 // ── And against the panel being opened again ──
251 // Shut by whichever closer this window is wearing: the rail's drawer has its own cross,
252 // and a window with no rail hosts the same view in a modal card.
253 const shut = async () => {
254 for (const sel of ['#admin-close', '#settings-close']) {
255 if (await p.isVisible(sel)) { await p.click(sel); return; }
256 }
257 throw new Error('nothing visible closes the Admin panel');
258 };
259 for (let i = 0; i < 3; i++) {
260 await shut();
261 await p.waitForTimeout(200);
262 await p.click('#astat-model');
263 await p.waitForTimeout(300);
264 }
265 const reopened = { key: or.key };
266
267 // ── The user spent: the figure moves with no request at all ──
268 // The two things daimond.js does at the end of every metered turn, in that order: the cost
269 // goes into the ledger, and the app announces that it is idle again. No clock is moved and
270 // no floor is spent — if the figure changes here, it changed out of books this device
271 // already keeps.
272 await p.evaluate(() => {
273 DaimondLedger.record({
274 ts: Date.now(), model: 'z-ai/glm-5p2', provider: 'openrouter',
275 promptTokens: 1000, completionTokens: 500, cachedTokens: 0, costUsd: 2.5,
276 });
277 window.dispatchEvent(new Event('daimond:idle'));
278 });
279 await p.waitForTimeout(500);
280 const spent = { key: or.key, credits: or.credits, said: await said() };
281
282 // ── The user topped up: found when the tab comes back ──
283 or.total = 140;
284 await vis('hidden');
285 await ff(FLOOR + MIN);
286 await vis('visible');
287 await p.waitForTimeout(600);
288 const toppedUp = { key: or.key, said: await said() };
289
290 // ── A hidden tab does not poll ──
291 await vis('hidden');
292 await ff(4 * FLOOR);
293 const whileHidden = { key: or.key };
294 await vis('visible');
295 await p.waitForTimeout(600);
296 const backAgain = { key: or.key, said: await said() };
297
298 // ── A probe that fails writes nothing, and says so ──
299 or.fail = true;
300 await ff(FLOOR + MIN);
301 await p.waitForTimeout(600);
302 const failed = { key: or.key, said: await said() };
303
304 // ── ...and the age goes on climbing, which is how you can tell ──
305 await ff(3 * 60 * MIN);
306 await p.waitForTimeout(400);
307 const aged = { said: await said() };
308
309 // ── A failing key backs off rather than retrying on the next beat ──
310 const beforeBackoff = or.key;
311 await ff(FLOOR + MIN);
312 const backoffHeld = { key: or.key, from: beforeBackoff };
313 await ff(60 * MIN);
314 const backoffFreed = { key: or.key };
315
316 // ── The user asks by hand: never held back by the floor ──
317 or.fail = false;
318 or.total = 200;
319 await p.evaluate(() => {
320 const w = document.querySelector('.models-credit[data-prov="openrouter"]');
321 const ask = w && w.querySelector(':scope > .models-refetch');
322 if (ask) ask.click();
323 });
324 await p.waitForTimeout(900);
325 const asked = { key: or.key, said: await said() };
326
327 const errs = s.errs.filter((e) => !/favicon|502|Failed to load resource/i.test(e));
328 await s.close();
329 return {
330 label, patched, or, fw, errs,
331 paste, opened, toggled, reopened, spent, toppedUp,
332 whileHidden, backAgain, failed, aged, backoffHeld, backoffFreed, asked,
333 };
334}
335
336/// The first money figure in a sentence, as a number.
337const amount = (s) => {
338 const m = String(s || '').match(/\$([0-9][0-9,]*\.?[0-9]*)/);
339 return m ? parseFloat(m[1].replace(/,/g, '')) : null;
340};
341const near = (a, b) => a !== null && Math.abs(a - b) < 0.005;
342
343// ── The claims, each one a function of a run's observations ────────────
344// Written once and asserted twice: TRUE of the shipped code, FALSE of the build whose patch
345// breaks it. A claim that holds on the broken build is a claim that was measuring something
346// else, and is reported as such.
347const CLAIMS = {
348 pasted: {
349 says: 'pasting a key still asks what is on it',
350 holds: (r) => r.paste.key === 1 && r.paste.credits === 1,
351 shows: (r) => `${r.paste.key} /key, ${r.paste.credits} /credits`,
352 },
353 shown: {
354 says: 'and the panel shows what it answered',
355 holds: (r) => near(amount(r.opened.said.line), 40) && /40/.test(r.opened.said.bal),
356 shows: (r) => `${r.opened.said.bal} | ${r.opened.said.line}`,
357 },
358 floorVisibility: {
359 says: 'FLOOR: away and back five times is not five probes',
360 breaks: 'floor',
361 holds: (r) => r.toggled.key === r.opened.key,
362 shows: (r) => `${r.opened.key} probe(s) before, ${r.toggled.key} after five round trips`,
363 },
364 floorPanel: {
365 says: 'FLOOR: opening the panel three more times is not three probes',
366 breaks: 'floor',
367 holds: (r) => r.reopened.key === r.opened.key,
368 shows: (r) => `${r.opened.key} before, ${r.reopened.key} after three re-opens`,
369 },
370 decrementCosts: {
371 says: 'SPENT: a turn moves the figure with NO request',
372 breaks: 'decrement',
373 holds: (r) => r.spent.key === r.reopened.key && r.spent.credits === r.paste.credits
374 && near(amount(r.spent.said.line), 37.5),
375 shows: (r) => `${r.spent.key} /key, ${r.spent.credits} /credits, "${r.spent.said.line}"`,
376 },
377 decrementAtOnce: {
378 says: 'SPENT: a finished turn moves it at once, without waiting for the beat',
379 breaks: 'idle',
380 holds: (r) => near(amount(r.spent.said.line), 37.5),
381 shows: (r) => r.spent.said.line,
382 },
383 decrementHead: {
384 says: 'SPENT: and the closed row never shows a different figure from the open one',
385 breaks: 'headsync',
386 holds: (r) => near(amount(r.spent.said.bal), amount(r.spent.said.line)),
387 shows: (r) => `head "${r.spent.said.bal}" vs block "${r.spent.said.line}"`,
388 },
389 decrementSays: {
390 says: 'SPENT: and the sentence stops claiming the provider said the new figure',
391 breaks: 'decrement',
392 holds: (r) => /40/.test(r.spent.said.line) && /2\.5/.test(r.spent.said.line),
393 shows: (r) => r.spent.said.line,
394 },
395 // No `breaks`: removing the floor makes the app probe MORE, so it finds the money too. What
396 // this claim is for is the other half — that coming back finds it with ONE request.
397 topUp: {
398 says: 'TOPPED UP: the tab coming back after the floor finds the money, in one request',
399 holds: (r) => r.toppedUp.key === r.opened.key + 1 && near(amount(r.toppedUp.said.line), 140),
400 shows: (r) => `${r.toppedUp.key} probe(s), "${r.toppedUp.said.line}"`,
401 },
402 freshNotDoubled: {
403 says: 'TOPPED UP: a fresh reading is not decremented by spending it already knew about',
404 holds: (r) => near(amount(r.toppedUp.said.line), 140),
405 shows: (r) => r.toppedUp.said.line,
406 },
407 hiddenQuiet: {
408 says: 'HIDDEN: four floors face down produces no request at all',
409 breaks: 'hidden',
410 holds: (r) => r.whileHidden.key === r.toppedUp.key,
411 shows: (r) => `${r.toppedUp.key} before hiding, ${r.whileHidden.key} after four floors hidden`,
412 },
413 hiddenThenBack: {
414 says: 'HIDDEN: and coming back asks once',
415 holds: (r) => r.backAgain.key === r.whileHidden.key + 1,
416 shows: (r) => `${r.whileHidden.key} -> ${r.backAgain.key}`,
417 },
418 failKeeps: {
419 says: 'FAILED: a probe that errors leaves the figure exactly where it was',
420 holds: (r) => near(amount(r.failed.said.line), amount(r.backAgain.said.line))
421 && r.failed.key > r.backAgain.key,
422 shows: (r) => `asked ${r.backAgain.key}->${r.failed.key}, "${r.failed.said.line}"`,
423 },
424 failSays: {
425 says: 'FAILED: and the age line says the last check did not answer',
426 breaks: 'age',
427 holds: (r) => /did not answer/i.test(r.failed.said.age) && r.failed.said.ageStale,
428 shows: (r) => `"${r.failed.said.age}" loud=${r.failed.said.ageStale}`,
429 },
430 failMarksClosedRow: {
431 says: 'FAILED: and the mark on the closed row keeps up without a redraw',
432 breaks: 'age',
433 holds: (r) => r.failed.said.stale && /did not answer/i.test(r.failed.said.tip),
434 shows: (r) => `mark=${r.failed.said.stale} tip="${r.failed.said.tip}"`,
435 },
436 failAges: {
437 says: 'FAILED: and the age goes on climbing, so the user can see it has stopped',
438 breaks: 'age',
439 holds: (r) => /minute/i.test(r.failed.said.age) && /hour/i.test(r.aged.said.age),
440 shows: (r) => `"${r.failed.said.age}" then "${r.aged.said.age}"`,
441 },
442 backoff: {
443 says: 'BACKOFF: a failing key waits longer than the floor before it is asked again',
444 breaks: 'floor',
445 holds: (r) => r.backoffHeld.key === r.backoffHeld.from && r.backoffFreed.key > r.backoffHeld.key,
446 shows: (r) => `${r.backoffHeld.from} -> ${r.backoffHeld.key} after a floor, -> ${r.backoffFreed.key} after twelve`,
447 },
448 byHand: {
449 says: 'BY HAND: "Ask again" is never held back, and picks up the new figure',
450 holds: (r) => r.asked.key === r.backoffFreed.key + 1 && near(amount(r.asked.said.line), 200),
451 shows: (r) => `${r.backoffFreed.key} -> ${r.asked.key}, "${r.asked.said.line}"`,
452 },
453 nowhere: {
454 says: 'NOWHERE: a provider with no such endpoint is never asked, ever',
455 breaks: 'anyone',
456 holds: (r) => r.fw.hits === 0,
457 shows: (r) => `${r.fw.hits} request(s) to api.fireworks.ai`,
458 },
459 quiet: {
460 says: 'and none of it throws',
461 holds: (r) => r.errs.length === 0,
462 shows: (r) => r.errs.slice(0, 2).join(' | '),
463 },
464};
465
466// ── The shipped build ──────────────────────────────────────────────────
467console.log('\n── the shipped build ──');
468const green = await scenario('green', null);
469for (const [k, c] of Object.entries(CLAIMS)) check(c.says, c.holds(green), c.shows(green));
470
471// ── The broken builds ──────────────────────────────────────────────────
472// One per patch, and in each the claims that name it must FAIL. Anything else it breaks is
473// reported but not counted: a patch is allowed collateral damage, it is not allowed to leave
474// its own claim standing.
475for (const [name, b] of Object.entries(BREAK)) {
476 const want = Object.entries(CLAIMS).filter(([, c]) => c.breaks === name);
477 if (!want.length) { console.log(` FAIL the "${name}" patch proves nothing: no claim names it`); bad.push(name); continue; }
478 console.log(`\n── broken on purpose: ${b.what} ──`);
479 const run = await scenario(name, b.patches);
480 check(`the "${name}" patch reached the browser`, run.patched >= b.patches.length,
481 `${run.patched} of ${b.patches.length} applied`);
482 for (const [, c] of want) {
483 check(`RED: "${c.says}" fails when ${b.what}`, !c.holds(run), c.shows(run));
484 }
485}
486
487console.log(`\n${bad.length ? `verify_creditage: ${bad.length} FAILED` : 'verify_creditage: all checks pass.'}`);
488if (bad.length) { bad.forEach((b) => console.log(' - ' + b)); process.exit(1); }