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
@@ -9,7 +9,7 @@
9
9
  // the same sky (drawSky). The game inherits the framework rather than imitating it.
10
10
  import { board3d } from './details3d.js';
11
11
  import { newGame, tick, move, rotate, softDrop, hardDrop, tiles, previewTiles, peekNext, gravityMs, PIECES, COLS, ROWS } from './tetris.js';
12
- import { loadSettings, setSetting, tetrustOptions } from './settings.js';
12
+ import { loadSettings, setSetting, tetrustOptions, nextSky, SKY_LABELS } from './settings.js';
13
13
  import * as sound from './tetsound.js';
14
14
 
15
15
  const SCORES_KEY = 'blockyard.tetrust.scores';
@@ -173,9 +173,20 @@ function drawScores(highlightAt = null) {
173
173
 
174
174
  // THE GAME'S OWN SWITCHES (operator: "Toggle for each in the game display"): three buttons on the
175
175
  // HUD, the same settings the Display panel shows, so a flip in either place is a flip in both
176
- const SWITCHES = [['tetStars', 'stars'], ['tetGalaxy', 'galaxy'], ['tetMusic', 'music'], ['tetSfx', 'sfx']];
176
+ const SWITCHES = [['tetMusic', 'music'], ['tetSfx', 'sfx']];
177
+ /** The sky button says which sky it is on, and lights up while there is one. */
178
+ function skyButton(id, sky) {
179
+ const b = el(id);
180
+ if (!b) return;
181
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
182
+ b.classList.toggle('on', sky !== 'none');
183
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
184
+ }
177
185
  function drawSwitches() {
178
186
  const t = tetrustOptions(loadSettings());
187
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
188
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
189
+ skyButton('tetSkySw', t.sky.sky);
179
190
  for (const [id, key] of SWITCHES) {
180
191
  const b = el(id);
181
192
  if (!b) continue;
@@ -194,7 +205,6 @@ function flip(key) {
194
205
  setSetting(loadSettings(), `tetrust.${key}`, !cur);
195
206
  sound.unlock();
196
207
  drawSwitches();
197
- if (key === 'stars' || key === 'galaxy') drawSky();
198
208
  G.dirty = true;
199
209
  if (!G.running || G.paused) draw();
200
210
  }
@@ -205,9 +215,7 @@ function drawSky() {
205
215
  const t = tetrustOptions(loadSettings());
206
216
  board3d(sky, [], {
207
217
  ...SKY,
208
- stars: t.stars, galaxy: t.stars && t.galaxy, galaxyAt: t.galaxyAt,
209
- starDensity: t.starDensity, starBrightness: t.starBrightness,
210
- nebulae: t.nebulae, galaxies: t.galaxies, dust: t.dust, clusters: t.clusters, starColours: t.starColours, starGlints: t.starGlints,
218
+ ...t.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
211
219
  });
212
220
  }
213
221
 
@@ -341,6 +349,7 @@ function bind() {
341
349
  document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
342
350
  el('tetResume')?.addEventListener('click', () => resume());
343
351
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
352
+ el('tetSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'tetrust.sky', nextSky(tetrustOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
344
353
  }
345
354
 
346
355
  export function renderTetrust(s, state, h) {
@@ -8,7 +8,7 @@
8
8
  // the first play or key press, and nothing here throws where there is no AudioContext at all
9
9
  // (tests, or a browser with audio blocked): every call is a no-op then.
10
10
 
11
- const HZ = { A4: 440, B4: 493.88, C5: 523.25, D5: 587.33, E5: 659.25, F5: 698.46, G5: 783.99, A5: 880, GS4: 415.3, E4: 329.63, R: 0 };
11
+ const HZ = { A4: 440, B4: 493.88, C5: 523.25, D5: 587.33, E5: 659.25, F5: 698.46, G5: 783.99, A5: 880, GS4: 415.3, E4: 329.63, D4: 293.66, F4: 349.23, G4: 392, BB4: 466.16, R: 0 };
12
12
 
13
13
  // Korobeiniki, A minor, one verse: [note, beats]
14
14
  export const THEME = Object.freeze([
@@ -23,6 +23,24 @@ export const THEME = Object.freeze([
23
23
  ]);
24
24
  export const BPM = 148;
25
25
 
26
+ // SCORCHED YARD's tune (2026-09-16): a march in D minor, sixteen bars, on the same square wave --
27
+ // a drum-less thing that keeps time between the turns and does not fight the blasts
28
+ export const THEME_SCORCHED = Object.freeze([
29
+ ['D4', 1], ['D4', 0.5], ['D4', 0.5], ['F4', 1], ['A4', 1],
30
+ ['G4', 1], ['F4', 0.5], ['E4', 0.5], ['D4', 2],
31
+ ['F4', 1], ['F4', 0.5], ['F4', 0.5], ['A4', 1], ['C5', 1],
32
+ ['BB4', 1], ['A4', 0.5], ['G4', 0.5], ['A4', 2],
33
+ ['D5', 1], ['D5', 0.5], ['D5', 0.5], ['C5', 1], ['BB4', 1],
34
+ ['A4', 1], ['G4', 0.5], ['F4', 0.5], ['G4', 2],
35
+ ['A4', 1], ['BB4', 0.5], ['A4', 0.5], ['G4', 1], ['F4', 1],
36
+ ['E4', 1], ['E4', 0.5], ['E4', 0.5], ['D4', 2],
37
+ ['D4', 0.5], ['R', 0.5], ['D4', 0.5], ['R', 0.5], ['A4', 1], ['R', 1],
38
+ ['F4', 0.5], ['R', 0.5], ['F4', 0.5], ['R', 0.5], ['D5', 1], ['R', 1],
39
+ ['C5', 1], ['BB4', 1], ['A4', 1], ['G4', 1],
40
+ ['F4', 1], ['E4', 1], ['D4', 2],
41
+ ]);
42
+ export const THEMES = Object.freeze({ tetrust: { notes: THEME, bpm: BPM }, scorched: { notes: THEME_SCORCHED, bpm: 112 } });
43
+
26
44
  // the effects: [frequency from, frequency to, seconds, wave, gain]
27
45
  export const SFX = Object.freeze({
28
46
  move: [520, 520, 0.045, 'square', 0.05],
@@ -56,6 +74,15 @@ export const SFX = Object.freeze({
56
74
  // BLOCKANOID (arkanoid.js): a silver brick RINGS rather than breaks and gold rings harder and
57
75
  // lower, so the ear learns which bricks are worth hitting before the eye does. A capsule is a
58
76
  // rising chime (it is always good news), the laser a short zap, a minion a dull pop.
77
+ // SCORCHED YARD (scorchedyard.js): a thump for the shot, a crack for a small blast and a rumble
78
+ // for a nuke, a knock for a hit, a low sweep for a death, a tick for the turn, a rustle for dirt
79
+ syFire: [140, 40, 0.16, 'sawtooth', 0.1],
80
+ syBlast: [180, 30, 0.26, 'sawtooth', 0.11],
81
+ syBig: [90, 18, 0.7, 'sawtooth', 0.12],
82
+ syHit: [520, 180, 0.09, 'square', 0.06],
83
+ syDeath: [400, 40, 0.9, 'sawtooth', 0.1],
84
+ syTurn: [880, 880, 0.04, 'square', 0.035],
85
+ syDirt: [260, 120, 0.12, 'triangle', 0.05],
59
86
  silver: [560, 620, 0.06, 'triangle', 0.055],
60
87
  gold: [240, 210, 0.07, 'square', 0.05],
61
88
  capsule: [620, 1560, 0.22, 'triangle', 0.075],
@@ -135,12 +162,13 @@ const TIMER_MS = 500;
135
162
  function schedule() {
136
163
  const c = S.ctx;
137
164
  if (!c || !S.music) return;
138
- const beat = 60 / BPM;
165
+ const tune = THEMES[S.theme] ?? THEMES.tetrust;
166
+ const beat = 60 / tune.bpm;
139
167
  // fallen behind (the tab was hidden, the thread was held): pick the tune up from now rather
140
168
  // than cramming the missed notes into an instant
141
169
  if (S.at < c.currentTime - 0.05) S.at = c.currentTime + 0.02;
142
170
  while (S.at < c.currentTime + LOOKAHEAD_S) {
143
- const [name, beats] = THEME[S.i % THEME.length];
171
+ const [name, beats] = tune.notes[S.i % tune.notes.length];
144
172
  const dur = beats * beat;
145
173
  const hz = HZ[name] ?? 0;
146
174
  if (hz > 0) {
@@ -153,9 +181,11 @@ function schedule() {
153
181
  S.timer = setTimeout(schedule, TIMER_MS);
154
182
  }
155
183
 
156
- export function setMusic(on) {
184
+ export function setMusic(on, theme = 'tetrust') {
157
185
  const want = !!on;
158
- if (want === S.music && (!want || S.timer)) return; // already so: never restart a running tune
186
+ const tune = THEMES[theme] ? theme : 'tetrust';
187
+ if (want === S.music && (!want || S.timer) && S.theme === tune) return; // already so: never restart a running tune
188
+ if (S.theme !== tune) { S.theme = tune; S.i = 0; }
159
189
  S.music = want;
160
190
  if (S.timer) { clearTimeout(S.timer); S.timer = null; }
161
191
  if (!S.music) { hush(); return; }
package/scripts/check.js CHANGED
@@ -148,6 +148,52 @@ export async function runChecks(node, { rpc, fs = { existsSync, statSync, readdi
148
148
  }
149
149
  }
150
150
 
151
+ // ---- what KIND of node is this, and what can it do that Core cannot? ----
152
+ //
153
+ // Until 2026-09-16 there was no way to tell over RPC. The monitor says so in
154
+ // its own source: "both report the same non-Core subversion string. So RPC
155
+ // cannot tell you whether RPC is complete; only the log's build banner can."
156
+ // That is why `log.enabled` is a per-node setting -- identifying the node
157
+ // needed FILE ACCESS to a machine we can already reach by RPC.
158
+ //
159
+ // bmcgetcapabilities answers it in one call. A plain Core node does not have
160
+ // the method, and that is not a failure: it is how we learn this is Core.
161
+ // Every field it returns is LIVE STATE rather than a compile-time list, so
162
+ // "the node serves address history" means it is serving it now, not that the
163
+ // binary could if configured.
164
+ {
165
+ const caps = await one('bmcgetcapabilities', [], 5000);
166
+ if (!caps.ok) {
167
+ facts.nodeKind = 'core';
168
+ add('node kind', 'ok', 'Bitcoin Core (or a node without bmcgetcapabilities) — BlockYard supplies the address index itself');
169
+ } else {
170
+ const r = caps.result ?? {};
171
+ const x = r.extensions ?? {};
172
+ facts.nodeKind = r.node ?? 'bitcoinmachinecode';
173
+ facts.nodeBuild = r.build?.commit ?? null;
174
+ facts.nodeCapabilities = {
175
+ addrindex: Boolean(x.addrindex),
176
+ esploraPort: Number(x.esploraport ?? 0) || 0,
177
+ mempoolJournal: Boolean(x.mempooljournal?.enabled),
178
+ mempoolJournalCapacity: x.mempooljournal?.enabled ? Number(x.mempooljournal.capacity) : 0,
179
+ downloadInfo: Boolean(x.downloadinfo),
180
+ rpcComplete: Boolean(r.rpc_complete?.peerinfo && r.rpc_complete?.nettotals),
181
+ };
182
+ const have = [];
183
+ if (facts.nodeCapabilities.addrindex) have.push('address history from the node');
184
+ if (facts.nodeCapabilities.esploraPort) have.push(`Esplora facade on :${facts.nodeCapabilities.esploraPort}`);
185
+ if (facts.nodeCapabilities.mempoolJournal) have.push(`mempool departure journal (${facts.nodeCapabilities.mempoolJournalCapacity.toLocaleString()} records)`);
186
+ if (facts.nodeCapabilities.downloadInfo) have.push('download worker map');
187
+ add('node kind', 'ok',
188
+ `${facts.nodeKind} ${facts.nodeBuild ? `(${facts.nodeBuild}${r.build?.dirty ? ', dirty' : ''}) ` : ''}in ${caps.ms}`
189
+ + (have.length ? ` — ${have.join(', ')}` : ' — no extensions enabled'));
190
+ // The one that changes what BlockYard has to DO: with the node serving
191
+ // address history there is no reason to build and follow a second copy.
192
+ if (facts.nodeCapabilities.addrindex)
193
+ add('address index', 'ok', 'the node serves it (addrindex=1) — BlockYard does not need to build its own');
194
+ }
195
+ }
196
+
151
197
  return { ok: !checks.some((c) => c.status === 'fail'), checks, facts };
152
198
  }
153
199
 
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // node scripts/index-build.js --out <dir> [--node <id>] [--workers N] [--files 0,1,5754]
5
5
  //
6
- // --files builds a partial index for testing (the completeness check is skipped). Progress goes to
6
+ // --files builds a partial index for testing (the completeness check is skipped). A build stopped part
7
+ // way resumes when run again with the same --out (build.js, THE BUILD JOURNAL); the log line says so,
8
+ // or says why the unfinished work was discarded. Progress goes to
7
9
  // stderr once a second; the manifest, with every phase's timings, goes to stdout at the end.
8
10
  import { loadConfig } from '../server/config.js';
9
11
  import { RpcClient } from '../server/rpc/client.js';
@@ -12,6 +14,10 @@ import path from 'node:path';
12
14
  import { progress, progressLine, strip, c, fmt } from './ui.js';
13
15
 
14
16
  const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : def; };
17
+ // --workers must be a whole number of at least one (audit 2026-09-16, I3): `abc` or `0` made a pool with
18
+ // no workers, and with --files that wrote a "successful" manifest of zero rows
19
+ const workersArg = process.argv.includes('--workers') ? String(arg('workers')) : null;
20
+ if (workersArg != null && !(/^\d+$/.test(workersArg) && Number(workersArg) >= 1 && Number.isSafeInteger(Number(workersArg)))) { console.error(`--workers must be a whole number of at least 1, not ${JSON.stringify(workersArg)}`); process.exit(2); }
15
21
  const cfg = loadConfig();
16
22
  const node = cfg.nodes.find((n) => n.id === arg('node', null)) ?? cfg.nodes.find((n) => n.datadir) ?? cfg.nodes[0];
17
23
  if (!node.datadir) { console.error(`node ${node.id} has no datadir configured; the index is built from its block files`); process.exit(2); }
@@ -25,7 +31,8 @@ const bar = progress();
25
31
  const manifest = await buildIndex({
26
32
  rpc, blocksDir: path.join(node.datadir, 'blocks'), out,
27
33
  pace: rpcPacer(rpc, { onChange: (held) => process.stderr.write(held ? ' paused while the node\'s RPC is slow or failing\n' : ' resumed\n') }),
28
- workers: arg('workers', null) ? Number(arg('workers')) : undefined,
34
+ log: (text) => { bar.done(); process.stderr.write(` ${text}\n`); },
35
+ workers: workersArg != null ? Number(workersArg) : undefined,
29
36
  files: arg('files', null) ? arg('files').split(',').map(Number) : null,
30
37
  onProgress: (p) => {
31
38
  if (p.phase !== phase) {
@@ -16,6 +16,13 @@
16
16
  // node scripts/pool-map.js # fetch and write data/pool-map.json
17
17
  // node scripts/pool-map.js --file pools-v2.json # offline input
18
18
  // node scripts/pool-map.js --check # compare against what we are seeing now
19
+ // node scripts/pool-map.js --yes # replace an existing data/pool-map.json without asking
20
+ // node scripts/pool-map.js --expect-sha256 HEX # refuse a download that is not exactly those bytes
21
+ //
22
+ // Before it replaces data/pool-map.json it prints which pools the new map adds, removes and
23
+ // changes, and writes only on --yes or a "y" at the prompt; redirects are followed to HTTPS only,
24
+ // by both the node:https path and the curl fallback; and the coverage check asks the monitor on
25
+ // the port and scheme its config names (2026-09-16, audit L15).
19
26
  //
20
27
  // The input shape as fetched on 2026-09-09: 35,733 bytes, 171 pools, 201 tags, keys
21
28
  // per pool: addresses, id, link, name, tags. Tags are literal fragments of the coinbase
@@ -85,14 +92,29 @@ function build(content, source) {
85
92
  // 367 ms. Pinning the family is the fix; a curl fallback is kept because a tool that
86
93
  // fetches pool labels has to work on a box whose stack is half broken.
87
94
  import https from 'node:https';
95
+ import readline from 'node:readline/promises';
88
96
  import { execFileSync } from 'node:child_process';
97
+ import { loadConfig } from '../server/config.js';
98
+
99
+ /**
100
+ * Where a redirect may go (2026-09-16, audit L15): HTTPS only. A `Location: http://...` would
101
+ * otherwise have been followed by the curl fallback, and the labels read over plain HTTP.
102
+ * Returns the absolute next URL, or throws.
103
+ */
104
+ export function redirectTarget(location, from) {
105
+ const next = new URL(location, from);
106
+ if (next.protocol !== 'https:') throw new Error(`refusing a redirect from ${from} to ${next.protocol}//${next.host}: HTTPS only`);
107
+ return next.toString();
108
+ }
89
109
 
90
- function getOverHttps(url, redirectsLeft = 3, extra = {}) {
110
+ export function getOverHttps(url, redirectsLeft = 3, extra = {}) {
91
111
  return new Promise((resolve, reject) => {
92
112
  const req = https.get(url, { family: 4, ...extra, headers: { 'user-agent': 'BlockYard pool-map/1' }, timeout: 20_000 }, (res) => {
93
113
  if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location && redirectsLeft > 0) {
94
114
  res.resume();
95
- return resolve(getOverHttps(new URL(res.headers.location, url).toString(), redirectsLeft - 1));
115
+ let next;
116
+ try { next = redirectTarget(res.headers.location, url); } catch (err) { return reject(err); }
117
+ return resolve(getOverHttps(next, redirectsLeft - 1));
96
118
  }
97
119
  if (res.statusCode !== 200) { res.resume(); return reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`)); }
98
120
  const chunks = [];
@@ -105,53 +127,147 @@ function getOverHttps(url, redirectsLeft = 3, extra = {}) {
105
127
  });
106
128
  }
107
129
 
130
+ /**
131
+ * The curl fallback's arguments. `--proto =https --proto-redir =https` (2026-09-16, audit L15):
132
+ * -L alone follows a redirect to any protocol curl speaks, plain http included.
133
+ */
134
+ export function curlArgs(url) {
135
+ return ['-fsSL', '--proto', '=https', '--proto-redir', '=https', '--max-time', '25', url];
136
+ }
137
+
108
138
  async function fetchContent() {
109
139
  try {
110
140
  return await getOverHttps(URL_);
111
141
  } catch (err) {
112
142
  // curl follows its own resolver order and got 200 here while node's fetch failed,
113
143
  // so a second, different path is worth one retry before declaring the fetch dead.
144
+ // A refused non-HTTPS redirect is not a flaky stack: it is not retried another way.
145
+ if (/HTTPS only/.test(err?.message ?? '')) throw err;
114
146
  try {
115
- return execFileSync('curl', ['-fsSL', '--max-time', '25', URL_], { encoding: 'utf8', maxBuffer: 8 << 20 });
147
+ return execFileSync('curl', curlArgs(URL_), { encoding: 'utf8', maxBuffer: 8 << 20 });
116
148
  } catch {
117
149
  throw new Error(`could not fetch ${URL_}: ${err?.message ?? err}`);
118
150
  }
119
151
  }
120
152
  }
121
153
 
122
- const content = asFile ? fs.readFileSync(asFile, 'utf8') : await fetchContent();
123
- const map = build(content, asFile ? `file:${path.basename(asFile)}` : URL_);
124
-
125
- // What we are actually seeing right now, so the coverage number is measured and not
126
- // assumed. Read live from the running monitor if it answers; otherwise say it is unknown.
127
- let observed = null;
128
- let coverageError = null;
129
- try {
130
- const cfgPath = path.join(ROOT, 'config', 'local.json');
131
- const host = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))?.server?.hosts?.[0] ?? '127.0.0.1';
132
- // Same family:4 path as the fetch above, plus the box's own CA: the monitor serves TLS
133
- // from a local CA that is not in the system trust store, and "could not verify" is a
134
- // different fact from "could not reach".
135
- const caFile = process.env.BLOCKYARD_CA_FILE ?? null; // a CA file, only where the fetch needs a private one
136
- const body = await getOverHttps(`https://${host}:8088/api/mining?node=main`, 0, fs.existsSync(caFile) ? { ca: fs.readFileSync(caFile) } : {});
137
- const d = JSON.parse(body);
138
- const rows = d.recent ?? [];
139
- const hit = rows.filter((r) => map.matchers.some((m) => normalizeText(`${r.rawCoinbase ? Buffer.from(r.rawCoinbase, 'hex').toString('utf8') : ''} ${r.tagText || ''}`).includes(m.tagNorm))).length;
140
- observed = {
141
- blocks: rows.length, labelled: hit, unknown: rows.filter((r) => !r.poolLabel).length,
142
- labelSource: d.labelSource ? `labels from ${d.labelSource.source} @ ${(d.labelSource.sha256 || '').slice(0, 10)}` : 'no map loaded by the monitor yet',
143
- };
144
- } catch (err) {
145
- coverageError = err?.message ?? String(err);
154
+ /**
155
+ * What a refresh changes, pool by pool (2026-09-16, audit L15): the file decides which
156
+ * organisation the dashboard names for a block, so a rewrite is shown before it is made.
157
+ * `changed` is a pool whose name, link or tags differ. Keys are the maps' own pool keys.
158
+ */
159
+ export function diffPoolMaps(before, after) {
160
+ const index = (m) => new Map((m?.pools ?? []).map((p) => [p.key, p]));
161
+ const a = index(before), b = index(after);
162
+ const same = (x, y) => x.name === y.name && (x.link ?? null) === (y.link ?? null)
163
+ && JSON.stringify([...(x.tags ?? [])].sort()) === JSON.stringify([...(y.tags ?? [])].sort());
164
+ const added = [...b.keys()].filter((k) => !a.has(k)).sort();
165
+ const removed = [...a.keys()].filter((k) => !b.has(k)).sort();
166
+ const changed = [...b.keys()].filter((k) => a.has(k) && !same(a.get(k), b.get(k))).sort();
167
+ return { added, removed, changed, unchanged: [...b.keys()].filter((k) => a.has(k)).length - changed.length };
168
+ }
169
+
170
+ export function formatDiff(d, name = (k) => k) {
171
+ const list = (ks) => (ks.length > 12 ? `${ks.slice(0, 12).map(name).join(', ')}, and ${ks.length - 12} more` : ks.map(name).join(', '));
172
+ const lines = [`pools: ${d.added.length} added, ${d.removed.length} removed, ${d.changed.length} changed, ${d.unchanged} unchanged`];
173
+ if (d.added.length) lines.push(` + ${list(d.added)}`);
174
+ if (d.removed.length) lines.push(` - ${list(d.removed)}`);
175
+ if (d.changed.length) lines.push(` ~ ${list(d.changed)}`);
176
+ return lines.join('\n');
177
+ }
178
+
179
+ /** --expect-sha256: the download must be exactly these bytes (2026-09-16, audit L15). */
180
+ export function checkExpectedSha(content, expected) {
181
+ if (expected == null) return null;
182
+ const want = String(expected).trim().toLowerCase();
183
+ if (!/^[0-9a-f]{64}$/.test(want)) throw new Error('--expect-sha256 takes 64 hex characters');
184
+ const got = crypto.createHash('sha256').update(content).digest('hex');
185
+ if (got !== want) throw new Error(`sha256 mismatch: expected ${want}, got ${got}; nothing written`);
186
+ return got;
187
+ }
188
+
189
+ /**
190
+ * The running monitor's own mining endpoint, for the coverage check (2026-09-16, audit L15): it
191
+ * was https://<host>:8088 whatever the config said, and the default port has been 21000 since
192
+ * 2026-09-13, so coverage was never measured. Port, TLS and bind address come from the loaded
193
+ * config (config/local.json, BLOCKYARD_PORT, BLOCKYARD_TLS); HTTPS unless TLS is turned off.
194
+ */
195
+ export function coverageUrl(cfg) {
196
+ const server = cfg?.server ?? {};
197
+ let host = server.hosts?.[0] ?? server.host ?? '127.0.0.1';
198
+ if (Array.isArray(host)) host = host[0] ?? '127.0.0.1';
199
+ if (host === '0.0.0.0') host = '127.0.0.1';
200
+ if (host === '::') host = '::1';
201
+ const port = Number.isInteger(Number(server.port)) && Number(server.port) > 0 ? Number(server.port) : 21000;
202
+ const scheme = cfg?.tls === false || server.tls?.enabled === false ? 'http' : 'https';
203
+ return `${scheme}://${host.includes(':') ? `[${host}]` : host}:${port}/api/mining?node=main`;
146
204
  }
147
205
 
148
- console.log(`pools: ${map.pools.length} matchers: ${map.matchers.length} sha256: ${map.sourceSha256.slice(0, 12)}`);
149
- if (observed) console.log(`coverage of the ${observed.blocks} attributed blocks: ${observed.labelled} matched a curated tag, ${observed.unknown} keep their raw tag\n ${observed.labelSource ?? ''}`);
150
- else console.log(`coverage: not measured (${coverageError}) -- reported as unknown rather than assumed`);
151
- if (checkOnly) process.exit(0);
206
+ async function main() {
207
+ const argAfter = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] ?? null : null);
208
+ const content = asFile ? fs.readFileSync(asFile, 'utf8') : await fetchContent();
209
+ try { checkExpectedSha(content, argAfter('--expect-sha256')); } catch (err) { console.error(err.message); process.exit(1); }
210
+ const map = build(content, asFile ? `file:${path.basename(asFile)}` : URL_);
152
211
 
153
- fs.mkdirSync(path.dirname(OUT), { recursive: true });
154
- const tmp = `${OUT}.tmp`;
155
- fs.writeFileSync(tmp, `${JSON.stringify(map, null, 1)}\n`);
156
- fs.renameSync(tmp, OUT);
157
- console.log(`wrote ${path.relative(ROOT, OUT)}`);
212
+ // What we are actually seeing right now, so the coverage number is measured and not
213
+ // assumed. Read live from the running monitor if it answers; otherwise say it is unknown.
214
+ let observed = null;
215
+ let coverageError = null;
216
+ try {
217
+ let cfg;
218
+ try { cfg = loadConfig(); } catch { cfg = { server: { port: Number(process.env.BLOCKYARD_PORT) || 21000 } }; }
219
+ const url = coverageUrl(cfg);
220
+ // Same family:4 path as the fetch above, plus the box's own CA: the monitor serves TLS
221
+ // from a local CA that is not in the system trust store, and "could not verify" is a
222
+ // different fact from "could not reach".
223
+ const caFile = process.env.BLOCKYARD_CA_FILE ?? null; // a CA file, only where the fetch needs a private one
224
+ const extra = caFile && fs.existsSync(caFile) ? { ca: fs.readFileSync(caFile) } : {};
225
+ const body = url.startsWith('https:') ? await getOverHttps(url, 0, extra) : await (await fetch(url)).text();
226
+ const d = JSON.parse(body);
227
+ const rows = d.recent ?? [];
228
+ const hit = rows.filter((r) => map.matchers.some((m) => normalizeText(`${r.rawCoinbase ? Buffer.from(r.rawCoinbase, 'hex').toString('utf8') : ''} ${r.tagText || ''}`).includes(m.tagNorm))).length;
229
+ observed = {
230
+ blocks: rows.length, labelled: hit, unknown: rows.filter((r) => !r.poolLabel).length,
231
+ labelSource: d.labelSource ? `labels from ${d.labelSource.source} @ ${(d.labelSource.sha256 || '').slice(0, 10)}` : 'no map loaded by the monitor yet',
232
+ };
233
+ } catch (err) {
234
+ coverageError = err?.message ?? String(err);
235
+ }
236
+
237
+ console.log(`pools: ${map.pools.length} matchers: ${map.matchers.length} sha256: ${map.sourceSha256}`);
238
+ if (observed) console.log(`coverage of the ${observed.blocks} attributed blocks: ${observed.labelled} matched a curated tag, ${observed.unknown} keep their raw tag\n ${observed.labelSource ?? ''}`);
239
+ else console.log(`coverage: not measured (${coverageError}) -- reported as unknown rather than assumed`);
240
+
241
+ // THE DIFF BEFORE THE WRITE (2026-09-16, audit L15): against the file this replaces, or, when
242
+ // there is none yet, the shipped config/pool-map.json the monitor uses until then.
243
+ const shipped = path.join(ROOT, 'config', 'pool-map.json');
244
+ const replacing = fs.existsSync(OUT);
245
+ const prevFile = replacing ? OUT : fs.existsSync(shipped) ? shipped : null;
246
+ let prev = null;
247
+ if (prevFile) { try { prev = JSON.parse(fs.readFileSync(prevFile, 'utf8')); } catch { prev = null; } }
248
+ if (prev) {
249
+ const names = new Map([...(prev.pools ?? []), ...map.pools].map((p) => [p.key, p.name]));
250
+ console.log(`against ${path.relative(ROOT, prevFile)}${prev.sourceSha256 ? ` (sha256 ${prev.sourceSha256.slice(0, 12)})` : ''}:`);
251
+ console.log(formatDiff(diffPoolMaps(prev, map), (k) => names.get(k) ?? k));
252
+ }
253
+ if (checkOnly) process.exit(0);
254
+
255
+ if (replacing && !args.includes('--yes')) {
256
+ if (!process.stdin.isTTY) { console.log(`not written: ${path.relative(ROOT, OUT)} exists; pass --yes to replace it`); process.exit(1); }
257
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
258
+ const a = (await rl.question(`replace ${path.relative(ROOT, OUT)}? (y/N) `)).trim().toLowerCase();
259
+ rl.close();
260
+ if (!a.startsWith('y')) { console.log('not written'); process.exit(1); }
261
+ }
262
+
263
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
264
+ const tmp = `${OUT}.tmp`;
265
+ fs.writeFileSync(tmp, `${JSON.stringify(map, null, 1)}\n`);
266
+ fs.renameSync(tmp, OUT);
267
+ console.log(`wrote ${path.relative(ROOT, OUT)}`);
268
+ }
269
+
270
+ // run only as a script, so the tests can import the pieces without a fetch
271
+ if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
272
+ main().catch((err) => { console.error(err?.message ?? err); process.exit(1); });
273
+ }