blockyard 0.1.0 → 0.1.2
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.
- package/CHANGELOG.md +112 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +247 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1071 -0
- package/public/js/scorchedai.js +268 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1361 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +27 -0
- package/scripts/smoke.sh +6 -5
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +432 -56
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/systemd/blockyard.service +34 -3
package/public/js/tetrust.js
CHANGED
|
@@ -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 = [['
|
|
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
|
-
|
|
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) {
|
package/public/js/tetsound.js
CHANGED
|
@@ -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
|
|
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] =
|
|
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
|
-
|
|
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
|
|
package/scripts/index-build.js
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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) {
|
package/scripts/pool-map.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|