blockyard 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +239 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1051 -0
  32. package/public/js/scorchedai.js +227 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1338 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +21 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. package/systemd/blockyard.service +34 -3
package/scripts/setup.js CHANGED
@@ -5,10 +5,16 @@
5
5
  // monitor -- so a new machine goes from a clone to running and indexing in one sitting.
6
6
  //
7
7
  // npm run setup # interactive
8
- // node scripts/setup.js --yes [--rpc-url URL] [--datadir DIR] [--label L] [--rpc-user U --rpc-password P]
8
+ // node scripts/setup.js --yes [--rpc-url URL] [--datadir DIR] [--label L]
9
+ // [--rpc-user U (--rpc-password-file PATH | --rpc-password - | --rpc-password P)]
9
10
  // [--host 127.0.0.1] [--port 21000] [--index-dir DIR] [--workers N]
10
11
  // [--build-here | --build-later] [--start] [--force]
11
12
  //
13
+ // The RPC password (2026-09-16, audit L12): --rpc-password-file PATH reads the first line of a
14
+ // file, --rpc-password - reads the first line of stdin; --rpc-password P still works but warns,
15
+ // because P is then visible to every user of the machine in `ps` and stays in shell history.
16
+ // Typed at the prompt, it is not echoed.
17
+ //
12
18
  // --yes takes every default without asking (a scripted install); --force replaces an existing
13
19
  // config/local.json (a backup is kept either way); --start boots the monitor at the end without
14
20
  // asking. The index is built by BlockYard itself, in the background, once it starts (the Overview shows
@@ -16,7 +22,7 @@
16
22
  // --build-later writes addressIndexBuild: "manual" so nothing builds until you run index-build.js.
17
23
  // The written file is mode 0600: it may carry an RPC password. Nothing here touches the
18
24
  // node: every call is a read. (operator, 2026-09-14: "Make this npm installer absolutely beautiful")
19
- import { existsSync, statSync, writeFileSync, copyFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
25
+ import { existsSync, statSync, writeFileSync, chmodSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
26
  import os from 'node:os';
21
27
  import net from 'node:net';
22
28
  import http from 'node:http';
@@ -58,18 +64,94 @@ export function localConfig(a) {
58
64
  if (a.indexDir) node.addressIndex = a.indexDir;
59
65
  if (a.indexBuild === 'manual') node.addressIndexBuild = 'manual'; // the server builds a missing index on start unless told not to
60
66
  if (Number.isInteger(a.workers) && a.workers > 0) node.addressIndexWorkers = a.workers; // the server's background build uses the same number
67
+ // What the node told us it can do, from bmcgetcapabilities (scripts/check.js).
68
+ // A SNAPSHOT, written so the server knows on its first tick without a probe --
69
+ // not a source of truth. These are live settings on the node: an operator can
70
+ // enable the journal or the facade tomorrow, so the server re-reads them and
71
+ // this only saves the first round trip. Absent for a Core node, which is how
72
+ // the server tells the two apart.
73
+ if (a.nodeKind && a.nodeKind !== 'core') {
74
+ node.nodeKind = a.nodeKind;
75
+ if (a.nodeCapabilities) node.nodeCapabilities = a.nodeCapabilities;
76
+ }
61
77
  return { server: { host: a.host, port: Number(a.port) }, nodes: [node] };
62
78
  }
63
79
 
64
80
  /** Write it, keeping a dated copy of whatever was there. Refuses an existing file unless `force`. */
65
- export function writeLocalConfig(file, cfg, { force = false, now = new Date() } = {}) {
81
+ export function writeLocalConfig(file, cfg, { force = false, now = new Date(), platform = process.platform } = {}) {
66
82
  if (existsSync(file)) {
67
83
  if (!force) throw new Error(`${file} exists; pass --force (or answer yes) to replace it`);
68
84
  const bak = `${file}.bak-${now.toISOString().replace(/[:.]/g, '').slice(0, 15)}`;
69
- copyFileSync(file, bak);
85
+ // THE BACKUP IS OWNER-ONLY TOO (2026-09-16, audit I5): it holds the same RPC password, and
86
+ // copyFileSync kept whatever mode the old file had -- 0644 on a config written by hand. It is
87
+ // written fresh at 0600, and chmod'ed in case a backup of that name was already there.
88
+ writeFileSync(bak, readFileSync(file), { mode: 0o600 });
89
+ if (platform !== 'win32') chmodSync(bak, 0o600);
70
90
  }
71
91
  mkdirSync(path.dirname(file), { recursive: true });
72
92
  writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
93
+ // `mode` above applies only when the file is CREATED (2026-09-16, audit L12): --force over an
94
+ // existing 0644 local.json kept 0644 while putting a password in it. Windows has no such bits.
95
+ if (platform !== 'win32') chmodSync(file, 0o600);
96
+ }
97
+
98
+ /**
99
+ * WHERE THE RPC PASSWORD COMES FROM ON THE COMMAND LINE (2026-09-16, audit L12). `--rpc-password P`
100
+ * puts P in `ps` for every user of the machine and in the shell's history, so two ways that do
101
+ * not: `--rpc-password-file PATH` (the first line of the file) and `--rpc-password -` (the first
102
+ * line of stdin; needs --yes, since stdin is then not a terminal to ask on). The plain form still
103
+ * works, with a warning. Returns { password, source, warning } -- password null when none is named.
104
+ */
105
+ export function rpcPasswordFrom(argv, { readFile = (p) => readFileSync(p, 'utf8'), readStdin = () => readFileSync(0, 'utf8') } = {}) {
106
+ const val = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] != null && (argv[i + 1] === '-' || !argv[i + 1].startsWith('--')) ? argv[i + 1] : null; };
107
+ const firstLine = (text) => String(text).split(/\r?\n/)[0];
108
+ const file = val('rpc-password-file'), plain = val('rpc-password');
109
+ if (file != null && plain != null) throw new Error('--rpc-password and --rpc-password-file name the password twice; give one');
110
+ if (file != null) {
111
+ let text;
112
+ try { text = readFile(expand(file)); } catch (err) { throw new Error(`--rpc-password-file ${file}: ${err.code ?? err.message}`); }
113
+ return { password: firstLine(text), source: 'file', warning: null };
114
+ }
115
+ if (plain === '-') return { password: firstLine(readStdin()), source: 'stdin', warning: null };
116
+ if (plain != null) {
117
+ return { password: plain, source: 'argv',
118
+ warning: '--rpc-password on the command line is visible to other users of this machine (ps) and stays in your shell history; use --rpc-password-file PATH, or --rpc-password - to read it from stdin' };
119
+ }
120
+ return { password: null, source: null, warning: null };
121
+ }
122
+
123
+ /**
124
+ * A PROMPT THAT DOES NOT ECHO (2026-09-16, audit L12): readline's question() shows every character
125
+ * typed, so the "secret" rpcPassword prompt used to hide only its default. The same approach as
126
+ * scripts/manage-users.js: raw mode on a terminal, the characters collected by hand, nothing
127
+ * written back but the newline. Backspace edits, Ctrl-C calls `onInterrupt`. The caller closes
128
+ * any readline interface on `input` first, so it does not echo the keys itself.
129
+ */
130
+ export function readHidden(question, { input = stdin, output = stdout, onInterrupt = () => process.exit(130) } = {}) {
131
+ return new Promise((resolve) => {
132
+ output.write(question);
133
+ const raw = Boolean(input.isTTY && input.setRawMode);
134
+ if (raw) input.setRawMode(true);
135
+ let buf = '';
136
+ input.setEncoding?.('utf8');
137
+ const finish = () => {
138
+ input.removeListener('data', onData);
139
+ if (raw) input.setRawMode(false);
140
+ input.pause();
141
+ output.write('\n');
142
+ };
143
+ // a chunk may hold many characters (a paste, or a pipe), so each is looked at on its own
144
+ const onData = (chunk) => {
145
+ for (const ch of String(chunk)) {
146
+ if (ch === '\n' || ch === '\r') { finish(); resolve(buf); return; }
147
+ if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1); // DEL is what backspace sends on a tty
148
+ else if (ch === '\u0003') { finish(); onInterrupt(); return; }
149
+ else buf += ch;
150
+ }
151
+ };
152
+ input.on('data', onData);
153
+ input.resume();
154
+ });
73
155
  }
74
156
 
75
157
  // ------------------------------------------------------------------------- what an answer must be
@@ -201,8 +283,12 @@ export async function portInUse(host, port) {
201
283
 
202
284
  // ------------------------------------------------------------------------------------ the flow
203
285
  async function main() {
204
- if (!YES && !stdin.isTTY) { console.error('no terminal to ask on: pass --yes with the --rpc-url/--datadir flags (see the header of scripts/setup.js)'); process.exit(2); }
205
- const rl = YES ? null : readline.createInterface({ input: stdin, output: stdout });
286
+ if (!YES && !stdin.isTTY) { console.error('no terminal to ask on: pass --yes with the --rpc-url/--datadir flags (see the header of scripts/setup.js; --rpc-password - reads stdin only with --yes)'); process.exit(2); }
287
+ // read once, before anything else touches stdin (2026-09-16, audit L12)
288
+ let pw;
289
+ try { pw = rpcPasswordFrom(argv); } catch (err) { console.error(c.bad(err.message)); process.exit(2); }
290
+ if (pw.warning) console.error(`${c.warn('!')} ${pw.warning}`);
291
+ let rl = YES ? null : readline.createInterface({ input: stdin, output: stdout });
206
292
  const out = (s = '') => stdout.write(`${s}\n`);
207
293
  // every line the installer says fits the terminal (operator: 80 columns, "Standard CRT")
208
294
  const say = (s) => out(` ${wrapText(s, cols() - 4, 4)}`);
@@ -215,7 +301,14 @@ async function main() {
215
301
  if (YES) raw = def;
216
302
  else {
217
303
  const shown = def != null && def !== '' && !secret ? ` ${c.dim(`(${def})`)}` : '';
218
- raw = (await rl.question(` ${q} ${label}${shown} ${c.dim('›')} `)).trim();
304
+ const prompt = ` ${q} ${label}${shown} ${c.dim('›')} `;
305
+ if (secret) {
306
+ // not echoed (2026-09-16, audit L12): readline would echo the keys itself, so it is
307
+ // closed for the one answer and opened again after
308
+ rl.close();
309
+ raw = (await readHidden(`${prompt}${c.dim('(not shown) ')}`, { onInterrupt: () => process.emit('SIGINT') })).trim();
310
+ rl = readline.createInterface({ input: stdin, output: stdout });
311
+ } else raw = (await rl.question(prompt)).trim();
219
312
  if (raw === '') raw = def;
220
313
  }
221
314
  const r = check ? check(raw ?? '') : { value: raw };
@@ -238,7 +331,7 @@ async function main() {
238
331
  let building = null;
239
332
  process.on('SIGINT', () => {
240
333
  out(); out();
241
- if (building) say(c.warn(`stopped. The index in ${building} is unfinished: run the build again (it starts over) before pointing BlockYard at it.`));
334
+ if (building) say(c.warn(`stopped. The index in ${building} is unfinished: run the build again (it resumes where it stopped) before pointing BlockYard at it.`));
242
335
  else say(c.dim('stopped; nothing written.'));
243
336
  process.exit(130);
244
337
  });
@@ -273,7 +366,7 @@ async function main() {
273
366
  const port = conf.values.rpcport ?? RPC_PORT[conf.chain] ?? 8332;
274
367
  a.rpcUrl = await ask('RPC URL', arg('rpc-url', `http://${host}:${port}`), validate.rpcUrl);
275
368
  a.label = await ask('a label for the node', arg('label', conf.chain === 'main' ? 'Bitcoin Core' : `Bitcoin Core (${conf.chain})`), validate.label);
276
- a.rpcUser = arg('rpc-user'); a.rpcPassword = arg('rpc-password');
369
+ a.rpcUser = arg('rpc-user'); a.rpcPassword = pw.password;
277
370
  a.cookieFile = conf.values.rpccookiefile ? (path.isAbsolute(conf.values.rpccookiefile) ? conf.values.rpccookiefile : path.join(a.datadir, conf.values.rpccookiefile)) : null;
278
371
  const cookie = resolveCookie({ datadir: a.datadir, chainHint: a.chain, cookieFile: a.cookieFile ?? undefined });
279
372
  if (cookie && cookie.source !== 'config') say(`${c.ok('✓')} cookie found: ${c.dim(cookie.source)}`);
@@ -284,7 +377,7 @@ async function main() {
284
377
  const who = conf.rpcauthUsers[0] ?? '';
285
378
  say(`${c.warn('!')} no .cookie readable under ${a.datadir}${who ? `; ${shortPath(conf.file)} has rpcauth for "${who}", whose password is not in the file` : ': a node authenticating with rpcauth needs a user and password'}`);
286
379
  a.rpcUser = await ask('rpcUser', who, null);
287
- if (a.rpcUser) a.rpcPassword = await ask('rpcPassword', '', null, { secret: true });
380
+ if (a.rpcUser && a.rpcPassword == null) a.rpcPassword = await ask('rpcPassword', '', null, { secret: true });
288
381
  }
289
382
 
290
383
  out(step(2, STEPS, 'Checking the node'));
@@ -293,6 +386,10 @@ async function main() {
293
386
  result = await runChecks(node, { rpc: clientFor(node, defaults) });
294
387
  if (result.facts.chain && result.facts.chain !== a.chain) { a.chain = result.facts.chain; node.chainHint = a.chain; result = await runChecks(node, { rpc: clientFor(node, defaults) }); }
295
388
  spin.stop();
389
+ // Carry what the node said about itself into the answers, so localConfig
390
+ // can record it below. A Core node reports nodeKind 'core' and nothing is
391
+ // written -- absence is the signal.
392
+ if (result.facts.nodeKind) { a.nodeKind = result.facts.nodeKind; a.nodeCapabilities = result.facts.nodeCapabilities ?? null; }
296
393
  for (const ch of result.checks) out(checkLine(ch.status, ch.name, ch.detail));
297
394
  out();
298
395
  if (result.ok) { say(c.ok(c.bold('everything this needs is there'))); break; }
@@ -385,6 +482,7 @@ async function main() {
385
482
  const manifest = await buildIndex({
386
483
  rpc, blocksDir: path.join(node.datadir, 'blocks'), out: a.indexDir, workers: a.workers,
387
484
  pace: rpcPacer(rpc, { onChange: (held) => bar.done(held ? c.dim(' paused while the node\'s RPC is slow or failing') : c.dim(' resumed')) }),
485
+ log: (text) => bar.done(c.dim(` ${text}`)),
388
486
  onProgress: (p) => {
389
487
  if (p.phase !== phase) {
390
488
  if (phase) bar.done(strip(progressLine({ phase, done: 1, total: 1, elapsed: (Date.now() - phaseStart) / 1000 })));
package/scripts/shots.mjs CHANGED
@@ -230,6 +230,27 @@ if (doing('tetrust')) {
230
230
  } catch (e) { fail('tetrust', e); }
231
231
  }
232
232
 
233
+ // ------------------------------------------------------------- scorched yard
234
+ // a game against the computer, a nuke on its way in from You (the shot is set up through the
235
+ // screen's currentGame hook so the picture has a blast in it rather than an empty field)
236
+ if (doing('scorched')) {
237
+ try {
238
+ await evl(`document.getElementById('navDivBtn')?.click()`);
239
+ await sleep(700);
240
+ const ok = await evl(`(() => { const b=document.querySelector('button[data-page="scorched"]'); if(!b) return false; b.click(); return true; })()`);
241
+ if (!ok) throw new Error('no scorched button behind Diversions');
242
+ await sleep(2500);
243
+ await metrics(1000);
244
+ await evl(`document.getElementById('syResume')?.click()`);
245
+ await sleep(1500);
246
+ await evl(`import('/js/scorchedyard.js').then(async (m) => { const g = m.currentGame(); const t = g.tanks[0]; const ai = await import('/js/scorchedai.js'); const target = ai.nearest(g, t); t.inventory.nuke = 1; t.weapon = 'nuke'; const s = ai.spoiler(g, t, () => 0.9, target); t.angle = s.angle; t.power = s.power; })`);
247
+ await sleep(300);
248
+ await evl(`document.getElementById('syFire')?.click()`);
249
+ await sleep(2400);
250
+ done.push(await shoot('scorched', { settle: 0, height: 1000 }));
251
+ } catch (e) { fail('scorched', e); }
252
+ }
253
+
233
254
  // -------------------------------------------------- neon: LAST, and it is restored
234
255
  if (doing('block-space-neon')) {
235
256
  let toggled = false;
package/scripts/smoke.sh CHANGED
@@ -59,8 +59,8 @@ echo "== booting server on :${PORT} (fake node :${FAKE_PORT}) =="
59
59
  # choices -- including which addresses to bind -- and inheriting them made this script
60
60
  # curl 127.0.0.1 against a server listening elsewhere (54 failures, 2 passes).
61
61
  # BLOCKYARD_BIND: pin it explicitly anyway, so a future default cannot repeat that.
62
- # BLOCKYARD_AUTH=1: accounts are OFF by default now, and this script's bulk is the
63
- # signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
62
+ # BLOCKYARD_AUTH=1: accounts are ON by default (since 2026-09-15); set explicitly anyway, since
63
+ # this script's bulk is the signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
64
64
  # own instance further down, on its own port, so both are asserted rather than one
65
65
  # replacing the other.
66
66
  BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 BLOCKYARD_TLS=0 \
@@ -282,8 +282,9 @@ check "the breaker names its threshold" "$(echo "$TEL" | grep -c '"threshold":3'
282
282
  check "telemetry reports the audit log too" "$(echo "$TEL" | grep -c '"rotationError"')" "1"
283
283
  check "the peer identity table has data to draw" "$(curl -s -b "$DIR/ck" "$BASE/api/peers" | grep -c '"identity"')" "1"
284
284
 
285
- echo "== open access: the default posture, no sign-in =="
286
- # Accounts are OFF by default. A second instance on its own port asserts what that
285
+ echo "== open access: the opt-in posture, no sign-in =="
286
+ # Accounts are ON by default (since 2026-09-15); open access is chosen with BLOCKYARD_AUTH=0,
287
+ # which this second instance sets. On its own port it asserts what that
287
288
  # grants (reads, the read-only RPC console, the stream) and, more importantly, what it
288
289
  # does NOT: user admin, the audit trail, node writes. Keeping this a separate boot is
289
290
  # the point -- the signed-in checks above must not be quietly replaced by open ones.
@@ -291,7 +292,7 @@ OPEN_PORT="${BLOCKYARD_SMOKE_OPEN_PORT:-18199}"
291
292
  OPEN_DIR="$(mktemp -d /tmp/blockyard-open.XXXXXX)"
292
293
  OPID=""
293
294
  # OPID is reaped by the single cleanup trap at the top
294
- BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 \
295
+ BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=0 BLOCKYARD_TLS=0 \
295
296
  BLOCKYARD_DATA="$OPEN_DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$OPEN_PORT" \
296
297
  BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$((FAKE_PORT + 1))" node server/main.js >"$OPEN_DIR/server.log" 2>&1 &
297
298
  OPID=$!
package/scripts/ui.js CHANGED
@@ -143,12 +143,14 @@ const G = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} B` : n >= 1e6 ? `${(n / 1e
143
143
  * One line of a progress bar: `phase ████░░ done/total · rows · rate · ETA`. `elapsed` in seconds
144
144
  * since the phase began; the ETA assumes the rate so far holds. Pure, so it is tested.
145
145
  */
146
- export function progressLine({ phase, done, total, rows = null, elapsed = 0 }, width = cols()) {
146
+ // `from` is where the phase started this run: a resumed build begins part way, and its rate (and so
147
+ // the time left) is what this run has done, not what an earlier run did
148
+ export function progressLine({ phase, done, total, from = 0, rows = null, elapsed = 0 }, width = cols()) {
147
149
  const frac = total > 0 ? Math.min(1, done / total) : 0;
148
150
  const barW = Math.max(10, Math.min(30, width - 62));
149
151
  const filled = Math.round(frac * barW);
150
152
  const bar = c.accent('█'.repeat(filled)) + c.dim('░'.repeat(barW - filled));
151
- const rate = elapsed > 0 && done > 0 ? done / elapsed : 0;
153
+ const rate = elapsed > 0 && done > from ? (done - from) / elapsed : 0;
152
154
  const eta = rate > 0 && total > done ? HMS((total - done) / rate) : null;
153
155
  const bits = [`${String(done).padStart(String(total).length)}/${total}`, `${Math.round(frac * 100)}%`];
154
156
  if (rows != null) bits.push(`${G(rows)} rows`);
@@ -140,9 +140,18 @@ export class RateLimiter {
140
140
  }
141
141
  }
142
142
 
143
- // Failed-login tracking, keyed on BOTH username and address: a spray across many
144
- // usernames from one host and a grind on one username from many hosts are
145
- // different attacks and both need to be visible.
143
+ // Failed-login tracking. Two attacks need to be visible: a grind on one account, and a spray across
144
+ // many accounts from one host.
145
+ //
146
+ // THE LOCK IS PER (USERNAME, ADDRESS) PAIR, NOT PER USERNAME (audit 2026-09-16, L14). The first cut
147
+ // locked the username itself after 8 failures from anywhere, and the address after 8 failures for
148
+ // any account: so one person behind a shared NAT or proxy could lock every account for everyone
149
+ // behind it, and anyone who knew a username could lock its owner out from any address. Now:
150
+ // * 8 failures for one username from one address lock that pair;
151
+ // * 5x that from one address, across every username, lock the address (a spray);
152
+ // * 10x that for one username, across every address, lock the username (a distributed grind) --
153
+ // a threshold only a real attack reaches, where locking the account is the right trade.
154
+ // The per-address throttle in front of the KDF (main.js loginLimiter) still paces all of it.
146
155
  export class LoginGuard {
147
156
  constructor({ maxAttempts = 8, windowMs = 300000, lockoutMs = 600000 } = {}) {
148
157
  this.maxAttempts = maxAttempts;
@@ -159,24 +168,35 @@ export class LoginGuard {
159
168
  return e;
160
169
  }
161
170
 
171
+ _keys(username, ip) {
172
+ return [
173
+ [`p:${username}\u0000${ip}`, this.maxAttempts],
174
+ [`i:${ip}`, this.maxAttempts * 5],
175
+ [`u:${username}`, this.maxAttempts * 10],
176
+ ];
177
+ }
178
+
162
179
  status(username, ip) {
163
- const a = this._entry(`u:${username}`);
164
- const b = this._entry(`i:${ip}`);
165
- const locked = Math.max(a.lockedUntil, b.lockedUntil);
180
+ let locked = 0, remaining = Infinity, worst = 0;
181
+ for (const [key, max] of this._keys(username, ip)) {
182
+ const e = this._entry(key);
183
+ locked = Math.max(locked, e.lockedUntil);
184
+ worst = Math.max(worst, e.hits.length);
185
+ remaining = Math.min(remaining, Math.max(0, max - e.hits.length));
186
+ }
166
187
  if (locked > Date.now()) return { blocked: true, retryAfterMs: locked - Date.now() };
167
- const worst = Math.max(a.hits.length, b.hits.length);
168
- return { blocked: false, attempts: worst, remaining: Math.max(0, this.maxAttempts - worst) };
188
+ return { blocked: false, attempts: worst, remaining };
169
189
  }
170
190
 
171
191
  noteFailure(username, ip) {
172
192
  const out = [];
173
- for (const key of [`u:${username}`, `i:${ip}`]) {
193
+ for (const [key, max] of this._keys(username, ip)) {
174
194
  const e = this._entry(key);
175
195
  e.hits.push(Date.now());
176
- if (e.hits.length >= this.maxAttempts) {
196
+ if (e.hits.length >= max) {
177
197
  e.lockedUntil = Date.now() + this.lockoutMs;
178
198
  e.hits = [];
179
- out.push(key);
199
+ out.push(key.startsWith('p:') ? `u:${username}@i:${ip}` : key);
180
200
  }
181
201
  this.attempts.set(key, e);
182
202
  }
@@ -184,8 +204,8 @@ export class LoginGuard {
184
204
  }
185
205
 
186
206
  noteSuccess(username, ip) {
187
- this.attempts.delete(`u:${username}`);
188
- this.attempts.delete(`i:${ip}`);
207
+ // success clears this pair only: it proves nothing about other addresses guessing this account
208
+ this.attempts.delete(`p:${username}\u0000${ip}`);
189
209
  }
190
210
  }
191
211
 
@@ -77,15 +77,49 @@ export function* records(buf, magic = MAGIC.main, trailer = 0, key = null) {
77
77
 
78
78
  // --- undo data -----------------------------------------------------------
79
79
  // Core's VARINT (serialize.h), which is NOT CompactSize: MSB base-128 with an offset per byte.
80
- function readVarInt(buf, st) {
80
+ // Core reads each field at a fixed width and throws "ReadVarInt(): size too large" past it. The
81
+ // height code, the version placeholder and the script size are uint32, so a longer run of
82
+ // continuation bytes is corruption, not a large number -- and refusing it keeps every value an exact
83
+ // double (a flipped byte used to hand back heights and amounts past 2^53, rounded).
84
+ function readVarInt(buf, st, max = 0xffffffff) {
85
+ const limit = Math.floor(max / 128);
81
86
  let n = 0;
82
87
  for (;;) {
83
88
  const b = buf[st.pos++];
84
89
  if (b === undefined) throw new RangeError('truncated VARINT');
90
+ if (n > limit) throw new RangeError('VARINT too large');
91
+ n = n * 128 + (b & 0x7f);
92
+ if (b & 0x80) { if (n === max) throw new RangeError('VARINT too large'); n += 1; } else return n;
93
+ }
94
+ }
95
+ // THE AMOUNT IS A uint64 VARINT, AND IT CAN PASS 2^53 FOR A LEGAL AMOUNT. CompressAmount makes an
96
+ // amount that does not end in a zero about 9x larger, so from ~1e15 satoshis (10M BTC in one output,
97
+ // never seen, but in range) the compressed value no longer fits a double: 2,099,999,999,999,999 sat
98
+ // compresses to 18,899,999,999,999,991, which rounds to ...992, and came back as 209,999,999,999,999,940
99
+ // -- a hundred times too much, because the rounding moved the exponent digit
100
+ // (test/chain-decode-property.test.js). Every real amount stays on the number path; past 2^46,
101
+ // where one more byte could round, the rest is read in BigInt, to Core's uint64 limit.
102
+ const U64 = 0xffffffffffffffffn;
103
+ function readAmountVarInt(buf, st) {
104
+ let n = 0;
105
+ for (;;) {
106
+ const b = buf[st.pos];
107
+ if (b === undefined) throw new RangeError('truncated VARINT');
108
+ if (n > 0x3fffffffffff) return readBigVarInt(buf, st, BigInt(n));
109
+ st.pos++;
85
110
  n = n * 128 + (b & 0x7f);
86
111
  if (b & 0x80) n += 1; else return n;
87
112
  }
88
113
  }
114
+ function readBigVarInt(buf, st, n) {
115
+ for (;;) {
116
+ const b = buf[st.pos++];
117
+ if (b === undefined) throw new RangeError('truncated VARINT');
118
+ if (n > U64 >> 7n) throw new RangeError('VARINT too large');
119
+ n = (n << 7n) | BigInt(b & 0x7f);
120
+ if (b & 0x80) { if (n === U64) throw new RangeError('VARINT too large'); n += 1n; } else return n;
121
+ }
122
+ }
89
123
  function readCompactSize(buf, st) {
90
124
  const b = buf[st.pos++];
91
125
  if (b < 0xfd) return b;
@@ -94,8 +128,21 @@ function readCompactSize(buf, st) {
94
128
  const v = Number(buf.readBigUInt64LE(st.pos)); st.pos += 8; return v;
95
129
  }
96
130
 
97
- // compressor.cpp DecompressAmount
131
+ // compressor.cpp DecompressAmount. `x` is a number, or a BigInt when its VARINT ran past 2^46 (see
132
+ // readAmountVarInt). The result is an exact number of satoshis, or a RangeError past 2^53 -- far
133
+ // beyond 21M BTC, so only corrupt data gets there, and Reader.u64 refuses such an amount the same way.
98
134
  export function decompressAmount(x) {
135
+ if (typeof x === 'bigint') {
136
+ if (x === 0n) return 0;
137
+ x -= 1n;
138
+ let e = x % 10n, n;
139
+ x /= 10n;
140
+ if (e < 9n) { const d = (x % 9n) + 1n; x /= 9n; n = x * 10n + d; } else { n = x + 1n; }
141
+ while (e > 0n) { n *= 10n; e--; }
142
+ if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError(`amount ${n} is past the safe integer range`);
143
+ return Number(n);
144
+ }
145
+ if (x > Number.MAX_SAFE_INTEGER) return decompressAmount(BigInt(x));
99
146
  if (x === 0) return 0;
100
147
  x -= 1;
101
148
  let e = x % 10;
@@ -103,6 +150,7 @@ export function decompressAmount(x) {
103
150
  let n;
104
151
  if (e < 9) { const d = (x % 9) + 1; x = Math.floor(x / 9); n = x * 10 + d; } else { n = x + 1; }
105
152
  while (e > 0) { n *= 10; e--; }
153
+ if (n > Number.MAX_SAFE_INTEGER) throw new RangeError(`amount ${n} is past the safe integer range`);
106
154
  return n;
107
155
  }
108
156
 
@@ -119,7 +167,12 @@ function decompressPubkey(prefix, x32) {
119
167
  // compressor.cpp: sizes 0-5 are special script templates, 6+ a raw script of (size - 6) bytes
120
168
  function readCompressedScript(buf, st) {
121
169
  const size = readVarInt(buf, st);
122
- const take = (n) => { const v = buf.subarray(st.pos, st.pos + n); st.pos += n; return v; };
170
+ // bounds-checked: a slice past the end used to come back short and silently, the record failing
171
+ // only later with a nonsense "-N bytes left" (or, for templates 4 and 5, a key rebuilt from a short x)
172
+ const take = (n) => {
173
+ if (st.pos + n > buf.length) throw new RangeError(`truncated script: need ${n} bytes at ${st.pos} of ${buf.length}`);
174
+ const v = buf.subarray(st.pos, st.pos + n); st.pos += n; return v;
175
+ };
123
176
  switch (size) {
124
177
  case 0: return Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), take(20), Buffer.from([0x88, 0xac])]);
125
178
  case 1: return Buffer.concat([Buffer.from([0xa9, 0x14]), take(20), Buffer.from([0x87])]);
@@ -144,7 +197,7 @@ export function decodeBlockUndo(body) {
144
197
  const code = readVarInt(body, st);
145
198
  const height = Math.floor(code / 2);
146
199
  if (height > 0) readVarInt(body, st); // the legacy nVersion placeholder, always 0
147
- const value_sat = decompressAmount(readVarInt(body, st));
200
+ const value_sat = decompressAmount(readAmountVarInt(body, st));
148
201
  const script = readCompressedScript(body, st);
149
202
  coins[j] = { height, coinbase: (code & 1) === 1, value_sat, script };
150
203
  }
@@ -165,11 +218,17 @@ export function undoShape(body) {
165
218
  for (let j = 0; j < nin; j++) {
166
219
  const height = Math.floor(readVarInt(body, st) / 2);
167
220
  if (height > 0) readVarInt(body, st);
168
- readVarInt(body, st);
221
+ readAmountVarInt(body, st);
169
222
  const size = readVarInt(body, st);
170
223
  st.pos += size === 0 || size === 1 ? 20 : size < 6 ? 32 : size - 6;
171
224
  }
172
225
  }
226
+ // SKIPPING IS NOT READING: script bytes are stepped over, never touched, so a record cut in the
227
+ // middle of its last script returned the full counts as if whole (found by truncating random
228
+ // records at every offset). pairBlocksWithUndo matches on this shape, so the end is checked here
229
+ // exactly as decodeBlockUndo checks it.
230
+ if (st.pos > body.length) throw new RangeError(`truncated undo record: need ${st.pos} bytes, have ${body.length}`);
231
+ if (st.pos !== body.length) throw new RangeError(`${body.length - st.pos} bytes left after the undo record`);
173
232
  return counts;
174
233
  }
175
234