blockyard 0.0.9 → 0.1.1

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 (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
package/server/config.js CHANGED
@@ -27,9 +27,13 @@ Defaults are Bitcoin Core's own, so \`npm start\` works against a stock local no
27
27
 
28
28
  const DEFAULTS = {
29
29
  server: {
30
- // Multi-user means the LAN has to reach it, so it binds broadly by default.
31
- // Set BLOCKYARD_BIND=127.0.0.1 to keep it on this machine only.
32
- host: '0.0.0.0',
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',
33
37
  // 21000 (operator, 2026-09-13: "make default web port 21000 for access"). It was 8088, which
34
38
  // sits in the range every other monitor on a box reaches for; this one is ours.
35
39
  port: 21000,
@@ -37,15 +41,19 @@ const DEFAULTS = {
37
41
  allowCidrs: [],
38
42
  trustProxy: false,
39
43
  // TLS is off unless both files are named, and then it is on for every listener.
40
- // It stays opt-in because this box is a LAN monitor whose certificate has no
41
- // issuer: a self-signed cert produces a browser warning on every address change,
42
- // and the alternative already documented (an SSH tunnel to 127.0.0.1, or a
43
- // reverse proxy that owns the cert) is better on a machine you control.
44
- // What was missing until 2026-09-09 was the option at all -- serving a session
45
- // cookie and every RPC reply over plain HTTP on a LAN is not a gap you get to
46
- // call "documented, therefore fine".
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.
47
54
  tls: {
48
- cert: null, // PEM; BLOCKYARD_TLS_CERT
55
+ enabled: true,
56
+ cert: null, // PEM; BLOCKYARD_TLS_CERT -- your own certificate, instead of the made one
49
57
  key: null, // PEM; BLOCKYARD_TLS_KEY
50
58
  // Sent over TLS responses only. Two days, not the usual year: a LAN address
51
59
  // can be reissued to something else, and HSTS is the header that cannot be
@@ -193,10 +201,11 @@ const DEFAULTS = {
193
201
  auditKeep: 5,
194
202
  },
195
203
  auth: {
196
- // OPEN BY DEFAULT, like a block explorer: anyone who can reach the listen
197
- // addresses reads the dashboard with no account. This is a posture decision, not
198
- // a convenience -- see README "Open by default" and the boot warning, which names
199
- // the addresses this leaves readable.
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.
200
209
  //
201
210
  // What "open" is bounded by, in server/http/server.js:
202
211
  // * the anonymous role is `viewer` and the ceiling is not configurable; user
@@ -207,9 +216,9 @@ const DEFAULTS = {
207
216
  // should discover by accident);
208
217
  // * rate limits key on the IP, so one noisy tab cannot spend everyone's bucket.
209
218
  //
210
- // Set BLOCKYARD_AUTH=1 (or auth.enabled in config/local.json) for accounts, roles,
211
- // sessions, CSRF and the audit trail-by-user.
212
- enabled: false,
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,
213
222
  dataDir: null,
214
223
  // THE LONG ONE IS THE ABSOLUTE LIFETIME, the short one the idle ceiling -- which is the way
215
224
  // round the names read, and the opposite of what shipped until 2026-09-13. With an 8 h
@@ -225,6 +234,12 @@ const DEFAULTS = {
225
234
  lockoutMs: 600000,
226
235
  cookieName: 'blockyard_sid',
227
236
  secureCookie: false, // forced true at boot when TLS is on
237
+ // OPEN MODE'S NODE CONNECTION IS SET FROM THIS MACHINE ONLY (audit 2026-09-16, M1/M2). With
238
+ // accounts off, the node-connection save and test are answered only for a loopback caller: the
239
+ // open-mode cross-site check stops a browser, never a script, and a script on the LAN could point
240
+ // the saved connection -- and with it the datadir cookie, after the next restart -- at itself, or
241
+ // make this server fetch its internal URLs. `true` restores the old reach, knowingly.
242
+ openNodeConfigFromNetwork: false,
228
243
  },
229
244
  actions: {
230
245
  // Anything that can change node or machine state is off unless explicitly
@@ -273,7 +288,11 @@ const DEFAULTS = {
273
288
  },
274
289
  // The Markets tab (server/collect/markets.js): public exchange APIs over HTTPS -- the one
275
290
  // outbound connection that is not the node. Polled only while someone has the tab open, and
276
- // parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0 turns it off.
291
+ // parked idleAfterMs after the last request. BLOCKYARD_MARKETS=0 removes it altogether.
292
+ // POLLING IS OFF OUT OF THE BOX regardless (operator, 2026-09-15: "disable markets by default so
293
+ // we can claim true zero telemetry out of the box" ... "an app wide 'Enable Market Polling'
294
+ // checkbox"): the feed is built here but asks nobody anything until the switch in Display
295
+ // settings -> Markets & Price -> Enable market polling is on (http/api.js marketsPollingOn).
277
296
  markets: {
278
297
  enabled: true,
279
298
  tickerMs: 15000,
@@ -389,12 +408,14 @@ export function loadConfig({ configFile = defaultConfigFile(), ifaces = null, no
389
408
  'BLOCKYARD_DATA': ['store.dir', String],
390
409
  'BLOCKYARD_AUTH': ['auth.enabled', Boolean],
391
410
  'BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH': ['actions.allowWritesWithoutAuth', Boolean],
411
+ 'BLOCKYARD_OPEN_NODE_CONFIG_FROM_NETWORK': ['auth.openNodeConfigFromNetwork', Boolean],
392
412
  // Run on RPC alone: 0 turns the log tail off for every node. Measured why is
393
413
  // in server/collect/monitor.js and MEASUREMENTS 3/4 -- bandwidth and per-peer
394
414
  // bytes work on builds that publish them and do not on the deployed one.
395
415
  'BLOCKYARD_LOG_SOURCE': ['log.enabled', Boolean],
396
416
  'BLOCKYARD_MARKETS': ['markets.enabled', Boolean],
397
417
  'BLOCKYARD_SECURE_COOKIE': ['auth.secureCookie', Boolean],
418
+ 'BLOCKYARD_TLS': ['server.tls.enabled', Boolean],
398
419
  'BLOCKYARD_TLS_CERT': ['server.tls.cert', String],
399
420
  'BLOCKYARD_TLS_KEY': ['server.tls.key', String],
400
421
  'BLOCKYARD_ACTIONS': ['actions.allow', (v) => v.split(',').map((s) => s.trim()).filter(Boolean)],
@@ -493,8 +514,12 @@ export function configProblems() { return problems; }
493
514
  function validateTls(cfg, now = Date.now()) {
494
515
  const tls = cfg.server.tls ?? {};
495
516
  cfg.server.tls = tls;
496
- cfg.tls = Boolean(tls.cert || tls.key);
497
- if (!cfg.tls) return;
517
+ if (tls.enabled === false) { cfg.tls = false; cfg.__tlsAuto = false; return; } // plain HTTP, chosen
518
+ cfg.tls = true;
519
+ // no certificate named: the server makes its own at boot (main.js, ensureSelfSigned) and
520
+ // inspects it then -- so nothing below applies yet
521
+ if (!tls.cert && !tls.key) { cfg.__tlsAuto = true; return; }
522
+ cfg.__tlsAuto = false;
498
523
  if (!tls.cert || !tls.key) {
499
524
  problems.push(`server.tls needs BOTH cert and key (got ${tls.cert ? 'cert only' : 'key only'}); a half-configured TLS would fall back to plaintext on a port you believe is HTTPS`);
500
525
  return;
@@ -507,6 +532,12 @@ function validateTls(cfg, now = Date.now()) {
507
532
  }
508
533
  }
509
534
  if (problems.length) return;
535
+ inspectTls(cfg, tls, now);
536
+ }
537
+
538
+ // read the certificate: fingerprint, expiry, whether it is self-signed; problems for an
539
+ // unparseable or expired one, a note for one about to expire
540
+ export function inspectTls(cfg, tls, now = Date.now()) {
510
541
  try {
511
542
  const x = new crypto.X509Certificate(fs.readFileSync(tls.cert, 'utf8'));
512
543
  tls.fingerprint = x.fingerprint256;
@@ -530,7 +561,7 @@ function validate(cfg, ifaces = null, now = Date.now()) {
530
561
  if (!Number.isInteger(cfg.server.port) || cfg.server.port < 1 || cfg.server.port > 65535) problems.push('server.port invalid');
531
562
  // `hosts` is the truth: one address, a comma list, or an array all normalise here.
532
563
  // `host` stays populated with the first entry for anything that still reads it.
533
- cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '0.0.0.0');
564
+ cfg.server.hosts = hostList(cfg.server.hosts ?? cfg.server.host ?? '127.0.0.1');
534
565
  cfg.server.host = cfg.server.hosts[0];
535
566
  if (!cfg.server.hosts.length) problems.push('server.hosts is empty; nothing would be served');
536
567
  // A hostname here binds whatever DNS says at boot, and fails at listen() with a
@@ -4,7 +4,7 @@
4
4
  import fsp from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { classifyMethod, allowlistSummary, ACTIONS, actionAllowed, NODE_REFUSES } from '../rpc/allowlist.js';
7
- import { RpcClient } from '../rpc/client.js';
7
+ import { RpcClient, displayUrl, shortPath } from '../rpc/client.js';
8
8
  import { SERIES } from '../store/history.js';
9
9
  import { randomPassword } from '../auth/users.js';
10
10
  import { formatEta, formatBytes } from '../util/fmt.js';
@@ -12,8 +12,36 @@ import { xSearch, xTx, xBlock, xAddress } from './explorer.js';
12
12
 
13
13
  // Dollar figures for the explorer: a spot price if one is at hand within 1.5 s -- never a slower
14
14
  // page for want of one (server/collect/markets.js spot()).
15
+ // THE MARKET SWITCH (operator, 2026-09-15: "disable markets by default so we can claim true zero
16
+ // telemetry out of the box" ... "an app wide 'Enable Market Polling' checkbox"). Two layers:
17
+ // - BLOCKYARD_MARKETS=0 (markets.enabled=false) removes the feed from the server altogether;
18
+ // nothing in the browser can turn it on. For machines that must never reach out.
19
+ // - otherwise the feed exists but polls only while the Display setting
20
+ // markets.polling is on -- and that ships OFF, so a fresh install makes no outbound
21
+ // connection but to the node until someone ticks the box.
22
+ // The setting lives in the server's settings file (config/blockyard.json, the same one every
23
+ // screen shares); it is read here per request, cached on the file's mtime and size, so a tick in
24
+ // the panel takes effect on the next call without a restart -- and the next call also PARKS the
25
+ // feed, so unticking stops the exchange traffic at once rather than ten minutes later.
26
+ const MARKETS_OFF = 'market data is off on this server (BLOCKYARD_MARKETS=0 or markets.enabled=false); the switch in Display settings cannot turn it on';
27
+ const POLLING_OFF = 'market polling is off -- the default, so that out of the box this monitor makes no outbound connection but to your node. Turn it on under Display settings → Markets & Price → Enable market polling';
28
+ const pollingCache = new WeakMap();
29
+ export async function marketsPollingOn(app) {
30
+ const file = app.settingsFile;
31
+ if (!file) return false;
32
+ let st;
33
+ try { st = await fsp.stat(file); } catch { return false; }
34
+ const hit = pollingCache.get(app);
35
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value;
36
+ let value = false;
37
+ try { value = JSON.parse(await fsp.readFile(file, 'utf8'))?.markets?.polling === true; } catch { value = false; }
38
+ pollingCache.set(app, { mtimeMs: st.mtimeMs, size: st.size, value });
39
+ return value;
40
+ }
41
+ const pollingOff = (app) => { app.markets?.stop?.(); return { ok: true, enabled: false, polling: false, note: POLLING_OFF }; };
42
+
15
43
  async function withUsd(app, r) {
16
- if (!r?.ok || !app.markets) return r;
44
+ if (!r?.ok || !app.markets || !(await marketsPollingOn(app))) return r;
17
45
  const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
18
46
  return { ...r, usd: p?.usd ?? null };
19
47
  }
@@ -70,6 +98,33 @@ function configWriteAllowed(app, ctx) {
70
98
  if (app.cfg.auth.enabled) needRole(ctx, 'admin');
71
99
  }
72
100
 
101
+ // THE NODE CONNECTION IN OPEN MODE: FROM THIS MACHINE ONLY (audit 2026-09-16, M1 and M2).
102
+ //
103
+ // The open-mode cross-site check refuses a browser on another origin, because a browser cannot
104
+ // suppress Origin or forge Sec-Fetch-Site. A script sends neither header, and can forge both. So with
105
+ // accounts off, a script anywhere on the LAN could (1) save an rpcUrl of its choosing, after which the
106
+ // next restart sends the datadir's cookie to it -- reproduced end to end -- and (2) use the probe to
107
+ // make this server POST to internal URLs and read back what they answered. There is no identity to
108
+ // check in open mode, so the check is WHERE the caller is: the socket's own peer address must be
109
+ // loopback. X-Forwarded-For is never consulted, and behind a trusted proxy every request would look
110
+ // local, so the form is refused there. `auth.openNodeConfigFromNetwork` restores the old reach.
111
+ // Full server paths go to an admin; everyone else, which in open mode is anyone who can reach the
112
+ // port, gets the last two parts (audit 2026-09-16, L11).
113
+ const pathFor = (ctx, p) => (ctx.user?.role === 'admin' ? p : shortPath(p));
114
+
115
+ export function isLoopbackAddress(addr) {
116
+ const a = String(addr ?? '').replace(/^::ffff:/i, '');
117
+ return a === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
118
+ }
119
+ function nodeConfigAllowed(app, ctx) {
120
+ if (app.cfg.auth.enabled) return needRole(ctx, 'admin');
121
+ if (app.cfg.auth.openNodeConfigFromNetwork === true) return undefined;
122
+ if (!app.cfg.server?.trustProxy && isLoopbackAddress(ctx.req.socket?.remoteAddress)) return undefined;
123
+ throw new HttpError(403, app.cfg.server?.trustProxy
124
+ ? 'with accounts off and server.trustProxy on, the node connection cannot be changed over HTTP: every proxied request looks local. Edit config/local.json, or turn accounts on'
125
+ : 'with accounts off, the node connection can only be changed from this machine (open the monitor at 127.0.0.1), or turn accounts on, or set auth.openNodeConfigFromNetwork', { code: 'local_only' });
126
+ }
127
+
73
128
  // What a form may set, and nothing else. Credentials come from the datadir's .cookie
74
129
  // (config.js resolveCookie), so rpcUser / rpcPassword / cookieFile are NOT accepted here: taking a
75
130
  // password over an endpoint that is open by default is not a thing to add quietly.
@@ -425,17 +480,33 @@ export const routes = [
425
480
  // Exchange prices (server/collect/markets.js). Asking is what keeps the feed polling.
426
481
  {
427
482
  method: 'GET', path: '/api/markets', auth: 'any',
428
- handler: (ctx, app) => {
429
- if (!app.markets) return { ok: true, enabled: false, note: 'market data is off on this monitor (BLOCKYARD_MARKETS=0 or markets.enabled=false)' };
483
+ handler: async (ctx, app) => {
484
+ if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
485
+ if (!(await marketsPollingOn(app))) return pollingOff(app);
430
486
  app.markets.touch();
431
487
  return app.markets.view();
432
488
  },
433
489
  },
490
+ // THE SPOT PRICE, for dollar figures on pages that are not Markets (the Mining tab's reward
491
+ // stats, 2026-09-15: "Add dollar figures"): the feed's median while it is polling, else the
492
+ // explorer's cached spot read (two exchanges, at most once a minute) -- and null, saying why,
493
+ // while market polling is off. Does NOT touch the feed: asking the price here never starts
494
+ // the week-long polling that Markets does.
495
+ {
496
+ method: 'GET', path: '/api/price', auth: 'any',
497
+ handler: async (ctx, app) => {
498
+ if (!app.markets) return { ok: true, usd: null, enabled: false, note: MARKETS_OFF };
499
+ if (!(await marketsPollingOn(app))) return { ok: true, usd: null, polling: false, note: POLLING_OFF };
500
+ const p = await Promise.race([app.markets.spot().catch(() => null), new Promise((res) => { setTimeout(res, 1500, null).unref?.(); })]);
501
+ return { ok: true, usd: p?.usd ?? null, at: p?.at ?? null, source: p?.source ?? null };
502
+ },
503
+ },
434
504
  // The depth chart: the books as cumulative depth, and the snapshot `ago` seconds earlier.
435
505
  {
436
506
  method: 'GET', path: '/api/markets/depth', auth: 'any',
437
- handler: (ctx, app) => {
438
- if (!app.markets) return { ok: true, enabled: false, note: 'market data is off on this monitor (BLOCKYARD_MARKETS=0 or markets.enabled=false)' };
507
+ handler: async (ctx, app) => {
508
+ if (!app.markets) return { ok: true, enabled: false, note: MARKETS_OFF };
509
+ if (!(await marketsPollingOn(app))) return pollingOff(app);
439
510
  app.markets.touch();
440
511
  return app.markets.depthView(Number(ctx.query.ago) || 600);
441
512
  },
@@ -494,7 +565,7 @@ export const routes = [
494
565
  let sync = null;
495
566
  try { sync = m.snapshot({ seriesRanges: {} }).sync; } catch { /* not yet populated */ }
496
567
  return {
497
- id: m.id, label: m.label, color: m.color, rpcUrl: m.node?.rpcUrl ?? m.rpc.url,
568
+ id: m.id, label: m.label, color: m.color, rpcUrl: displayUrl(m.node?.rpcUrl ?? m.rpc.url),
498
569
  chain: m.state.chain, online: m.rpc.telemetry().online,
499
570
  optional: !!m.cfg.optional,
500
571
  syncState: sync?.state ?? null, pct: sync?.pct ?? null,
@@ -562,8 +633,11 @@ export const routes = [
562
633
  const params = Array.isArray(ctx.body?.params) ? ctx.body.params : [];
563
634
  const cls = classifyMethod(method);
564
635
  if (!cls.allowed) {
565
- await app.audit({ type: 'rpc-denied', username: ctx.user.username, node: m.id, method, reason: cls.reason, ip: ctx.ip });
566
- throw new HttpError(403, `${method || '(empty)'} is not callable from the web UI: ${cls.reason}`, { code: 'rpc_denied' });
636
+ // No RPC name is longer than a few dozen characters; the caller's string is clamped before
637
+ // it is written or echoed (audit 2026-09-16, M6).
638
+ const shown = method.length > 64 ? `${method.slice(0, 64)}… (${method.length} chars)` : method;
639
+ await app.audit({ type: 'rpc-denied', username: ctx.user.username, node: m.id, method: shown, reason: cls.reason, ip: ctx.ip });
640
+ throw new HttpError(403, `${shown || '(empty)'} is not callable from the web UI: ${cls.reason}`, { code: 'rpc_denied' });
567
641
  }
568
642
  const t0 = Date.now();
569
643
  try {
@@ -728,7 +802,7 @@ export const routes = [
728
802
  {
729
803
  method: 'POST', path: '/api/config/node/test', auth: 'any', csrf: true, body: true,
730
804
  handler: async (ctx, app) => {
731
- configWriteAllowed(app, ctx);
805
+ nodeConfigAllowed(app, ctx);
732
806
  const node = candidateNode(app, ctx.body);
733
807
  const started = Date.now();
734
808
  // A THROWAWAY CLIENT WITH ITS OWN LANE. The live node's client holds a serialized queue
@@ -781,9 +855,22 @@ export const routes = [
781
855
  // `authenticated` is reported on EVERY path, success or failure: a caller cannot otherwise
782
856
  // tell "it refused us" from "we deliberately sent no credential", and those mean different
783
857
  // things to someone deciding whether the connection they typed is right.
858
+ //
859
+ // NOT WHAT A FOREIGN ENDPOINT SAID (audit 2026-09-16, M2). RpcClient's message carries up to
860
+ // 200 characters of the reply body, so echoing it made this route a way to read internal URLs
861
+ // through the server. For any endpoint but the configured one, the answer is the class of
862
+ // failure only.
863
+ const GENERIC = {
864
+ timeout: 'the endpoint did not answer in time',
865
+ transport: 'the endpoint could not be reached, or answered with an HTTP error',
866
+ parse: 'the endpoint answered, but not with JSON-RPC: this is not a Bitcoin Core RPC port',
867
+ rpc: 'the endpoint answered with an RPC error',
868
+ breaker: 'the endpoint could not be reached',
869
+ };
870
+ const message = sameEndpoint ? err.message : (GENERIC[err.kind] ?? 'the connection failed');
784
871
  return {
785
872
  ok: false, ms: Date.now() - started, authenticated: sameEndpoint,
786
- error: { message: err.message, kind: err.kind ?? null, code: err.code ?? null },
873
+ error: { message, kind: err.kind ?? null, code: sameEndpoint ? (err.code ?? null) : null },
787
874
  };
788
875
  }
789
876
  },
@@ -791,7 +878,7 @@ export const routes = [
791
878
  {
792
879
  method: 'POST', path: '/api/config/node', auth: 'any', csrf: true, body: true,
793
880
  handler: async (ctx, app) => {
794
- configWriteAllowed(app, ctx);
881
+ nodeConfigAllowed(app, ctx);
795
882
  if (!app.configFile) {
796
883
  throw new HttpError(409, 'this process was started without a config file (BLOCKYARD_CONFIG=none), so there is nowhere to save to', { code: 'no_config_file' });
797
884
  }
@@ -806,7 +893,21 @@ export const routes = [
806
893
  // JSON.stringify then omits it -- so carrying `{...cur}` across could DELETE a field from the
807
894
  // file rather than preserve it. Only real values take part in the merge.
808
895
  const changes = Object.fromEntries(Object.entries(node).filter(([, v]) => v !== undefined));
809
- nodes[0] = { ...(nodes[0] ?? {}), ...changes };
896
+ // CREDENTIALS BELONG TO AN ENDPOINT (audit 2026-09-16, M1). The merge kept rpcUser, rpcPassword
897
+ // and cookieFile while the URL changed, so a saved address inherited another server's secret.
898
+ // When the host or port changes they are dropped; the cookie is then read from the datadir the
899
+ // form names, which is the credential this form was always meant to use.
900
+ const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
901
+ const prior = nodes[0] ?? {};
902
+ const droppedCredentials = [];
903
+ if (prior.rpcUrl && hostOf(prior.rpcUrl) !== hostOf(changes.rpcUrl)) {
904
+ for (const k of ['rpcUser', 'rpcPassword', 'cookieFile']) if (k in prior) droppedCredentials.push(k);
905
+ }
906
+ nodes[0] = { ...prior, ...changes };
907
+ for (const k of droppedCredentials) delete nodes[0][k];
908
+ if (droppedCredentials.length && !nodes[0].datadir) {
909
+ throw new HttpError(400, 'a new endpoint needs a datadir, so its own .cookie can be read', { code: 'need_datadir' });
910
+ }
810
911
  delete nodes[0].__urlOverridden;
811
912
  const next = { ...fileCfg, nodes };
812
913
 
@@ -826,7 +927,7 @@ export const routes = [
826
927
  .filter((k) => process.env[k] !== undefined && process.env[k] !== '');
827
928
  await app.audit({ type: 'config-node', username: ctx.user.username, ip: ctx.ip, rpcUrl: node.rpcUrl, file: app.configFile });
828
929
  return {
829
- ok: true, file: app.configFile, restartRequired: true, envOverrides,
930
+ ok: true, file: app.configFile, restartRequired: true, envOverrides, droppedCredentials,
830
931
  note: envOverrides.length
831
932
  ? `saved, but this process takes its node from ${envOverrides.join(', ')}, which the environment sets and which beats the file — change the unit or drop-in, or the restart will keep the old endpoint`
832
933
  : 'saved; restart the monitor for it to take effect',
@@ -852,12 +953,12 @@ export const routes = [
852
953
  handler: async (ctx, app) => {
853
954
  try {
854
955
  const raw = await fsp.readFile(app.settingsFile, 'utf8');
855
- return { settings: JSON.parse(raw), file: app.settingsFile, stored: true };
956
+ return { settings: JSON.parse(raw), file: pathFor(ctx, app.settingsFile), stored: true };
856
957
  } catch (err) {
857
958
  // Nothing saved yet is the normal first-run answer, not a fault: the client then keeps its
858
959
  // own defaults and offers to push them up. A CORRUPT file is different and says so.
859
- if (err.code === 'ENOENT') return { settings: null, file: app.settingsFile, stored: false };
860
- return { settings: null, file: app.settingsFile, stored: false, error: `unreadable: ${err.message}` };
960
+ if (err.code === 'ENOENT') return { settings: null, file: pathFor(ctx, app.settingsFile), stored: false };
961
+ return { settings: null, file: pathFor(ctx, app.settingsFile), stored: false, error: `unreadable: ${err.message.replaceAll(app.settingsFile, pathFor(ctx, app.settingsFile))}` };
861
962
  }
862
963
  },
863
964
  },
@@ -883,7 +984,7 @@ export const routes = [
883
984
  await fh.close();
884
985
  await fsp.rename(tmp, app.settingsFile);
885
986
 
886
- return { ok: true, file: app.settingsFile, bytes: text.length };
987
+ return { ok: true, file: pathFor(ctx, app.settingsFile), bytes: text.length };
887
988
  },
888
989
  },
889
990
  ];
@@ -0,0 +1,77 @@
1
+ // THE GAME FILES (operator, 2026-09-15: "I've added doom_dos to the project directory. Get DOOM
2
+ // working as a diversion inside blockyard with zero dependancies"; later "move doom_dos out of the
3
+ // root and move into games", and "get Quake working as a diversion").
4
+ //
5
+ // The DOS Diversions run shareware DOOM and Quake in a PC emulated in the browser (public/js/x86.js,
6
+ // dospc.js, soundcard.js), and the emulator needs the games' own files. They live in games/, one
7
+ // directory a game, where the operator put them -- not in public/, which is the app and is stamped
8
+ // with a build id computed over every file in it (18 MB of PAK in that digest, re-hashed every two
9
+ // seconds of page loads, would be a cost paid by every page for two diversions).
10
+ //
11
+ // Served under /games/<game>/<path>, and only that: a game this file names, and a DOS path of at
12
+ // most one directory and an 8.3 name of a kind the games read (an executable, a WAD, a PAK, a
13
+ // config, Wolfenstein 3D's .WL1 data). No dots but the one in each name, so there is no path to
14
+ // traverse; the lookup is
15
+ // case-insensitive because DOS names are, and the files on disk are upper-case while a browser asks
16
+ // for whatever it was told.
17
+ import fsp from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { securityHeaders } from './static.js';
20
+
21
+ /** Each game's directory under the games root. */
22
+ export const GAME_DIRS = Object.freeze({ wolf3d: 'wolf3d_dos', doom: 'doom_dos', quake: 'quake_dos' });
23
+
24
+ export const GAME_PATH = /^\/games\/([a-z0-9]+)\/((?:[A-Za-z0-9_-]{1,8}\/)?[A-Za-z0-9_-]{1,8}\.(?:wad|exe|cfg|pak|wl1))$/i;
25
+
26
+ /** The file under `dir` at the DOS path `rel` ("ID1/PAK0.PAK"), matching each part ignoring case, or null. */
27
+ export async function findGameFile(dir, rel) {
28
+ let at = dir;
29
+ const parts = rel.split('/');
30
+ for (let i = 0; i < parts.length; i++) {
31
+ let names;
32
+ try { names = await fsp.readdir(at, { withFileTypes: true }); } catch { return null; }
33
+ const want = parts[i].toUpperCase(), last = i === parts.length - 1;
34
+ const hit = names.find((e) => (last ? e.isFile() : e.isDirectory()) && e.name.toUpperCase() === want);
35
+ if (!hit) return null;
36
+ at = path.join(at, hit.name);
37
+ }
38
+ return at;
39
+ }
40
+
41
+ /**
42
+ * Answer a /games/ request. Returns { status } when it answered, or null when the path is not one
43
+ * of ours (the caller's 404 applies).
44
+ */
45
+ export async function serveGame(req, res, urlPath, root, { tls = false, hstsMs = 0 } = {}) {
46
+ const m = GAME_PATH.exec(urlPath);
47
+ if (!m || !Object.hasOwn(GAME_DIRS, m[1])) return null;
48
+ const dirName = GAME_DIRS[m[1]];
49
+ const file = await findGameFile(path.join(root, dirName), m[2]);
50
+ const headers = securityHeaders({ tls, hstsMs });
51
+ if (!file) {
52
+ res.writeHead(404, { ...headers, 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
53
+ res.end(req.method === 'HEAD' ? undefined : `${m[2].toUpperCase()} is not in games/${dirName}/`);
54
+ return { status: 404 };
55
+ }
56
+ const st = await fsp.stat(file);
57
+ const etag = `W/"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`;
58
+ if (req.headers['if-none-match'] === etag) {
59
+ res.writeHead(304, { ...headers, ETag: etag, 'Cache-Control': 'no-cache' });
60
+ res.end();
61
+ return { status: 304 };
62
+ }
63
+ res.writeHead(200, {
64
+ ...headers,
65
+ 'Content-Type': 'application/octet-stream',
66
+ 'Content-Length': st.size,
67
+ 'Cache-Control': 'no-cache',
68
+ ETag: etag,
69
+ 'Last-Modified': new Date(st.mtimeMs).toUTCString(),
70
+ });
71
+ if (req.method === 'HEAD') { res.end(); return { status: 200 }; }
72
+ const { createReadStream } = await import('node:fs');
73
+ const stream = createReadStream(file);
74
+ stream.on('error', () => res.destroy());
75
+ stream.pipe(res);
76
+ return { status: 200 };
77
+ }
@@ -7,6 +7,7 @@ import http from 'node:http';
7
7
  import https from 'node:https';
8
8
  import { URL } from 'node:url';
9
9
  import { StaticFiles, SECURITY_HEADERS, securityHeaders } from './static.js';
10
+ import { serveGame } from './games.js';
10
11
  import { routes, HttpError } from './api.js';
11
12
  import { parseCookies, serializeCookie, csrfOk } from '../auth/sessions.js';
12
13
  import { ipDecision } from '../netinfo.js';
@@ -16,6 +17,7 @@ import { ipDecision } from '../netinfo.js';
16
17
  export { ipAllowed, parseIp, parseCidr } from '../netinfo.js';
17
18
 
18
19
  const MAX_BODY = 1024 * 1024; // 1 MB: a raw transaction is nowhere near this
20
+ export const REQUEST_TIMEOUT_MS = 30_000; // the whole request, body included, must arrive within this
19
21
 
20
22
  function compile(routesTable) {
21
23
  return routesTable.map((r) => {
@@ -56,10 +58,13 @@ export function createAppServer(app) {
56
58
  ? https.createServer({ ...app.tlsOptions }, listener)
57
59
  : http.createServer(listener);
58
60
  server.keepAliveTimeout = 5000;
59
- server.headersTimeout = 10000;
60
- // SSE connections are long-lived by design; do not let the defaults reap them.
61
- server.requestTimeout = 0;
61
+ // A DEADLINE FOR RECEIVING THE WHOLE REQUEST, body included (audit 2026-09-16, M5). This was 0,
62
+ // "so SSE connections are not reaped" -- but requestTimeout bounds only how long the REQUEST takes
63
+ // to arrive, and a stream's GET has arrived the moment its headers do; the response may then run
64
+ // for days. Measured on Node 22: with a 1.5 s deadline a stream kept delivering for 5 s, while a
65
+ // POST trickling one body byte every 500 ms got 408 at 2 s. At 0, that POST was held forever.
62
66
  server.headersTimeout = 15000;
67
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
63
68
 
64
69
  async function handle(req, res) {
65
70
  const ip = clientIp(req, app.cfg);
@@ -115,13 +120,20 @@ export function createAppServer(app) {
115
120
  },
116
121
  });
117
122
  }
118
- const client = app.hub.add(req, res, { user: user.user, nodeId: wantNode });
123
+ // A BOUNDED NUMBER OF STREAMS PER ADDRESS OR ACCOUNT (audit 2026-09-16, H1). The rate limiter
124
+ // above only paces how fast streams open; it never capped how many stay open.
125
+ const streamKey = openAccess ? `ip:${ip}` : `user:${user.user.id}`;
126
+ if (app.hub.countFor(streamKey) >= app.hub.limits.maxPerKey) {
127
+ return sendJson(req, res, 429, { error: { message: `too many open streams (${app.hub.limits.maxPerKey}) for this ${openAccess ? 'address' : 'account'}`, kind: 'ratelimited' } });
128
+ }
129
+ const client = app.hub.add(req, res, { user: user.user, nodeId: wantNode, key: streamKey });
119
130
  app.log({ level: 'info', msg: `sse #${client.id} opened by ${user.user.username} (${ip})` });
120
131
  req.on('close', () => app.log({ level: 'info', msg: `sse #${client.id} closed (${Math.round((Date.now() - started) / 1000)}s)` }));
121
132
  return undefined;
122
133
  }
123
134
 
124
135
  const match = matchRoute(compiled, req.method, path);
136
+ if (match?.badPath) return sendJson(req, res, 400, { error: { message: 'malformed percent-encoding in the path', kind: 'bad_request' } });
125
137
  if (match?.methodMismatch) {
126
138
  res.setHeader('Allow', [...new Set(compiled.filter((r) => r.re.test(path)).map((r) => r.method))].join(', '));
127
139
  return sendJson(req, res, 405, { error: { message: `${req.method} is not allowed on ${path}`, kind: 'method' } });
@@ -136,6 +148,13 @@ export function createAppServer(app) {
136
148
  return res.end();
137
149
  }
138
150
  if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(req, res, 405, { error: { message: 'method not allowed', kind: 'method' } });
151
+ // The DOS Diversions' game files (http/games.js). Behind the session when accounts are on:
152
+ // twenty megabytes of somebody else's games are not a public asset of this monitor.
153
+ if (path.startsWith('/games/')) {
154
+ if (!openAccess && !resolveSession(req, app)) return sendJson(req, res, 401, { error: { message: 'authentication required', kind: 'auth' }, login: '/login' });
155
+ const done = await serveGame(req, res, path, app.gamesDir, { tls: app.tls, hstsMs });
156
+ if (done) { app.access({ req, res, path, status: done.status, ms: Date.now() - started, ip, user: null }); return undefined; }
157
+ }
139
158
  const out = await statics.serve(req, res, path);
140
159
  if (out?.error) return serveStaticError(req, res, statics, path, out.error, H());
141
160
  app.access({ req, res, path, status: out?.status ?? 200, ms: Date.now() - started, ip, user: null });
@@ -301,7 +320,13 @@ function matchRoute(compiled, method, path) {
301
320
  if (!m) continue;
302
321
  if (r.method !== method) { methodMismatch = true; continue; }
303
322
  const params = {};
304
- r.names.forEach((n, i) => { params[n] = decodeURIComponent(m[i + 1]); });
323
+ // A malformed escape in a path parameter is the client's mistake, and answered as one: it used to
324
+ // throw here and come back as a 500 "internal error" (audit 2026-09-16, L13).
325
+ try {
326
+ r.names.forEach((n, i) => { params[n] = decodeURIComponent(m[i + 1]); });
327
+ } catch {
328
+ return { badPath: true };
329
+ }
305
330
  return { route: r, params };
306
331
  }
307
332
  return methodMismatch ? { methodMismatch: true } : null;