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