blockyard 0.0.1 → 0.0.9

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 (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,620 @@
1
+ // Configuration. Defaults are wired to the actual deployment discovered on this
2
+ // box (see README "Where the defaults come from"), so `npm start` works with no
3
+ // setup. Every value is overridable by env var or by config/local.json.
4
+ import fs from 'node:fs';
5
+ import fsp from 'node:fs/promises';
6
+ import crypto from 'node:crypto';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { isBindableHost, planBinds, parseCidr } from './netinfo.js';
10
+
11
+ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ const HERE_DOC = `
14
+ Defaults are Bitcoin Core's own, so \`npm start\` works against a stock local node:
15
+ node RPC 127.0.0.1:8332 (Core's mainnet default; testnet 18332, signet 38332)
16
+ cookie <datadir>/<chain>/.cookie (regenerated each boot, deleted on stop)
17
+ datadir ~/.bitcoin
18
+ systemd bitcoind.service
19
+ log <datadir>/debug.log
20
+ NOTE a node that serves RPC on a NON-default port is the single most common reason
21
+ this monitor reports the node offline with ECONNREFUSED while the node is healthy.
22
+ Measured twice on the machine this was built against, where an rpcport= line was lost
23
+ to a config cleanup: check the node's own log for its "JSON-RPC server on ..." line
24
+ before believing the report. RPC can also bind well after systemd says "running", and
25
+ chain RPCs can block for tens of seconds after that -- see MEASUREMENTS 18.
26
+ `.trim();
27
+
28
+ const DEFAULTS = {
29
+ server: {
30
+ // Multi-user means the LAN has to reach it, so it binds broadly by default.
31
+ // Set BLOCKYARD_BIND=127.0.0.1 to keep it on this machine only.
32
+ host: '0.0.0.0',
33
+ // 21000 (operator, 2026-09-13: "make default web port 21000 for access"). It was 8088, which
34
+ // sits in the range every other monitor on a box reaches for; this one is ours.
35
+ port: 21000,
36
+ // Defense in depth: only these CIDRs may connect. Empty = any.
37
+ allowCidrs: [],
38
+ trustProxy: false,
39
+ // TLS is off unless both files are named, and then it is on for every listener.
40
+ // It stays opt-in because this box is a LAN monitor whose certificate has no
41
+ // issuer: a self-signed cert produces a browser warning on every address change,
42
+ // and the alternative already documented (an SSH tunnel to 127.0.0.1, or a
43
+ // reverse proxy that owns the cert) is better on a machine you control.
44
+ // What was missing until 2026-09-09 was the option at all -- serving a session
45
+ // cookie and every RPC reply over plain HTTP on a LAN is not a gap you get to
46
+ // call "documented, therefore fine".
47
+ tls: {
48
+ cert: null, // PEM; BLOCKYARD_TLS_CERT
49
+ key: null, // PEM; BLOCKYARD_TLS_KEY
50
+ // Sent over TLS responses only. Two days, not the usual year: a LAN address
51
+ // can be reissued to something else, and HSTS is the header that cannot be
52
+ // unsent. Deliberately no includeSubDomains and no preload.
53
+ hstsMs: 172_800_000,
54
+ },
55
+ },
56
+ // Production only, by decision -- see "Why the benchmark node is not monitored"
57
+ // at the bottom of this block, and the measurements in docs/MEASUREMENTS.md.
58
+ //
59
+ // Multi-node support is intact (see the node switcher, per-node series rings, and
60
+ // the fact that this is an array): add entries in config/local.json to bring other
61
+ // nodes back. What is no longer default is watching a benchmark from the same box
62
+ // that runs it.
63
+ //
64
+ // A node whose datadir is absent is skipped at boot (see main.js) rather than
65
+ // failing, so a datadir that gets cleaned up does not break startup.
66
+ nodes: [
67
+ {
68
+ // Bitcoin Core's own mainnet defaults (operator, 2026-09-13: a Core-centric release,
69
+ // "meant to be pointed at your Umbrel nodes or local Bitcoin Nodes"). A deployment
70
+ // that differs says so in config/local.json or through BLOCKYARD_NODE_* -- both are
71
+ // applied over these, so nothing here has to be right for everyone.
72
+ id: 'main',
73
+ label: 'Bitcoin Core (mainnet)',
74
+ rpcUrl: 'http://127.0.0.1:8332',
75
+ datadir: '/home/bitcoin/.bitcoin',
76
+ chainHint: 'main',
77
+ cookieFile: null, // derived from datadir+chainHint when null
78
+ rpcUser: null,
79
+ rpcPassword: null,
80
+ logFile: '/home/bitcoin/.bitcoin/debug.log',
81
+ systemdUnit: 'bitcoind.service',
82
+ color: '#f7931a',
83
+ },
84
+ ],
85
+ // Why the benchmark node is not monitored (was `bench`, removed 2026-09-08).
86
+ //
87
+ // Not because the data was wrong. Because on this box the observation changes the
88
+ // thing observed and degrades the thing that matters:
89
+ //
90
+ // 1. Contention with the thing we actually care about. The node's RPC server
91
+ // services ONE connection at a time on one thread. Every poll of the bench
92
+ // node is time the bench cannot spend on the benchmark, and a monitor that
93
+ // shares the box with a benchmark is a load generator wearing a label.
94
+ // main.js already refuses to let `npm run dev` or smoke.sh touch the real
95
+ // bench node for this reason; running it as a monitored node was the same
96
+ // mistake with better branding.
97
+ // 2. It could not be read reliably anyway. Measured in one hour of monitoring:
98
+ // RPC average latency ~18-32 s, 90 s timeouts on the fast and slow tiers,
99
+ // 25 failed tier runs, and nine-plus restarts by its harness (each looking
100
+ // like an outage). Production, same monitor, same code: 0 errors, max 22 ms.
101
+ // 3. It contaminated the production charts. Un-tagged series rings mixed 2,308
102
+ // production rows with 1,816 bench rows in one line (MEASUREMENTS 20). The
103
+ // rings are per-node now, but the lesson stands: while both were displayed,
104
+ // at least one chart was not a fact about the node it was labelled with.
105
+ //
106
+ // To watch a benchmark again, put this in config/local.json (gitignored) -- the
107
+ // node switcher, the per-node rings and the picker all already support it:
108
+ //
109
+ // { "nodes": [
110
+ // { "id": "main", "label": "Bitcoin Core (mainnet)",
111
+ // "rpcUrl": "http://127.0.0.1:8332",
112
+ // "datadir": "/home/bitcoin/.bitcoin", "chainHint": "main",
113
+ // "logFile": "/home/bitcoin/.bitcoin/debug.log" },
114
+ // { "id": "bench", "label": "Bench node (IBD / benchmark)",
115
+ // "rpcUrl": "http://127.0.0.1:8461",
116
+ // "datadir": "/mnt/2tbssd/bench/data", "chainHint": "main",
117
+ // "logFile": "/mnt/2tbssd/bench/console.log",
118
+ // "logStaleMs": 600000, "optional": true }
119
+ // ] }
120
+ //
121
+ // Two caveats recorded while it was wired, because they will bite whoever pastes
122
+ // that in: the benchmark's real log is the run directory's `console.log`, NOT
123
+ // `<datadir>/main/debug.log` (a 144-byte stub -- three "node start" lines -- which
124
+ // is what the monitor tailed for two hours while reporting nothing); and that file
125
+ // is block-buffered, so lines can sit frozen for minutes and then arrive in a
126
+ // burst. `log-silent` distinguishes the two by quoting the chain delta it saw.
127
+ /* previous second entry, kept readable rather than silently deleted:
128
+ {
129
+ id: 'bench',
130
+ label: 'Bench node (IBD / benchmark)',
131
+ rpcUrl: 'http://127.0.0.1:8461',
132
+ datadir: '/mnt/2tbssd/bench/data',
133
+ chainHint: 'main',
134
+ logFile: '/mnt/2tbssd/bench/console.log',
135
+ logStaleMs: 600000,
136
+ optional: true,
137
+ },
138
+ */
139
+ rpc: {
140
+ // The node's RPC server accepts and services ONE connection at a time on a
141
+ // single thread (docs/RPC_LIVE_NODE.md, slice 11). One browser tab polling
142
+ // eight methods is polite; forty tabs is a denial of service against our own
143
+ // node. So: one in-flight request globally, a floor between requests, and
144
+ // every poll tier sized so the node is never the bottleneck for itself.
145
+ maxInFlight: 1,
146
+ minIntervalMs: 250,
147
+ // Measured on this box: a bare getblockcount against the bench node took
148
+ // 40.4s while it was doing initial block download, and 7s once against the
149
+ // synced node under benchmark load. A 20s timeout would have declared a
150
+ // healthy-but-busy node unreachable, so the ceiling is well above that and
151
+ // the tier cadence adapts instead (see monitor.effectiveTierMs).
152
+ timeoutMs: 90000,
153
+ heavyTimeoutMs: 300000,
154
+ // A poll answer that arrives later than this describes a moment that has
155
+ // already passed; it is dropped rather than shown as current state.
156
+ staleDropMs: 12000,
157
+ // Above this average latency the UI says the node is slow rather than
158
+ // implying the monitor is broken.
159
+ slowLatencyMs: 5000,
160
+ breakerThreshold: 3, // consecutive failures before we back off
161
+ breakerCooldownMs: 30000,
162
+ // Hard ceiling on RPC calls/second we will issue, whatever the tiers ask for.
163
+ maxRatePerSec: 4,
164
+ },
165
+ poll: {
166
+ fastMs: 4000, // chaininfo, mempoolinfo, connections, nettotals, uptime
167
+ midMs: 15000, // mining info, fee estimates, chain tips, mempool ids
168
+ // The verbose mempool read has its own tier since 2026-09-11 (operator: "Faster
169
+ // refresh"): it measured 0.144 s for ~19k entries on this node, cheap next to
170
+ // gettxoutsetinfo, so it no longer waits on the heavy minute.
171
+ poolMs: 20000, // mempool verbose (feeds the block-space viewer and the mempool map)
172
+ slowMs: 60000, // indexes, txoutset, chaintxstats
173
+ rareMs: 900000, // peerinfo, deployment info, rpc info
174
+ blockBackfill: 30, // blocks of history to backfill at startup
175
+ },
176
+ store: {
177
+ dir: null, // resolved below; JSONL + snapshots
178
+ retentionHours: 72,
179
+ ringCapacity: 20000,
180
+ maxEventLog: 5000,
181
+ snapshotEveryMs: 120000,
182
+ // The in-memory height -> block map, kept bounded so long-range analytics come
183
+ // from the rings and not from an unbounded Map. Measured on 2026-09-09: 12,000
184
+ // rows cost 3.6 MB of heap (~310 B/row, test/monitor-shapes.test.js). The old
185
+ // 3,000-row cap was a round number rather than a budget, and it evicted blocks
186
+ // that the 72 h retention would happily have kept -- for ~0.9 MB.
187
+ blockMapCap: 12000,
188
+ // audit.jsonl rotation. The file records logins, RPC calls and action results;
189
+ // it holds no credentials, and it used to grow forever on a box that has filled
190
+ // its disk before -- at which point the failure is not "no audit" but "no node",
191
+ // because the monitor cannot write snapshots either.
192
+ auditMaxBytes: 8 * 1024 * 1024,
193
+ auditKeep: 5,
194
+ },
195
+ auth: {
196
+ // OPEN BY DEFAULT, like a block explorer: anyone who can reach the listen
197
+ // addresses reads the dashboard with no account. This is a posture decision, not
198
+ // a convenience -- see README "Open by default" and the boot warning, which names
199
+ // the addresses this leaves readable.
200
+ //
201
+ // What "open" is bounded by, in server/http/server.js:
202
+ // * the anonymous role is `viewer` and the ceiling is not configurable; user
203
+ // administration, the audit trail and password changes stay 403;
204
+ // * node writes are refused outright in open mode unless
205
+ // actions.allowWritesWithoutAuth says so explicitly (config load is fatal
206
+ // otherwise, because "open monitor + enabled writes" is a combination nobody
207
+ // should discover by accident);
208
+ // * rate limits key on the IP, so one noisy tab cannot spend everyone's bucket.
209
+ //
210
+ // Set BLOCKYARD_AUTH=1 (or auth.enabled in config/local.json) for accounts, roles,
211
+ // sessions, CSRF and the audit trail-by-user.
212
+ enabled: false,
213
+ dataDir: null,
214
+ // THE LONG ONE IS THE ABSOLUTE LIFETIME, the short one the idle ceiling -- which is the way
215
+ // round the names read, and the opposite of what shipped until 2026-09-13. With an 8 h
216
+ // absolute and a 72 h idle ceiling, the idle check in sessions.js could never fire: nothing
217
+ // lived long enough to be 72 h idle, so a session was 8 h whatever you did. Found in an audit;
218
+ // docs/SECURITY.md described the intended relationship, not the one in force.
219
+ sessionTtlMs: 72 * 3600 * 1000, // absolute: a session dies 72 h after sign-in, active or not
220
+ idleTtlMs: 8 * 3600 * 1000, // idle: 8 h without a request and it is gone
221
+ scrypt: { N: 16384, r: 8, p: 1, keylen: 32 },
222
+ minPasswordChars: 12,
223
+ loginMaxAttempts: 8,
224
+ loginWindowMs: 300000,
225
+ lockoutMs: 600000,
226
+ cookieName: 'blockyard_sid',
227
+ secureCookie: false, // forced true at boot when TLS is on
228
+ },
229
+ actions: {
230
+ // Anything that can change node or machine state is off unless explicitly
231
+ // enabled AND role-gated. Reads are the default; the UI says so.
232
+ enabled: false,
233
+ allow: [], // e.g. ['broadcast','savemempool']
234
+ requireAdmin: true,
235
+ // Separate acknowledgement: with accounts off there is no role to check, so an
236
+ // enabled write would be reachable by anyone who can open a socket.
237
+ allowWritesWithoutAuth: false,
238
+ },
239
+ log: {
240
+ level: 'info',
241
+ tailBytes: 2 * 1024 * 1024,
242
+ // OFF by default, and NOT SUPPORTED AGAINST BITCOIN CORE.
243
+ //
244
+ // logparse.js targets an experimental node's log grammar: its rules key on [dlc], [dl],
245
+ // [dial], [utxo_live] and [config] tags, and its timestamp rule wants
246
+ // "YYYY-MM-DD HH:MM:SS.mmm ", not the "2026-09-13T01:30:00Z" Core writes. Measured
247
+ // 2026-09-13 against real Core debug.log lines: every line comes back as an unstructured
248
+ // `raw` event with NO fields extracted, and its timestamp falls back to the time of reading
249
+ // rather than the time in the line -- so turning this on against Core adds nothing and
250
+ // misdates the event feed. Both fixtures in test/fixtures/ are experimental-format; no Core
251
+ // log has ever been tested against this parser (test/log-core-unsupported.test.js pins that).
252
+ //
253
+ // Off since 2026-09-09 for an independent reason that still holds: tailing a file the node
254
+ // rewrites between releases is a grammar dependency that has already cost this project two
255
+ // silent-outage incidents, and every panel that needed it was removed rather than left
256
+ // showing dashes. Nothing in the UI depends on the log.
257
+ //
258
+ // The measurements that motivated the option are from the experimental node and are kept for
259
+ // the reasoning, not as claims about Core: on the build deployed there, RPC answered
260
+ // getnettotals 0/0 and getpeerinfo [] while getconnectioncount said 16, so "RPC only" meant
261
+ // "no bandwidth and no peer names at all" -- and both builds reported the same non-Core
262
+ // subversion string, so RPC could not tell you which one you had (MEASUREMENTS 3, 4, 11, 26).
263
+ enabled: false,
264
+ // How long a tailed file may go without a single new byte before the monitor
265
+ // says so. Default 30 min: measured 2026-09-08, the synced production node's own
266
+ // log went 1,182 s (~20 min) between lines at its quietest across 1,840 lines,
267
+ // so anything under ~20 min flags a healthy idle node as broken. A per-node
268
+ // `logStaleMs` tightens it where the node is known to be chatty.
269
+ staleMs: 1800000,
270
+ // How often that check runs. It is a stat() plus arithmetic, no RPC, so it can
271
+ // be far more frequent than anything that touches the node's single lane.
272
+ healthMs: 30000,
273
+ },
274
+ // The Markets tab (server/collect/markets.js): public exchange APIs over HTTPS -- the one
275
+ // outbound connection that is not the node. Polled only while someone has the tab open, and
276
+ // parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0 turns it off.
277
+ markets: {
278
+ enabled: true,
279
+ tickerMs: 15000,
280
+ candleMs: 300000,
281
+ bookMs: 30000, // order books, for the depth chart
282
+ idleAfterMs: 600000,
283
+ timeoutMs: 8000,
284
+ },
285
+ };
286
+
287
+ function isPlainObject(v) {
288
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
289
+ }
290
+
291
+ function deepMerge(base, extra) {
292
+ if (extra === undefined || extra === null) return base;
293
+ if (Array.isArray(base) || Array.isArray(extra)) return extra;
294
+ if (!isPlainObject(base) || !isPlainObject(extra)) return extra;
295
+ const out = { ...base };
296
+ for (const k of Object.keys(extra)) out[k] = deepMerge(base[k], extra[k]);
297
+ return out;
298
+ }
299
+
300
+ // Bind accepts one address, a comma list, or a JSON array -- because "LAN and the
301
+ // tunnel, but not the container bridges" cannot be said with a single socket.
302
+ function hostList(v) {
303
+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
304
+ return String(v).split(',').map((x) => x.trim()).filter(Boolean);
305
+ }
306
+
307
+ /**
308
+ * Read one env var and put it in the shape the config expects.
309
+ *
310
+ * This function used to handle only Number and Boolean and silently return the raw
311
+ * string for every *function* cast -- and half the entries in the table below pass a
312
+ * function. Measured consequence, found by test/cidr.test.js on 2026-09-09:
313
+ *
314
+ * BLOCKYARD_ALLOW_CIDRS=203.0.113.0/24 -> the string "203.0.113.0/24", not a list.
315
+ * The gate then iterated it CHARACTER by character, matched nothing, and refused
316
+ * every address -- so the documented way to restrict the monitor to a LAN was a
317
+ * deny-all firewall that also locked out the operator.
318
+ *
319
+ * BLOCKYARD_ACTIONS=broadcast -> a string, and `allow.includes(name)` is a substring
320
+ * test on a string: permissions decided by substring matching instead of set
321
+ * membership. It happened not to grant anything today only because the action names
322
+ * do not contain each other.
323
+ *
324
+ * The host list survived by luck: validate() re-splits a string there.
325
+ */
326
+ function env(name, cast) {
327
+ const raw = process.env[name];
328
+ if (raw === undefined || raw === '') return undefined;
329
+ if (cast === Number) return Number(raw);
330
+ if (cast === Boolean) return /^(1|true|yes|on)$/i.test(raw);
331
+ if (typeof cast === 'function') return cast(raw);
332
+ return raw;
333
+ }
334
+
335
+ /**
336
+ * Where the machine-specific file lives -- and how a hermetic run opts out of it.
337
+ *
338
+ * Tests and the dev fake-node boot used to inherit whatever `config/local.json`
339
+ * happened to say on this box. That is how a *deployment* decision (bind this box's
340
+ * LAN + tailnet addresses) silently broke 54 smoke assertions: the script pinned its
341
+ * data dir, ports and admin password, curled `127.0.0.1` -- and the server under test
342
+ * was not listening there. A machine config must not leak into a hermetic run, so
343
+ * give it a way to say "no file".
344
+ */
345
+ function defaultConfigFile() {
346
+ const e = process.env.BLOCKYARD_CONFIG;
347
+ if (e === undefined || e === '') return path.join(ROOT, 'config', 'local.json');
348
+ // A literal device path (e.g. /dev/null) is not a config file either; treat the
349
+ // sentinel and a non-regular file the same way rather than throwing on parse.
350
+ if (/^(none|off|no|-)$/i.test(e)) return null;
351
+ return e;
352
+ }
353
+
354
+ // `ifaces` is injectable so tests are hermetic. Without it, validating a bind
355
+ // address against os.networkInterfaces() meant any test naming a bind address
356
+ // could only pass by naming THIS machine's real address -- fragile across lease
357
+ // changes, and a standing reason to commit a host address into the repository.
358
+ /**
359
+ * `ifaces` and `now` are injectable so the checks below are testable without
360
+ * touching this machine's real interfaces or waiting for a certificate to expire.
361
+ * Both have production defaults; neither is read from anywhere but the caller.
362
+ */
363
+ export function loadConfig({ configFile = defaultConfigFile(), ifaces = null, now = Date.now() } = {}) {
364
+ let fileCfg = {};
365
+ if (configFile && fs.existsSync(configFile) && fs.statSync(configFile).isFile()) {
366
+ try {
367
+ fileCfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
368
+ } catch (err) {
369
+ throw new Error(`config: cannot parse ${configFile}: ${err.message}`);
370
+ }
371
+ }
372
+ let cfg = deepMerge(structuredClone(DEFAULTS), fileCfg);
373
+
374
+ const e = {
375
+ 'BLOCKYARD_HOST': ['server.host', hostList],
376
+ 'BLOCKYARD_PORT': ['server.port', Number],
377
+ 'BLOCKYARD_BIND': ['server.host', hostList],
378
+ 'BLOCKYARD_ALLOW_CIDRS': ['server.allowCidrs', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
379
+ 'BLOCKYARD_TRUST_PROXY': ['server.trustProxy', Boolean],
380
+ 'BLOCKYARD_NODE_URL': ['__nodeUrl', String],
381
+ 'BLOCKYARD_DATADIR': ['__datadir', String],
382
+ 'BLOCKYARD_LOGFILE': ['__logfile', String],
383
+ 'BLOCKYARD_COOKIE': ['__cookie', String],
384
+ 'BLOCKYARD_UNIT': ['__unit', String],
385
+ 'BLOCKYARD_NODE_LABEL': ['__label', String],
386
+ 'BLOCKYARD_RPC_TIMEOUT': ['rpc.timeoutMs', Number],
387
+ 'BLOCKYARD_RPC_MIN_INTERVAL': ['rpc.minIntervalMs', Number],
388
+ 'BLOCKYARD_RPC_STALE_DROP': ['rpc.staleDropMs', Number],
389
+ 'BLOCKYARD_DATA': ['store.dir', String],
390
+ 'BLOCKYARD_AUTH': ['auth.enabled', Boolean],
391
+ 'BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH': ['actions.allowWritesWithoutAuth', Boolean],
392
+ // Run on RPC alone: 0 turns the log tail off for every node. Measured why is
393
+ // in server/collect/monitor.js and MEASUREMENTS 3/4 -- bandwidth and per-peer
394
+ // bytes work on builds that publish them and do not on the deployed one.
395
+ 'BLOCKYARD_LOG_SOURCE': ['log.enabled', Boolean],
396
+ 'BLOCKYARD_MARKETS': ['markets.enabled', Boolean],
397
+ 'BLOCKYARD_SECURE_COOKIE': ['auth.secureCookie', Boolean],
398
+ 'BLOCKYARD_TLS_CERT': ['server.tls.cert', String],
399
+ 'BLOCKYARD_TLS_KEY': ['server.tls.key', String],
400
+ 'BLOCKYARD_ACTIONS': ['actions.allow', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
401
+ 'BLOCKYARD_ENABLE_ACTIONS': ['actions.enabled', Boolean],
402
+ 'BLOCKYARD_LOG_LEVEL': ['log.level', String],
403
+ 'BLOCKYARD_RETENTION_HOURS': ['store.retentionHours', Number],
404
+ };
405
+ // Sentinel keys gathered below (they address nodes[0], a fixed path would not).
406
+ const sentinels = {};
407
+ for (const [name, [p, cast]] of Object.entries(e)) {
408
+ const v = env(name, cast);
409
+ if (v === undefined) continue;
410
+ if (p.startsWith('__')) { sentinels[p.slice(2)] = v; continue; }
411
+ setPath(cfg, p, v);
412
+ }
413
+ if (sentinels.nodeUrl) {
414
+ cfg.nodes[0].rpcUrl = sentinels.nodeUrl;
415
+ cfg.nodes[0].__urlOverridden = true;
416
+ }
417
+ if (sentinels.datadir) {
418
+ cfg.nodes[0].datadir = sentinels.datadir;
419
+ cfg.nodes[0].cookieFile = null;
420
+ }
421
+ if (sentinels.logfile) cfg.nodes[0].logFile = sentinels.logfile;
422
+ if (sentinels.cookie) cfg.nodes[0].cookieFile = sentinels.cookie;
423
+ if (sentinels.unit) cfg.nodes[0].systemdUnit = sentinels.unit;
424
+ // A node is named by whoever knows its name: an explicit label -- from the environment or from
425
+ // the file -- is the operator speaking, and always wins. Failing that, a node whose URL was
426
+ // overridden is named by the endpoint it actually answers on, which cannot be wrong. Keeping
427
+ // the built-in label there would state something false about a node nobody named; the
428
+ // fallback states only what was measured. Nodes nobody redirected keep their built-in name.
429
+ const fileNode0 = Array.isArray(fileCfg.nodes) ? fileCfg.nodes[0] : null;
430
+ const fileLabel = fileNode0 && fileNode0.label;
431
+ // The address this node was already configured for, before the environment spoke. An override
432
+ // that names this same address has redirected nothing, so the built-in name still describes the
433
+ // node being polled and must stand.
434
+ const baseUrl = (fileNode0 && fileNode0.rpcUrl) || DEFAULTS.nodes[0].rpcUrl;
435
+ const movedNode = !!sentinels.nodeUrl && sentinels.nodeUrl !== baseUrl;
436
+ // Guarded, because `nodes: []` is refused by validate() below with a sentence that names the
437
+ // mistake, and reaching into nodes[0] before then would replace that sentence with a TypeError.
438
+ if (cfg.nodes[0]) {
439
+ if (sentinels.label) cfg.nodes[0].label = sentinels.label;
440
+ else if (movedNode && !fileLabel) {
441
+ let host = cfg.nodes[0].rpcUrl;
442
+ try { host = new URL(cfg.nodes[0].rpcUrl).host; } catch { /* validate() reports a bad URL */ }
443
+ cfg.nodes[0].label = `node @ ${host}`;
444
+ }
445
+ }
446
+
447
+ if (env('BLOCKYARD_FAKE_NODE', Boolean)) cfg.__fakeNode = true;
448
+
449
+ cfg.store.dir = cfg.store.dir || path.join(ROOT, 'data');
450
+ cfg.auth.dataDir = cfg.auth.dataDir || cfg.store.dir;
451
+ cfg.nodes.forEach((n, i) => { n.id = n.id || `node-${i}`; });
452
+
453
+ validate(cfg, ifaces, now);
454
+ cfg.__defaultsDoc = HERE_DOC;
455
+ // WHICH FILE THIS CAME FROM. The path was an argument, used and then forgotten, so nothing
456
+ // downstream could say where the settings live -- and a UI that offers to save a connection must
457
+ // name the file it would write rather than guess at one. `null` is a real answer: a hermetic run
458
+ // (BLOCKYARD_CONFIG=none) has no file, and a save must be refused rather than invent a path.
459
+ cfg.__configFile = configFile ?? null;
460
+ return cfg;
461
+ }
462
+
463
+ function setPath(obj, dotted, value) {
464
+ const parts = dotted.split('.');
465
+ let cur = obj;
466
+ for (let i = 0; i < parts.length - 1; i++) {
467
+ if (!isPlainObject(cur[parts[i]])) cur[parts[i]] = {};
468
+ cur = cur[parts[i]];
469
+ }
470
+ cur[parts[parts.length - 1]] = value;
471
+ }
472
+
473
+ const problems = [];
474
+ export function configProblems() { return problems; }
475
+
476
+ /**
477
+ * TLS, checked at load, because every failure mode here is worse later.
478
+ *
479
+ * Half a TLS configuration is the important one. `cert` without `key` would
480
+ * otherwise be silently ignored, which means an operator who set one line believed
481
+ * the monitor was serving HTTPS while it was serving plaintext on the same port.
482
+ * So a partial pair is fatal, not warned.
483
+ *
484
+ * The certificate is parsed with crypto.X509Certificate (a builtin, so no
485
+ * dependency for a fact this important) and an expired cert is fatal too: a browser
486
+ * refusing the connection is not information the dashboard can surface, because the
487
+ * dashboard is on the other side of that refusal.
488
+ */
489
+ /**
490
+ * TLS, checked at load. `now` is injectable so the expiry branch is a tested
491
+ * branch rather than a comment about a future date.
492
+ */
493
+ function validateTls(cfg, now = Date.now()) {
494
+ const tls = cfg.server.tls ?? {};
495
+ cfg.server.tls = tls;
496
+ cfg.tls = Boolean(tls.cert || tls.key);
497
+ if (!cfg.tls) return;
498
+ if (!tls.cert || !tls.key) {
499
+ problems.push(`server.tls needs BOTH cert and key (got ${tls.cert ? 'cert only' : 'key only'}); a half-configured TLS would fall back to plaintext on a port you believe is HTTPS`);
500
+ return;
501
+ }
502
+ for (const [what, file] of [['cert', tls.cert], ['key', tls.key]]) {
503
+ try {
504
+ fs.readFileSync(file);
505
+ } catch (err) {
506
+ problems.push(`server.tls.${what} cannot be read (${file}: ${err.code ?? err.message})`);
507
+ }
508
+ }
509
+ if (problems.length) return;
510
+ try {
511
+ const x = new crypto.X509Certificate(fs.readFileSync(tls.cert, 'utf8'));
512
+ tls.fingerprint = x.fingerprint256;
513
+ tls.notAfter = Date.parse(x.validTo);
514
+ tls.selfSigned = x.issuer === x.subject;
515
+ if (!Number.isFinite(tls.notAfter)) problems.push('server.tls.cert has no parseable validity window');
516
+ else if (tls.notAfter <= now) {
517
+ problems.push(`server.tls.cert expired ${new Date(tls.notAfter).toISOString()}; the browser will refuse the connection, and the dashboard cannot tell you so from behind that refusal`);
518
+ } else if (tls.notAfter - now < 14 * 86_400_000) {
519
+ cfg.__tlsExpiring = `certificate expires ${new Date(tls.notAfter).toISOString()}`;
520
+ }
521
+ } catch (err) {
522
+ problems.push(`server.tls.cert is not a parseable X.509 certificate: ${err.message}`);
523
+ }
524
+ }
525
+
526
+
527
+ function validate(cfg, ifaces = null, now = Date.now()) {
528
+ problems.length = 0;
529
+ if (!Array.isArray(cfg.nodes) || cfg.nodes.length === 0) problems.push('nodes must be non-empty');
530
+ if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) problems.push('server.port invalid');
531
+ // `hosts` is the truth: one address, a comma list, or an array all normalise here.
532
+ // `host` stays populated with the first entry for anything that still reads it.
533
+ cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '0.0.0.0');
534
+ cfg.server.host = cfg.server.hosts[0];
535
+ if (!cfg.server.hosts.length) problems.push('server.hosts is empty; nothing would be served');
536
+ // A hostname here binds whatever DNS says at boot, and fails at listen() with a
537
+ // message about names -- or, after a reboot with changed DNS, at the worst moment.
538
+ // Refuse it at load and name the alternative.
539
+ for (const h of cfg.server.hosts) {
540
+ if (!isBindableHost(h)) problems.push(`server.hosts entry "${h}" is not an address literal; use an IPv4/IPv6 address, 0.0.0.0, or localhost`);
541
+ }
542
+ // Warn at boot -- not fatal -- about addresses this machine cannot take right now
543
+ // (a tunnel interface that comes up after us is the common case).
544
+ const plan = planBinds(cfg.server.hosts, ifaces ?? undefined);
545
+ if (plan.noneUsable) problems.push(`none of the configured bind addresses (${plan.list.join(', ')}) exist on this machine; refusing to start with nothing to serve`);
546
+ else if (plan.missing.length) cfg.server.hostsMissing = plan.missing;
547
+ if (cfg.rpc.maxInFlight < 1) problems.push('rpc.maxInFlight must be >= 1');
548
+ if (cfg.poll.fastMs < 1000) problems.push('poll.fastMs below 1s risks hammering a single-threaded RPC server');
549
+ // An allowlist entry that does not parse is an inert rule, and an inert rule in an
550
+ // allowlist is a hole that looks like a policy. The matcher also refuses to let one
551
+ // admit anything (server/netinfo.js ipDecision), but the operator has to hear about
552
+ // it at boot rather than notice when the gate disagrees with the file.
553
+ for (const c of cfg.server.allowCidrs ?? []) {
554
+ const parsed = parseCidr(c);
555
+ if (!parsed.ok) problems.push(`server.allowCidrs entry "${c}" is unusable: ${parsed.reason}`);
556
+ }
557
+ validateTls(cfg, now);
558
+ // "Open to everyone" plus "node writes enabled" is the one combination where the
559
+ // role gate is vacuous: with no accounts there is no role to check, so every
560
+ // action in actions.allow is callable by whoever can reach the port. Refuse to
561
+ // boot on that configuration rather than trusting that the operator meant it; the
562
+ // override exists precisely so it has to be chosen twice.
563
+ if (cfg.actions.enabled && !cfg.auth.enabled && !cfg.actions.allowWritesWithoutAuth) {
564
+ problems.push('node actions are enabled while accounts are OFF, which would let any address that can reach the port call them (there is no role to check). Either set BLOCKYARD_AUTH=1, or set BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1 deliberately alongside BLOCKYARD_ACTIONS.');
565
+ }
566
+ if (!cfg.auth.enabled) {
567
+ // Not a problem, a fact the operator should see once at boot.
568
+ cfg.__openAccess = true;
569
+ }
570
+ if (!(Number.isInteger(cfg.store.blockMapCap) && cfg.store.blockMapCap >= 100)) {
571
+ problems.push('store.blockMapCap must be an integer >= 100; a block map that holds nothing renders "no blocks" as if it were a fact');
572
+ }
573
+ if (!(Number.isInteger(cfg.store.auditMaxBytes) && cfg.store.auditMaxBytes >= 64 * 1024)) {
574
+ problems.push('store.auditMaxBytes must be >= 65536; below that the audit rotates on every write');
575
+ }
576
+ for (const n of cfg.nodes) {
577
+ if (!n.rpcUrl || !/^https?:\/\//.test(n.rpcUrl)) problems.push(`node ${n.id}: rpcUrl must be http(s)://host:port`);
578
+ // AUTHENTICATION CAN COME FROM EITHER PLACE. Cookie auth needs a datadir (or an explicit
579
+ // cookieFile) to read <datadir>/<chain>/.cookie -- but a node that authenticates with rpcauth
580
+ // has no cookie to read, and rpcUser/rpcPassword is the way in. resolveCookie() has always
581
+ // supported that (it falls through to the configured user/password); this validator did not,
582
+ // so a user/password node was refused at boot with "need datadir or cookieFile". Found
583
+ // 2026-09-13. BlockYard runs on the node's machine (the explorer needs its block files), so
584
+ // this is for a node that uses rpcauth, not a node elsewhere.
585
+ const hasCookiePath = !!(n.datadir || n.cookieFile);
586
+ const hasUserPass = !!(n.rpcUser && n.rpcPassword);
587
+ if (!hasCookiePath && !hasUserPass) {
588
+ problems.push(`node ${n.id}: needs either datadir/cookieFile (cookie auth, same machine) or rpcUser + rpcPassword (a node authenticating with rpcauth)`);
589
+ }
590
+ if (n.rpcUser && !n.rpcPassword) problems.push(`node ${n.id}: rpcUser is set but rpcPassword is empty`);
591
+ }
592
+ if (problems.length) {
593
+ throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`);
594
+ }
595
+ }
596
+
597
+ // Cookie resolution. <datadir>/<chain>/.cookie is the real layout; the file is
598
+ // regenerated on every boot and deleted on shutdown, so a cached cookie is a
599
+ // liability and callers re-resolve on 401 rather than trusting a stale value.
600
+ export function resolveCookie(node) {
601
+ const candidates = [];
602
+ if (node.cookieFile) candidates.push(node.cookieFile);
603
+ if (node.datadir) {
604
+ if (node.chainHint) candidates.push(path.join(node.datadir, node.chainHint, '.cookie'));
605
+ candidates.push(path.join(node.datadir, '.cookie'));
606
+ try {
607
+ for (const ent of fs.readdirSync(node.datadir, { withFileTypes: true })) {
608
+ if (ent.isDirectory()) candidates.push(path.join(node.datadir, ent.name, '.cookie'));
609
+ }
610
+ } catch { /* datadir not readable from here; the explicit paths still stand */ }
611
+ }
612
+ for (const c of candidates) {
613
+ try {
614
+ const raw = fs.readFileSync(c, 'utf8').trim();
615
+ if (raw.includes(':')) return { user: raw.split(':')[0], password: raw.slice(raw.indexOf(':') + 1), source: c };
616
+ } catch { /* try next */ }
617
+ }
618
+ if (node.rpcUser) return { user: node.rpcUser, password: node.rpcPassword || '', source: 'config' };
619
+ return null;
620
+ }