blockyard 0.0.1 → 0.1.0

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