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
package/server/main.js ADDED
@@ -0,0 +1,628 @@
1
+ // Entry point: build the app, boot the monitors, serve, and shut down cleanly.
2
+ import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { loadConfig, ROOT } from './config.js';
6
+ import { History } from './store/history.js';
7
+ import { AuditLog } from './store/audit.js';
8
+ import { UserStore, randomPassword } from './auth/users.js';
9
+ import { SessionStore, RateLimiter, LoginGuard } from './auth/sessions.js';
10
+ import { StreamHub } from './http/sse.js';
11
+ import { createAppServer } from './http/server.js';
12
+ import { computeBuildId } from './http/static.js';
13
+ import { NodeMonitor } from './collect/monitor.js';
14
+ import { localAddresses, bindProblemMessage, planBinds } from './netinfo.js';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ // ONE PLACE, NOT TWO. This was a literal here AND a "version" field in package.json, and on
18
+ // 2026-09-13 they had drifted: this said 0.0.9 while CHANGELOG.md released [0.9.0]. Harmless until
19
+ // something compares versions -- and the auto-update design (docs/AUTO-UPDATE.md) compares exactly
20
+ // this field to decide whether a release is newer, so the drift would have made it answer wrongly.
21
+ // package.json is the authority; a test asserts the two agree.
22
+ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
23
+
24
+ export async function boot({ configFile, log: logOverride = null } = {}) {
25
+ const cfg = loadConfig({ configFile });
26
+ await fsp.mkdir(cfg.store.dir, { recursive: true });
27
+ await fsp.mkdir(cfg.auth.dataDir, { recursive: true });
28
+
29
+ const app = {
30
+ version: VERSION,
31
+ cfg,
32
+ // the file the settings were read from, or null for a run that was given none
33
+ configFile: cfg.__configFile ?? null,
34
+ // DISPLAY SETTINGS live on the server (operator, 2026-09-13: "This is a server app. Should
35
+ // store things on a server"). They were per-browser localStorage, which meant a kiosk screen
36
+ // and a desk looking at the same monitor kept different answers and neither could be read back.
37
+ // Beside local.json rather than in data/: it is configuration a person chose, not runtime state.
38
+ //
39
+ // DERIVED FROM THE CONFIG FILE, not pinned to ROOT. A hardcoded repo path meant any test that
40
+ // POSTed settings wrote the REAL config/blockyard.json of the working copy -- a test run
41
+ // clobbering a deployment's own preferences. Following configFile puts it in the temp dir for a
42
+ // hermetic boot and leaves it exactly where it already is for this one (the unit sets no
43
+ // BLOCKYARD_CONFIG, so configFile is ROOT/config/local.json and the dirname is unchanged).
44
+ // env is deliberately NOT the lever: helpers/http.js touches no process.env, because Node runs
45
+ // a file's tests concurrently and a mutated var leaks into a sibling's boot.
46
+ settingsFile: path.join(
47
+ cfg.__configFile ? path.dirname(cfg.__configFile) : path.join(ROOT, 'config'),
48
+ 'blockyard.json',
49
+ ),
50
+ startedAt: Date.now(),
51
+ publicDir: path.join(ROOT, 'public'),
52
+ monitors: new Map(),
53
+ stateSeq: 0,
54
+ rssStart: process.memoryUsage().rss,
55
+ cpuStart: process.cpuUsage(),
56
+ selfRing: [],
57
+ };
58
+
59
+ app.log = logOverride ?? makeLogger(cfg);
60
+ // `logOverride` exists so a test can capture what the boot said. The alternative —
61
+ // replacing process.stdout.write for the duration — raced the test runner's own TAP
62
+ // writer and swallowed the results of other tests in the same file, which is a
63
+ // worse outcome than the silence it was trying to observe.
64
+
65
+ // ---------------------------------------------------------------- TLS
66
+ // Decided here, before any listener exists, and the cookie follows it: a Secure
67
+ // cookie on an HTTP listener is a cookie the browser will not send, which reads
68
+ // as "login keeps failing" -- so the two settings must not be independently set.
69
+ app.tls = Boolean(cfg.server.tls?.cert && cfg.server.tls?.key);
70
+ if (app.tls) {
71
+ app.tlsOptions = {
72
+ cert: fs.readFileSync(cfg.server.tls.cert),
73
+ key: fs.readFileSync(cfg.server.tls.key),
74
+ };
75
+ if (!cfg.auth.secureCookie) {
76
+ cfg.auth.secureCookie = true;
77
+ app.log({ level: 'info', msg: 'TLS is on, so the session cookie is now Secure (a Secure cookie over plain HTTP is never sent, which looks like a login that will not stick)' });
78
+ }
79
+ app.log({ level: 'warn', msg: `TLS on (fingerprint ${String(cfg.server.tls.fingerprint).slice(0, 17)}…${cfg.server.tls.selfSigned ? ', self-signed: expect a browser warning the first time per address' : ''})${cfg.__tlsExpiring ? `; WARNING ${cfg.__tlsExpiring}` : ''}` });
80
+ } else if (cfg.auth.enabled) {
81
+ app.log({ level: 'warn', msg: 'serving HTTP, not HTTPS: the session cookie and every RPC reply cross the LAN in the clear. Either put a TLS terminator in front (then BLOCKYARD_SECURE_COOKIE=1), name server.tls.cert/key, or bind 127.0.0.1 and use an SSH tunnel -- see README, "TLS, or the lack of it".' });
82
+ }
83
+ app.scheme = app.tls ? 'https' : 'http';
84
+
85
+ // The build id the pages are stamped with and /api/build reports. The browser
86
+ // compares the two and says "this tab is running an older build" in words;
87
+ // without it, a stale tab is indistinguishable from a fix that did not land.
88
+ app.buildId = async () => computeBuildId(app.publicDir, VERSION);
89
+ app.build = await app.buildId();
90
+
91
+ app.history = new History(cfg.store.dir, cfg.store, { log: app.log });
92
+ const loaded = await app.history.load();
93
+ app.log({ level: 'info', msg: `history: ${loaded.loaded ? `restored ${loaded.points} points from ${new Date(loaded.savedAt).toISOString()}` : `starting empty (${loaded.reason})`}` });
94
+ app.history.startAutosave();
95
+
96
+ app.users = new UserStore(path.join(cfg.auth.dataDir, 'users.json'), cfg.auth);
97
+ const userLoad = await app.users.load();
98
+ app.sessions = new SessionStore(path.join(cfg.auth.dataDir, 'sessions.json'), cfg.auth);
99
+ await app.sessions.load();
100
+ app.guard = new LoginGuard(cfg.auth);
101
+ app.limiter = new RateLimiter({ capacity: 120, perSec: 40 });
102
+ // A second, separate bucket for /api/login only. LoginGuard answers "this
103
+ // username keeps failing"; the per-user token bucket cannot cover login because
104
+ // it is keyed on the authenticated user and login has none yet. What was
105
+ // unmitigated was the slow distributed grind: N addresses at 1 attempt each per
106
+ // second, under every lockout threshold. Cost of a login attempt is a scrypt KDF
107
+ // (~50 ms, 16 MB) on the request thread, so this is also the only thing keeping
108
+ // a cheap flood from becoming a CPU denial of service on the monitor.
109
+ app.loginLimiter = new RateLimiter({ capacity: 10, perSec: 0.5 });
110
+ app.hub = new StreamHub({ log: app.log });
111
+
112
+ // First run has to produce a credential somehow. Env-provided wins; otherwise
113
+ // one is generated, used once and never persisted in recoverable form.
114
+ let bootstrap = null;
115
+ if (cfg.auth.enabled && app.users.count === 0) {
116
+ const pw = process.env.BLOCKYARD_ADMIN_PASSWORD || randomPassword(20);
117
+ const created = await app.users.createUser('admin', pw, { role: 'admin' });
118
+ bootstrap = { username: created.username, password: pw, generated: !process.env.BLOCKYARD_ADMIN_PASSWORD };
119
+ app.log({ level: 'warn', msg: `created the first admin account (${created.username}) -- ${bootstrap.generated ? 'generated password below is shown once' : 'password from BLOCKYARD_ADMIN_PASSWORD'}` });
120
+ }
121
+ app.bootstrap = bootstrap;
122
+
123
+ // Open access is a posture, so it is announced rather than left implicit. The line
124
+ // names the addresses that are now readable by anyone and the one switch that
125
+ // closes it, because "anyone on the LAN can read the node" must never be something
126
+ // an operator discovers from a screenshot.
127
+ if (!cfg.auth.enabled) {
128
+ const where = (cfg.server.hosts ?? [cfg.server.host]).join(', ') || '(wildcard)';
129
+ app.log({
130
+ level: 'warn',
131
+ msg: `NO SIGN-IN (auth.enabled=false, the default): anyone who can reach ${where}:${cfg.server.port} reads this monitor — charts, the event feed, peer and mempool detail, and the read-only RPC console — as role "viewer". Not open to them: user administration, the audit trail, password changes, and node writes (set BLOCKYARD_AUTH=1 for accounts, roles, sessions and CSRF).`,
132
+ });
133
+ }
134
+
135
+ if (cfg.__fakeNode) {
136
+ const { startFakeNode } = await import('../scripts/fake-node.js');
137
+ const logFile = path.join(cfg.store.dir, 'fake-node.log');
138
+ let fake;
139
+ try {
140
+ fake = await startFakeNode({
141
+ port: Number(process.env.FAKE_PORT || 18331),
142
+ logFile,
143
+ ibd: process.env.FAKE_IBD !== '0',
144
+ catchupBlocksPerSec: Number(process.env.FAKE_RATE || 9),
145
+ });
146
+ } catch (err) {
147
+ throw new Error(`dev mode needs a local fake node: ${err.message}`);
148
+ }
149
+ cfg.nodes[0].rpcUrl = fake.url;
150
+ cfg.nodes[0].cookieFile = null;
151
+ cfg.nodes[0].rpcUser = 'fake';
152
+ cfg.nodes[0].rpcPassword = 'fake';
153
+ cfg.nodes[0].datadir = cfg.store.dir;
154
+ cfg.nodes[0].logFile = logFile;
155
+ cfg.nodes[0].label = 'Fake node (IBD simulation)';
156
+ // Dev and smoke runs are hermetic: keep ONLY the fake. Leaving the real
157
+ // default nodes in place would have `npm run dev` and scripts/smoke.sh
158
+ // polling the operator's actual bench node -- a benchmark run disturbed by a
159
+ // test suite is a nasty class of interference.
160
+ cfg.nodes = [cfg.nodes[0]];
161
+ app.fakeNode = fake;
162
+ app.log({ level: 'warn', msg: `fake node running at ${fake.url}, logging to ${logFile}` });
163
+ }
164
+
165
+ // RPC-only mode. Blanked here rather than in config.js so the config object
166
+ // stays declarative and this line is the one place that says what is lost.
167
+ if (!cfg.log.enabled) {
168
+ for (const n of cfg.nodes) {
169
+ if (n.logFile) app.log({ level: 'info', msg: `node "${n.id}": ignoring logFile ${n.logFile} because the log source is disabled (BLOCKYARD_LOG_SOURCE=0)` });
170
+ n.logFile = null;
171
+ }
172
+ app.log({ level: 'warn', msg: 'log source DISABLED: running on RPC only -- which is the supported mode, and the only one for Bitcoin Core (the log parsers target an experimental node grammar; see docs/CONFIGURATION.md). Bandwidth and per-peer bytes work only on node builds that publish them (measured 2026-09-08: bench build 11.56 MB/s via getnettotals against 11.2 MB/s stated in its log; production build 0 bytes and getpeerinfo [] with 16 connections). Per-peer relay legs, served-block attribution, tx accept/reject counts, disk-write rate, worker bans, the node\'s own ETA, compaction/validation stalls and sync_failing have no RPC source at all.' });
173
+ } else {
174
+ // THE INVERSE CASE, said out loud. Turning the log source on against Bitcoin Core produces
175
+ // nothing useful and is easy to mistake for a configuration problem: logparse.js targets an
176
+ // experimental node's grammar ([dlc], [dl], [dial], [utxo_live], [config], and a
177
+ // "YYYY-MM-DD HH:MM:SS.mmm " timestamp), so Core's debug.log lines come back as unstructured
178
+ // `raw` events with no fields, timestamped when they were READ rather than when they were
179
+ // written. The boot log is the most visible place to say so before someone spends an evening
180
+ // wondering why the bandwidth chart is empty. (operator, 2026-09-13.)
181
+ app.log({
182
+ level: 'warn',
183
+ msg: 'log source ENABLED: note that log parsing does NOT support Bitcoin Core -- the parsers '
184
+ + 'were written and tested against an experimental node implementation with a different log '
185
+ + 'grammar. Against a Core debug.log every line is kept as an unstructured event with no '
186
+ + 'figures extracted and a timestamp taken at read time, so panels that need the log stay '
187
+ + 'empty and the event feed is misdated. Set BLOCKYARD_LOG_SOURCE=0 (the default) unless your '
188
+ + 'node writes the format in test/fixtures/. See docs/CONFIGURATION.md, "RPC-only mode".',
189
+ });
190
+ }
191
+
192
+ for (const nodeCfg of cfg.nodes) {
193
+ // Cookie auth is impossible without the datadir, so a configured node whose
194
+ // datadir has gone (a benchmark directory that got cleaned up) is skipped
195
+ // with a reason rather than kept as a permanently-offline panel. A wrong
196
+ // "offline" is worse than an absent one: it invites someone to go fix a node
197
+ // that is running fine.
198
+ if (nodeCfg.datadir && !nodeCfg.cookieFile && !fs.existsSync(nodeCfg.datadir)) {
199
+ app.log({ level: nodeCfg.optional ? 'info' : 'warn', msg: `skipping node "${nodeCfg.id}": datadir ${nodeCfg.datadir} does not exist, so its RPC cookie cannot be read (remove it from config.nodes or point it at a live node)` });
200
+ continue;
201
+ }
202
+ const m = new NodeMonitor(nodeCfg, { rpc: cfg.rpc, poll: cfg.poll, store: cfg.store, log: app.log, history: app.history, logCfg: cfg.log, miningCfg: {
203
+ // Two cheap reads per block on the shared lane (measured 2026-09-09: 8 ms + 63 ms).
204
+ // BLOCKYARD_MINING=0 turns attribution off entirely; the sizes, fees and weights stay.
205
+ enabled: process.env.BLOCKYARD_MINING !== '0',
206
+ backfill: Number(process.env.BLOCKYARD_MINING_BACKFILL ?? 36),
207
+ // The block being built is assembled from the mempool the pool tier already reads
208
+ // (collect/gbt.js), so it costs the node no call of its own -- it used to be a
209
+ // getblocktemplate worth 1.3-1.5 s of the node's single RPC thread.
210
+ // BLOCKYARD_MINING_TEMPLATE=0 still turns the card off for anyone who does not want it.
211
+ template: process.env.BLOCKYARD_MINING_TEMPLATE !== '0',
212
+ perTick: 1,
213
+ // A human-edited tag -> label map. Absent by default, which is the correct state:
214
+ // the coinbase text is shown as the pool wrote it.
215
+ aliasesFile: path.join(cfg.store.dir, 'pool-aliases.json'),
216
+ // Written by `node scripts/pool-map.js` into data/; until someone runs it, the copy shipped in
217
+ // config/ (mempool.space/mining-pools, MIT, fetched 2026-09-09) -- a fresh install attributed
218
+ // nothing and showed raw coinbase tags (operator, 2026-09-14: "Block attribution is fucked up")
219
+ poolMapFile: process.env.BLOCKYARD_POOL_MAP ?? (fs.existsSync(path.join(cfg.store.dir, 'pool-map.json')) ? path.join(cfg.store.dir, 'pool-map.json') : path.join(ROOT, 'config', 'pool-map.json')),
220
+ } });
221
+ m.node = nodeCfg;
222
+ // Deliberately NOT `m.history = app.history`: the monitor wraps the shared store
223
+ // in a node-scoped view in its constructor, and reassigning the raw store here is
224
+ // what made every node's charts draw the average of all nodes.
225
+ wireMonitor(app, m);
226
+ app.monitors.set(nodeCfg.id, m);
227
+ await m.start();
228
+ }
229
+ app.primary = [...app.monitors.values()][0] ?? null;
230
+
231
+ // Exchange prices for the Markets tab: the one outbound connection that is not the node,
232
+ // and it polls only while someone has that tab open (server/collect/markets.js).
233
+ if (cfg.markets?.enabled) {
234
+ const { MarketFeed } = await import('./collect/markets.js');
235
+ app.markets = new MarketFeed(cfg.markets, { log: app.log });
236
+ } else app.markets = null;
237
+
238
+ app.auditLog = new AuditLog(path.join(cfg.store.dir, 'audit.jsonl'), {
239
+ maxBytes: cfg.store.auditMaxBytes,
240
+ keep: cfg.store.auditKeep,
241
+ log: app.log,
242
+ });
243
+ await app.auditLog.adopt();
244
+ // REDACT BY SHAPE, not by a list of two names. Deleting `password` and `rpcPassword` covered the
245
+ // fields today's routes happen to carry -- but the trail also stores action ARGUMENTS and a
246
+ // 200-character preview of action RESULTS, so the next action that echoes a key-shaped argument
247
+ // would write it into audit.jsonl for ever, where the whole point of the file is that it is kept.
248
+ // An audit on 2026-09-13 pointed at exactly that gap. Nested, because arguments are objects.
249
+ //
250
+ // `key` alone is deliberately NOT in the pattern: it would redact poolKey, labelKey and keylen,
251
+ // which are not secrets, and an audit trail full of [redacted] where the useful fields were is
252
+ // its own kind of failure.
253
+ const SECRETISH = /pass(word|phrase)?|secret|cookie|token|priv(ate)?_?key|seed|mnemonic|authorization|credential/i;
254
+ const redact = (v, depth = 0) => {
255
+ if (v == null || depth > 6) return v;
256
+ if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
257
+ if (typeof v !== 'object') return v;
258
+ const out = {};
259
+ for (const [k, val] of Object.entries(v)) out[k] = SECRETISH.test(k) ? '[redacted]' : redact(val, depth + 1);
260
+ return out;
261
+ };
262
+ app.audit = async (row) => {
263
+ const entry = redact({ at: Date.now(), ...row });
264
+ await app.auditLog.append(entry).catch((err) => app.log({ level: 'error', msg: `audit write failed: ${err.message}` }));
265
+ };
266
+ app.readAudit = async (limit = 100) => app.auditLog.read(limit);
267
+
268
+ app.access = ({ req, path: p, status, ms, ip, user = null, error = null }) => {
269
+ if (p === '/api/stream') return;
270
+ const slow = ms > 3000;
271
+ if (status >= 400 || slow) {
272
+ app.log({ level: status >= 500 ? 'error' : 'warn', msg: `${req.method} ${p} -> ${status} in ${ms}ms${error ? ` (${error})` : ''} user=${user ?? '-'} ip=${ip}` });
273
+ }
274
+ };
275
+
276
+ app.selfTelemetry = () => {
277
+ const mem = process.memoryUsage();
278
+ const cpu = process.cpuUsage(app.cpuStartBaseline ?? process.cpuUsage());
279
+ const wall = Date.now() - app.startedAt;
280
+ const cpuPct = wall > 0 ? ((cpu.user + cpu.system) / 1000 / wall) * 100 : 0;
281
+ app.cpuStartBaseline ||= process.cpuUsage();
282
+ const row = {
283
+ t: Date.now(),
284
+ rssMb: +(mem.rss / 1048576).toFixed(1),
285
+ heapMb: +(mem.heapUsed / 1048576).toFixed(1),
286
+ sseClients: app.hub.clients.size,
287
+ usersActive: app.sessions.active().users,
288
+ cpuPct: +cpuPct.toFixed(2),
289
+ eventRate: app.eventWindow?.rate ?? 0,
290
+ build: app.build,
291
+ };
292
+ app.selfRing.push(row);
293
+ if (app.selfRing.length > 5000) app.selfRing.splice(0, app.selfRing.length - 5000);
294
+ return row;
295
+ };
296
+
297
+ // One HTTP server per bound address, all sharing `app`. A single server cannot
298
+ // listen twice (ERR_SERVER_ALREADY_LISTEN), and the wildcard is exactly what we
299
+ // are avoiding, so the fan-out lives here. The rate limiter, sessions and monitors
300
+ // all live on `app`, so an attacker picking a different address gets the same
301
+ // budget, not another one. (StaticFiles is per server -- duplicated ETag state for
302
+ // a couple of hundred KB of assets, which is cheaper than pretending one socket
303
+ // can speak for two interfaces.)
304
+ app.servers = [];
305
+ app.server = null;
306
+
307
+ const hosts = cfg.server.hosts ?? [cfg.server.host];
308
+ // Binding is a decision with casualties, so make them visible: addresses the
309
+ // machine lacks are reported and skipped (a tunnel interface that comes up after
310
+ // us must not stop the monitor serving the interfaces that exist), and only
311
+ // "nothing here is bindable" is fatal. Loopback and the container bridges are
312
+ // excluded by construction when specific addresses are named -- and saying so at
313
+ // boot is what saves the next person an hour, because a refused 127.0.0.1 curl is
314
+ // indistinguishable from a dead monitor.
315
+ const plan = planBinds(hosts);
316
+ if (plan.noneUsable) {
317
+ app.log({ level: 'error', msg: bindProblemMessage({ err: { code: 'EADDRNOTAVAIL' }, host: plan.list.join(' | '), port: cfg.server.port }) });
318
+ process.exitCode = 1;
319
+ await app.history?.save?.().catch(() => {});
320
+ process.exit(1);
321
+ }
322
+ for (const missing of plan.missing) {
323
+ app.log({
324
+ level: 'warn',
325
+ msg: `not binding ${missing}: this machine has no such address right now. If it is a tunnel (tailscale0), start this unit after tailscaled.service, or drop it from server.hosts. Continuing on: ${plan.bindable.join(', ')}`,
326
+ });
327
+ }
328
+ for (const host of plan.bindable) {
329
+ const srv = createAppServer(app);
330
+ app.servers.push(srv);
331
+ if (!app.server) app.server = srv;
332
+ await new Promise((resolve, reject) => {
333
+ srv.once('error', reject);
334
+ srv.listen(cfg.server.port, host, resolve);
335
+ }).catch((err) => {
336
+ app.log({ level: 'error', msg: bindProblemMessage({ err, host, port: cfg.server.port }) });
337
+ // Anything already listening must not be left serving half the addresses --
338
+ // "reachable on the LAN but not the tailnet" is worse than a clean failure.
339
+ for (const s of app.servers) { try { s.close(); } catch { /* already gone */ } }
340
+ process.exit(1);
341
+ });
342
+ }
343
+ const served = plan.bindable.map((h) => `${app.scheme}://${h}:${cfg.server.port}`);
344
+ app.log({ level: 'info', msg: `BlockYard ${VERSION} listening on ${served.join(' and ')}` });
345
+ if (!plan.bindable.includes('0.0.0.0') && !plan.bindable.includes('::')) {
346
+ const v4 = localAddresses().filter((a) => a.family === 'IPv4' && !plan.bindable.includes(a.address) && !a.internal);
347
+ const v6 = localAddresses().filter((a) => a.family === 'IPv6' && !plan.bindable.includes(a.address) && !a.internal).length;
348
+ // warn, not info: when loopback is not among the bound addresses, "connection
349
+ // refused from the machine itself" is the confusion this line exists to prevent,
350
+ // and tooling that runs at LOG_LEVEL=warn (scripts/smoke.sh) would never see it at
351
+ // info. That silencing cost a false "54 failures" reading today.
352
+ app.log({
353
+ level: 'warn',
354
+ msg: `bound to specific interfaces -- NOT reachable on ${v4.map((a) => `${a.address} (${a.name})`).join(', ') || 'other IPv4 addresses'}`
355
+ + `${v6 ? ` (plus ${v6} IPv6 address(es))` : ''}, and not on 127.0.0.1 either: use one of the addresses above from this machine too.`,
356
+ });
357
+ if (plan.missing.length) {
358
+ app.log({ level: 'warn', msg: `skipped at boot: ${plan.missing.join(', ')} -- clients that would have used those addresses will get "connection refused", which is not a crash` });
359
+ }
360
+ }
361
+
362
+ // Periodic series refresh and self-telemetry, plus an event-rate window the
363
+ // telemetry panel uses so "how much is this app doing" is answerable.
364
+ app.eventWindow = { count: 0, since: Date.now(), rate: 0 };
365
+ app.timers = [
366
+ setInterval(() => {
367
+ const now = Date.now();
368
+ const secs = (now - app.eventWindow.since) / 1000;
369
+ app.eventWindow.rate = secs > 0 ? +(app.eventWindow.count / secs).toFixed(2) : 0;
370
+ app.eventWindow.count = 0;
371
+ app.eventWindow.since = now;
372
+ app.selfTelemetry();
373
+ if (app.history.dirtySince && now - app.history.dirtySince > 30_000) app.history.prune();
374
+ }, 10_000),
375
+ setInterval(() => {
376
+ const n = app.sessions.sweep();
377
+ if (n) app.sessions.save().catch(() => {});
378
+ }, 60_000),
379
+ ];
380
+ app.timers.forEach((t) => t.unref?.());
381
+
382
+ // Chart series change slowly; pushing them on every snapshot would be most of
383
+ // the bandwidth for almost no benefit.
384
+ const seriesPush = setInterval(() => {
385
+ if (!app.hub.clients.size) return;
386
+ for (const m of app.monitors.values()) {
387
+ app.hub.pushSeries({ node: m.id, series: m.seriesView({}) }, { nodeId: m.id });
388
+ }
389
+ }, 20_000);
390
+ seriesPush.unref?.();
391
+ app.timers.push(seriesPush);
392
+
393
+ // THE ADDRESS INDEX FOLLOWS THE CHAIN (server/chain/index/live.js): one follower per index
394
+ // directory, fed by a node that uses it -- one with its block files on this machine if there is
395
+ // one, since that is the node the index was built from. A follower that cannot open its index
396
+ // logs why and the address page says the same; nothing else is held up by it.
397
+ const followers = new Map();
398
+ for (const m of app.monitors.values()) {
399
+ const dir = m.cfg?.addressIndex;
400
+ if (!dir) continue;
401
+ const had = followers.get(dir);
402
+ if (!had || (!had.cfg?.datadir && m.cfg?.datadir)) followers.set(dir, m);
403
+ }
404
+ if (followers.size) {
405
+ const { LiveIndex } = await import('./chain/index/live.js');
406
+ const { registerLiveIndex, registerIndexBuild } = await import('./http/explorer.js');
407
+ const { buildIndex, defaultWorkers, rpcPacer } = await import('./chain/index/build.js');
408
+ const follow = (dir, m) => {
409
+ const live = new LiveIndex(dir, { rpc: m.rpc, nodeId: m.id, log: { info: (msg) => app.log({ level: 'info', msg }), warn: (msg) => app.log({ level: 'warn', msg }) } });
410
+ registerLiveIndex(dir, live);
411
+ const tick = () => { live.poll().catch(() => {}); };
412
+ tick();
413
+ const t = setInterval(tick, 30_000);
414
+ t.unref?.();
415
+ app.timers.push(t);
416
+ app.log({ level: 'info', msg: `address index ${dir}: following ${m.id} from block ${live.tip}` });
417
+ };
418
+ // A MISSING INDEX IS BUILT HERE, IN THE BACKGROUND (operator, 2026-09-14: "Is it possible to run
419
+ // step 6 in the background, and have a status notification in blockyard when the index process
420
+ // is finished?"). The build runs on worker threads inside this process while every page keeps
421
+ // serving; its progress is a quality flag the Overview shows and the address page repeats; an
422
+ // event -- which the browser toasts -- marks the start, the finish, or a failure. A node config
423
+ // can say addressIndexBuild: "manual" to keep this from happening.
424
+ const HMS = (sec) => (sec < 90 ? `${Math.round(sec)} s` : sec < 5400 ? `${Math.round(sec / 60)} min` : `${(sec / 3600).toFixed(1)} h`);
425
+ const build = async (dir, m) => {
426
+ // at most four, and half what a dedicated build would take: the node shares this machine's disk
427
+ // (an external one, on the first Mac), and the pacer only sees trouble after it has started
428
+ // ...or the number the config names: on spinning disks one reader is the fast one (parallel
429
+ // readers seek against each other and against the node), so addressIndexWorkers: 1 there
430
+ const workers = Number.isInteger(m.cfg?.addressIndexWorkers) && m.cfg.addressIndexWorkers > 0 ? m.cfg.addressIndexWorkers : Math.max(1, Math.min(4, Math.floor(defaultWorkers() / 2)));
431
+ // ITS OWN CONNECTION (2026-09-14, the first Mac: the build's getblockhash batches sat at the back
432
+ // of the monitor's one-in-flight lane behind multi-second mempool and block reads, and both
433
+ // starved -- "heights 1,000 of 967,015" for a quarter of an hour). The build's calls are cheap
434
+ // and few, on a second lane, exactly as scripts/index-build.js has always run; the pacer still
435
+ // reads the monitor's lane, which is the measure of how the node is coping.
436
+ const { RpcClient } = await import('./rpc/client.js');
437
+ const quiet = { info() {}, warn() {}, error() {}, debug() {} };
438
+ const rpc = new RpcClient(m.cfg, { ...(cfg.rpc ?? {}), ...(m.cfg.rpc ?? {}) }, { log: quiet });
439
+ const status = { dir, node: m.id, phase: 'starting', done: 0, total: 0, rows: 0, eta: null, startedAt: Date.now(), error: null, paused: false };
440
+ registerIndexBuild(dir, status);
441
+ m.indexBuild = status;
442
+ // PACED BY THE NODE'S OWN ANSWERS: the workers read the block files the node is also reading, so
443
+ // when its RPC slows past the monitor's own threshold (rpc.slowLatencyMs, 5 s) the next file waits
444
+ // until it recovers (2026-09-14, the first Mac install: 18 s answers and 90 s timeouts while the
445
+ // build ran flat out -- which turned out to be gettxoutsetinfo, not the build, but the pacing stays)
446
+ const pace = rpcPacer(m.rpc, { slowMs: cfg.rpc?.slowLatencyMs ?? 5000, onChange: (held, t) => {
447
+ status.paused = held;
448
+ app.log({ level: 'info', msg: held ? `address index build: paused while the node's RPC is ${t.breakerOpen ? 'refused' : t.lastError ? 'failing' : `answering in ${((t.avgLatencyMs ?? 0) / 1000).toFixed(1)} s`}` : 'address index build: resumed' });
449
+ } });
450
+ const say = (text, severity = 'info') => { m.addEvent?.({ kind: 'index', severity, tag: 'index', ts: Date.now(), text }); app.log({ level: severity === 'warn' ? 'warn' : 'info', msg: text }); };
451
+ say(`address index: building ${dir} from ${m.id}'s block files with ${workers} workers -- the Overview shows the progress`);
452
+ let phase = null, phaseAt = Date.now(), lastFlag = 0;
453
+ buildIndex({
454
+ rpc, blocksDir: path.join(m.cfg.datadir, 'blocks'), out: dir, workers, pace,
455
+ onProgress: (p) => {
456
+ if (p.phase !== phase) { phase = p.phase; phaseAt = Date.now(); }
457
+ const elapsed = (Date.now() - phaseAt) / 1000;
458
+ const rate = elapsed > 0 && p.done > 0 ? p.done / elapsed : 0;
459
+ Object.assign(status, { phase: p.phase, done: p.done, total: p.total, rows: p.rows ?? status.rows, eta: rate > 0 && p.total > p.done ? HMS((p.total - p.done) / rate) : null });
460
+ if (Date.now() - lastFlag > 5000) {
461
+ lastFlag = Date.now();
462
+ const pct = p.total ? Math.round((100 * p.done) / p.total) : 0;
463
+ m.flagQuality?.('address-index-building', `the address index is being built: ${p.phase} ${p.done.toLocaleString()} of ${p.total.toLocaleString()} (${pct}%)${p.rows ? `, ${p.rows.toLocaleString()} rows so far` : ''}${status.eta ? `, about ${status.eta} left` : ''}${status.paused ? ' -- paused while the node\'s RPC is slow' : ''}`, 'info');
464
+ }
465
+ },
466
+ }).then((manifest) => {
467
+ registerIndexBuild(dir, null);
468
+ m.indexBuild = null;
469
+ m.clearQuality?.('address-index-building');
470
+ const mins = ((Date.now() - status.startedAt) / 60000).toFixed(1);
471
+ say(`address index built: ${Number(manifest.rows ?? 0).toLocaleString()} rows to block ${Number(manifest.tip?.height ?? 0).toLocaleString()} in ${mins} min -- address pages are live`);
472
+ try { follow(dir, m); } catch (err) { say(`address index ${dir}: built, but the follower could not start: ${err.message}`, 'warn'); }
473
+ }).catch((err) => {
474
+ status.error = err.message;
475
+ registerIndexBuild(dir, null);
476
+ m.indexBuild = null;
477
+ m.flagQuality?.('address-index-build-failed', `the address index build failed: ${err.message} -- fix the cause and run node scripts/index-build.js --out ${dir}`, 'warn');
478
+ say(`address index build failed: ${err.message}`, 'warn');
479
+ });
480
+ };
481
+ for (const [dir, m] of followers) {
482
+ const finished = fs.existsSync(path.join(dir, 'manifest.json'));
483
+ if (finished) {
484
+ try { follow(dir, m); } catch (err) { app.log({ level: 'warn', msg: `address index ${dir}: ${err.message}` }); }
485
+ } else if (m.cfg?.addressIndexBuild === 'manual') {
486
+ app.log({ level: 'warn', msg: `address index ${dir}: not built, and addressIndexBuild is "manual" -- run node scripts/index-build.js --out ${dir}` });
487
+ } else if (!m.cfg?.datadir) {
488
+ app.log({ level: 'warn', msg: `address index ${dir}: not built, and node ${m.id} has no datadir to build it from` });
489
+ } else build(dir, m).catch((err) => app.log({ level: 'warn', msg: `address index ${dir}: ${err.message}` }));
490
+ }
491
+ }
492
+
493
+ installShutdown(app);
494
+ return app;
495
+ }
496
+
497
+ function wireMonitor(app, m) {
498
+ let pushTimer = null;
499
+ const schedulePush = () => {
500
+ if (pushTimer) return;
501
+ // Coalesce to one snapshot push per second per node: several tiers can
502
+ // finish inside the same tick, and the browser only ever wants the newest.
503
+ pushTimer = setTimeout(() => {
504
+ pushTimer = null;
505
+ if (!app.hub.clients.size) return;
506
+ try {
507
+ const snap = m.snapshot({});
508
+ app.stateSeq += 1;
509
+ snap.seq = app.stateSeq;
510
+ app.hub.pushSnapshot(snap, { nodeId: m.id });
511
+ } catch (err) {
512
+ app.log({ level: 'error', msg: `snapshot failed: ${err.stack ?? err.message}` });
513
+ }
514
+ }, 1000);
515
+ pushTimer.unref?.();
516
+ };
517
+ m.on('changed', schedulePush);
518
+ m.on('blocks', schedulePush);
519
+ m.on('events', (rows) => {
520
+ const list = Array.isArray(rows) ? rows : [rows];
521
+ app.eventWindow.count += list.length;
522
+ for (const r of list) {
523
+ // The raw chatter (per-tick bandwidth, heartbeat) is stored for charts but
524
+ // not pushed as a feed line, or the event list buries its own news.
525
+ if (r.kind === 'raw' && r.severity === 'info' && !/error|fail|warn|drop|refus|reject|unreachable|banned|connect/i.test(r.text ?? '')) continue;
526
+ app.hub.pushEvent(r, { nodeId: m.id });
527
+ }
528
+ schedulePush();
529
+ });
530
+ }
531
+
532
+ function makeLogger(cfg) {
533
+ const levels = { debug: 10, info: 20, warn: 30, error: 40 };
534
+ const threshold = levels[cfg.log.level] ?? 20;
535
+ const log = (entry) => {
536
+ const e = typeof entry === 'string' ? { msg: entry } : entry;
537
+ const level = e.level ?? 'info';
538
+ if ((levels[level] ?? 20) < threshold) return;
539
+ const ctx = e.node ? ` [${e.node}]` : '';
540
+ const line = `${new Date().toISOString().replace('T', ' ').slice(0, 19)} ${level.toUpperCase().padEnd(5)}${ctx} ${e.msg}`;
541
+ // Never let a credential reach stdout, where a journald capture would keep it
542
+ // forever -- including a fake-node cookie in a dev URL.
543
+ process.stdout.write(line.replace(/(:[^:@\s]{16,}@)/g, ':***@').replace(/(password|secret|cookie)["']=([^"']{6,})/gi, '$1=***') + '\n');
544
+ };
545
+ log.child = (base) => (entry) => {
546
+ const e = typeof entry === 'string' ? { msg: entry } : entry;
547
+ log({ ...base, ...e });
548
+ };
549
+ return log;
550
+ }
551
+
552
+ function installShutdown(app) {
553
+ let closing = false;
554
+ // Exposed as app.shutdown() rather than living only in the signal handler, for a
555
+ // boring reason: an HTTP-level test that boots the real app in-process had no way
556
+ // to stop it, so every test that wanted to speak HTTP had to shell out to
557
+ // scripts/smoke.sh. Teardown being unreachable is why this repo had no in-process
558
+ // integration test for sessions, headers or the login throttle.
559
+ const close = async ({ saveHistory = true } = {}) => {
560
+ if (closing) return { alreadyClosed: true };
561
+ closing = true;
562
+ for (const t of app.timers ?? []) clearInterval(t);
563
+ app.hub?.closeAll();
564
+ for (const m of app.monitors.values()) await m.stop().catch(() => {});
565
+ app.markets?.stop();
566
+ if (saveHistory) await app.history?.stop?.().catch((err) => app.log({ level: 'error', msg: `history save failed: ${err.message}` }));
567
+ await app.sessions?.save?.().catch(() => {});
568
+ await new Promise((r) => {
569
+ const open = (app.servers ?? [app.server]).filter(Boolean);
570
+ if (!open.length) return r();
571
+ let left = open.length;
572
+ for (const s of open) s.close(() => { if (--left <= 0) r(); });
573
+ });
574
+ if (app.fakeNode) await app.fakeNode.stop().catch(() => {});
575
+ return { closed: true };
576
+ };
577
+ app.shutdown = close;
578
+
579
+ const bye = async (sig) => {
580
+ if (closing) return;
581
+ app.log({ level: 'info', msg: `${sig}: shutting down` });
582
+ await close();
583
+ app.log({ level: 'info', msg: 'bye' });
584
+ // Do not hang on a socket that refused to close; history is already durable.
585
+ setTimeout(() => process.exit(0), 1500).unref();
586
+ };
587
+ process.on('SIGINT', () => bye('SIGINT'));
588
+ process.on('SIGTERM', () => bye('SIGTERM'));
589
+ process.on('unhandledRejection', (err) => app.log({ level: 'error', msg: `unhandled rejection: ${err?.stack ?? err}` }));
590
+ process.on('uncaughtException', (err) => {
591
+ app.log({ level: 'error', msg: `uncaught exception: ${err?.stack ?? err}` });
592
+ setTimeout(() => process.exit(1), 300);
593
+ });
594
+ }
595
+
596
+ export function banner(app) {
597
+ const lines = [];
598
+ const host = app.cfg.server.host;
599
+ lines.push('');
600
+ lines.push(' BlockYard is up');
601
+ lines.push(` URL ${app.scheme}://${host === '0.0.0.0' ? 'localhost' : host}:${app.cfg.server.port} (build ${app.build})`);
602
+ if (app.bootstrap) {
603
+ lines.push(` login ${app.bootstrap.username} / ${app.bootstrap.password}`);
604
+ lines.push(` ${app.bootstrap.generated ? 'generated now, shown once, stored only as a scrypt hash' : 'taken from BLOCKYARD_ADMIN_PASSWORD'}`);
605
+ } else if (app.cfg.auth.enabled) {
606
+ lines.push(' login your usual account');
607
+ } else {
608
+ // Same content as the boot warning, in the banner: the first thing on screen
609
+ // after `npm start` should be the sentence about who can read the node.
610
+ lines.push(' login DISABLED — open to anyone who can reach the addresses above (role: viewer, read-only)');
611
+ lines.push(' user admin, the audit trail and node writes stay closed; BLOCKYARD_AUTH=1 turns accounts on');
612
+ }
613
+ lines.push(` nodes ${[...app.monitors.values()].map((m) => `${m.id} -> ${m.rpc.url}`).join(', ')}`);
614
+ if (app.cfg.server.allowCidrs.length) lines.push(` CIDRs ${app.cfg.server.allowCidrs.join(', ')}`);
615
+ lines.push(` actions ${app.cfg.actions.enabled ? `enabled: ${app.cfg.actions.allow.join(', ') || '(none listed)'}` : 'disabled (read-only)'}`);
616
+ lines.push('');
617
+ return lines.join('\n');
618
+ }
619
+
620
+ // run-as-main, portably: on Windows a file URL's path is "/C:/x" and realpath gives "C:\\x", so the
621
+ // two are compared as paths, never as strings (2026-09-14, the first Windows CI run)
622
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]);
623
+ if (isMain) {
624
+ const app = await boot();
625
+ process.stdout.write(banner(app) + '\n');
626
+ // The banner is also the only place the bootstrap password ever appears.
627
+ if (app.bootstrap) await app.audit({ type: 'bootstrap-admin', generated: app.bootstrap.generated, ip: 'local' });
628
+ }