blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,414 @@
1
+ // Bitcoin Core JSON-RPC client with deliberate etiquette.
2
+ //
3
+ // Why this is more than a fetch wrapper: a dashboard with N tabs each polling M methods is an
4
+ // accidental denial-of-service against the very node we exist to monitor. Whatever the node's
5
+ // threading, the monitor must be a good guest.
6
+ //
7
+ // HOW MANY CONNECTIONS THE NODE CAN ACTUALLY USE DEPENDS ON THE NODE, and the default here was
8
+ // written for one that services ONE connection at a time on a single thread
9
+ // (docs/RPC_LIVE_NODE.md slice 11 -- also why its `waitforblock` refuses to wait indefinitely,
10
+ // and why `rescanblockchain` blocks every other RPC). That is NOT true of Bitcoin Core, which
11
+ // defaults to four RPC threads: measured 2026-09-13 against an Umbrel running Core 31.1.0, four
12
+ // concurrent getblockchaininfo calls finished in 158 ms wall against 157 ms each. The line that
13
+ // used to sit below -- "maxInFlight = 1 (the server cannot use more anyway)" -- was false there,
14
+ // and the cost was real: a 3.9 s getblocktemplate held the only slot while every other tier
15
+ // queued, giving avgLatency 1715 ms and repeated 90 s timeouts on a node answering single calls
16
+ // in ~106 ms. A node entry can now carry its own `rpc` block (see monitor.js), so this is a
17
+ // default, not an assumption.
18
+ //
19
+ // So every request from every user and every poll tier goes through ONE serialized lane with:
20
+ // - one call in flight (by construction: Lane gates on a boolean, and `maxInFlight`
21
+ // in the config is advisory -- it is reported but NOT read.
22
+ // Measured 2026-09-13 at 1, 4 and 8: peak concurrency 1 every
23
+ // time. Making it real is a change to this file, not config.)
24
+ // - a floor between request starts (minIntervalMs)
25
+ // - a global calls/second ceiling
26
+ // - a circuit breaker that backs off instead of pile-driving a busy node
27
+ //
28
+ // Batching is the other half of the answer. Slice 11 records that a top-level
29
+ // JSON array is a batch answered on ONE connection ("Batches never throw HTTP
30
+ // errors"). Verified against the live node: a 3-method batch round-tripped in
31
+ // 3ms, where three separate calls cost three single-connection handoffs. Ten
32
+ // methods per tier therefore cost ~1 connection, not 10.
33
+ import http from 'node:http';
34
+ import https from 'node:https';
35
+ import { resolveCookie } from '../config.js';
36
+
37
+ export class RpcError extends Error {
38
+ constructor(message, { code = null, httpStatus = null, kind = 'rpc' } = {}) {
39
+ super(message);
40
+ this.name = 'RpcError';
41
+ this.code = code;
42
+ this.httpStatus = httpStatus;
43
+ this.kind = kind; // rpc | transport | timeout | auth | breaker | parse
44
+ }
45
+ }
46
+
47
+ // A serialized lane: one call at a time, spaced, rate-capped, with a breaker.
48
+ export class Lane {
49
+ constructor(cfg) {
50
+ this.cfg = cfg;
51
+ // Honour the rate ceiling for real: with one slot in flight, spacing is the
52
+ // lever that sets calls/second, so take whichever floor is stricter.
53
+ this.spacingMs = Math.max(cfg.minIntervalMs, cfg.maxRatePerSec ? Math.ceil(1000 / cfg.maxRatePerSec) : 0);
54
+ this.pending = new Map();
55
+ this.busy = false;
56
+ this.lastStartAt = 0;
57
+ this.openUntil = 0;
58
+ this.consecutive = 0;
59
+ this.recent = []; // {ts, ms, methods} over a sliding 60s window
60
+ this.stats = {
61
+ calls: 0, batches: 0, methods: 0, errors: 0, timeouts: 0, authRetries: 0,
62
+ breakerTrips: 0, lastLatencyMs: null, avgLatencyMs: null, maxLatencyMs: 0,
63
+ ratePerSec: 0, busyMsPerSec: 0, staleDropped: 0,
64
+ };
65
+ }
66
+
67
+ get breakerOpen() { return Date.now() < this.openUntil; }
68
+
69
+ // `priority` orders the queue, lowest first. Without it every tier shared one
70
+ // FIFO lane, and a measured 69-second getchaintxstats batch left the cheap
71
+ // getblockchaininfo poll starved behind it -- so the sync bar received about
72
+ // one height sample per two minutes on exactly the node that most needed it.
73
+ submit(job, { weight = 1, key = null, maxWaitMs = null, priority = 5, label = null } = {}) {
74
+ if (this.breakerOpen) {
75
+ const e = new RpcError(
76
+ `RPC circuit breaker open; retry in ${Math.ceil((this.openUntil - Date.now()) / 1000)}s${this.openedBy ? ` (opened by ${this.openedBy.label}, ${this.openedBy.kind} after ${this.openedBy.ms}ms)` : ''}`,
77
+ { kind: 'breaker' },
78
+ );
79
+ return Promise.reject(e);
80
+ }
81
+ const enqueuedAt = Date.now();
82
+ const budget = maxWaitMs ?? this.cfg.staleDropMs ?? 15000;
83
+
84
+ // Coalesce by key. A poll job asks "what is the state NOW", so a second
85
+ // identical request queued behind the first is not new information -- and on
86
+ // a node whose RPC was measured at 40.4s for a bare getblockcount during
87
+ // initial block download, a 4-second tier would otherwise stack up dozens of
88
+ // questions about a moment that had already passed. Newest wins; the
89
+ // superseded job is rejected as stale, which is a deliberate drop.
90
+ if (key && this.pending.has(key)) {
91
+ const old = this.pending.get(key);
92
+ this.pending.delete(key);
93
+ old.reject(new RpcError('superseded by a newer poll of the same tier', { kind: 'stale' }));
94
+ this.stats.staleDropped += 1;
95
+ }
96
+
97
+ return new Promise((resolve, reject) => {
98
+ const mapKey = key ?? `anon:${enqueuedAt}:${Math.random().toString(36).slice(2, 10)}`;
99
+ const entry = { job, weight, key, mapKey, enqueuedAt, budget, settled: false, label };
100
+ entry.resolve = (v) => { if (!entry.settled) { entry.settled = true; resolve(v); } };
101
+ entry.reject = (e) => { if (!entry.settled) { entry.settled = true; reject(e); } };
102
+ entry.priority = priority;
103
+ this.pending.set(mapKey, entry);
104
+ setImmediate(() => this._drain());
105
+ });
106
+ }
107
+
108
+ _drain() {
109
+ if (this.busy) return;
110
+ // Highest priority first, insertion order within a priority (Map preserves
111
+ // it, and the filter keeps that order).
112
+ let first = null;
113
+ for (const e of this.pending.values()) if (!first || e.priority < first.priority) first = e;
114
+ if (!first) return;
115
+ this.pending.delete(first.mapKey);
116
+ const now = Date.now();
117
+
118
+ // The breaker is respected at dequeue as well as at submit. Without this, work
119
+ // that was already queued when the breaker opened still fired at the node, so
120
+ // "back off for 30 s" meant "back off for new questions only" -- which is not
121
+ // what protects a single-threaded server. The measured shape (2026-09-08) was a
122
+ // fast-tier poll sitting in the lane behind three slow-tier failures and running
123
+ // anyway after the breaker opened.
124
+ if (now < this.openUntil) {
125
+ first.reject(new RpcError(
126
+ `RPC circuit breaker open; retry in ${Math.ceil((this.openUntil - now) / 1000)}s${this.openedBy ? ` (opened by ${this.openedBy.label}, ${this.openedBy.kind})` : ''} — this call was already queued when it opened`,
127
+ { kind: 'breaker' },
128
+ ));
129
+ if (this.pending.size) this._drain();
130
+ return;
131
+ }
132
+
133
+ // Stale-on-dequeue: if the lane could not get to it inside its freshness
134
+ // budget, asking now is worse than not asking, because the answer would be
135
+ // served as current state while describing an older moment.
136
+ if (now - first.enqueuedAt > first.budget) {
137
+ first.reject(new RpcError(`dropped: waited ${now - first.enqueuedAt}ms for a lane free enough to answer meaningfully`, { kind: 'stale' }));
138
+ this.stats.staleDropped += 1;
139
+ if (this.pending.size) this._drain();
140
+ return;
141
+ }
142
+
143
+ const wait = Math.max(0, this.spacingMs - (now - this.lastStartAt));
144
+ this.busy = true;
145
+ this.lastStartAt = now + wait;
146
+
147
+ setTimeout(() => {
148
+ Promise.resolve()
149
+ .then(() => first.job())
150
+ .then((v) => { this.consecutive = 0; first.resolve(v); })
151
+ .catch((err) => {
152
+ if (err.kind === 'stale') { first.reject(err); return; }
153
+ this.stats.errors += 1;
154
+ if (err.kind === 'timeout') this.stats.timeouts += 1;
155
+ this.consecutive += 1;
156
+ this.lastFailure = { at: Date.now(), label: first.label ?? 'call', kind: err.kind ?? 'rpc', ms: this.stats.lastLatencyMs ?? null, message: String(err.message ?? '').slice(0, 160) };
157
+ // A stale drop is this module choosing not to ask; only a real failure
158
+ // may open the breaker.
159
+ if (err.kind !== 'rpc' && this.consecutive >= this.cfg.breakerThreshold) {
160
+ this.openUntil = Date.now() + this.cfg.breakerCooldownMs;
161
+ // Which method did this, and what was queued behind it. The breaker is
162
+ // per-node on purpose, so "a slow tier blinds the fast one" is a real
163
+ // property of this design; recording the trigger is what makes the next
164
+ // occurrence answerable instead of arguable.
165
+ this.openedBy = { ...this.lastFailure, at: Date.now(), blocked: [...this.pending.values()].map((e) => e.label ?? 'call') };
166
+ this.stats.breakerTrips += 1;
167
+ this.consecutive = 0;
168
+ }
169
+ first.reject(err);
170
+ })
171
+ .finally(() => {
172
+ this.busy = false;
173
+ if (this.pending.size) this._drain();
174
+ });
175
+ }, wait);
176
+ }
177
+
178
+ // A timeout has to be timed too. Latency was previously only sampled on
179
+ // success, so a node that answered nothing at all for 90 s produced an empty
180
+ // latency history: the adaptive cadence saw a healthy node, the "RPC is slow"
181
+ // quality flag never fired, and the heavy-tier skip never engaged. The lane WAS
182
+ // busy for those 90 seconds; the telemetry has to say so.
183
+ noteFailure(ms, methodCount) {
184
+ const s = this.stats;
185
+ s.calls += 1;
186
+ s.failedCalls = (s.failedCalls ?? 0) + 1;
187
+ s.lastLatencyMs = ms;
188
+ s.avgLatencyMs = s.avgLatencyMs == null ? ms : Math.round(s.avgLatencyMs * 0.8 + ms * 0.2);
189
+ s.maxLatencyMs = Math.max(s.maxLatencyMs, ms);
190
+ this.recent.push({ ts: Date.now(), ms, methods: methodCount });
191
+ const cutoff = Date.now() - 60_000;
192
+ while (this.recent.length && this.recent[0].ts < cutoff) this.recent.shift();
193
+ s.ratePerSec = +(this.recent.length / 60).toFixed(2);
194
+ s.busyMsPerSec = +(this.recent.reduce((a, r) => a + r.ms, 0) / 60).toFixed(1);
195
+ }
196
+
197
+ note(ms, methodCount, wasBatch) {
198
+ const s = this.stats;
199
+ s.calls += 1;
200
+ if (wasBatch) s.batches += 1;
201
+ s.methods += methodCount;
202
+ s.lastLatencyMs = ms;
203
+ s.avgLatencyMs = s.avgLatencyMs == null ? ms : Math.round(s.avgLatencyMs * 0.8 + ms * 0.2);
204
+ s.maxLatencyMs = Math.max(s.maxLatencyMs, ms);
205
+ const cutoff = Date.now() - 60_000;
206
+ this.recent.push({ ts: Date.now(), ms, methods: methodCount });
207
+ while (this.recent.length && this.recent[0].ts < cutoff) this.recent.shift();
208
+ if (this.recent.length > 2000) this.recent.splice(0, this.recent.length - 2000);
209
+ s.ratePerSec = +(this.recent.reduce((a, r) => a + 1, 0) / 60).toFixed(2);
210
+ s.busyMsPerSec = +(this.recent.reduce((a, r) => a + r.ms, 0) / 60).toFixed(1);
211
+ }
212
+
213
+ get queued() { return this.pending.size; }
214
+
215
+ /**
216
+ * The breaker as a fact rather than a symptom: open or not, how long remains,
217
+ * what opened it, and what is being blocked while it is open.
218
+ *
219
+ * This is the answer to the question that could not be answered on 2026-09-08,
220
+ * when `online false (breaker open, retry in 26s)` alternated with `online true`
221
+ * while a direct getblockcount answered in 1 ms. Whether per-tier granularity
222
+ * would help is still unmeasured (docs/DEFECTS.md); whether the breaker is to
223
+ * blame for a given flap is now checkable from the telemetry panel.
224
+ */
225
+ breakerState() {
226
+ const now = Date.now();
227
+ return {
228
+ open: now < this.openUntil,
229
+ openForMs: this.openedBy && now < this.openUntil ? Math.max(0, this.cfg.breakerCooldownMs - (now - this.openedBy.at)) : 0,
230
+ retryInMs: now < this.openUntil ? this.openUntil - now : 0,
231
+ openedBy: this.openedBy ?? null,
232
+ lastFailure: this.lastFailure ?? null,
233
+ consecutive: this.consecutive,
234
+ threshold: this.cfg.breakerThreshold,
235
+ cooldownMs: this.cfg.breakerCooldownMs,
236
+ };
237
+ }
238
+ }
239
+
240
+ function parseUrl(u) { return new URL(u); }
241
+
242
+ export class RpcClient {
243
+ constructor(node, rpcCfg, { log } = {}) {
244
+ this.node = node;
245
+ this.cfg = rpcCfg;
246
+ this.log = log ?? (() => {});
247
+ this.lane = new Lane(rpcCfg);
248
+ this.url = parseUrl(node.rpcUrl);
249
+ this._cookieSource = null;
250
+ this._auth = null;
251
+ this.lastGoodAt = null;
252
+ this.lastError = null;
253
+ }
254
+
255
+ get id() { return this.node.id; }
256
+
257
+ // The cookie is regenerated on every boot and deleted on shutdown, so we
258
+ // resolve it lazily and re-resolve on 401 rather than caching a credential
259
+ // that a restart has already invalidated.
260
+ _credentials(forceRefresh = false) {
261
+ if (this._auth && !forceRefresh) return this._auth;
262
+ this._auth = resolveCookie(this.node);
263
+ this._cookieSource = this._auth?.source ?? null;
264
+ return this._auth;
265
+ }
266
+
267
+ _transport() {
268
+ const mod = this.url.protocol === 'https:' ? https : http;
269
+ return { mod, port: this.url.port ? Number(this.url.port) : (this.url.protocol === 'https:' ? 443 : 80) };
270
+ }
271
+
272
+ // Raw HTTP. Deliberately no keep-alive: holding the socket would hold the
273
+ // server's single service slot between our own requests.
274
+ _raw(bodyStr, { timeoutMs, allowRetry = true } = {}) {
275
+ const { mod, port } = this._transport();
276
+ // With no credential we still send (some setups run RPC without auth), but a
277
+ // 401 below is then reported as "no credential found", not as a mystery.
278
+ const auth = this._credentials();
279
+ const headers = {
280
+ 'Content-Type': 'text/plain', // Core's httprpc accepts any; plain matches bitcoin-cli
281
+ 'Content-Length': Buffer.byteLength(bodyStr),
282
+ Connection: 'close',
283
+ 'User-Agent': 'BlockYard/0.1',
284
+ };
285
+ if (auth) headers.Authorization = 'Basic ' + Buffer.from(`${auth.user}:${auth.password}`).toString('base64');
286
+
287
+ return new Promise((resolve, reject) => {
288
+ const req = mod.request({
289
+ hostname: this.url.hostname,
290
+ port,
291
+ method: 'POST',
292
+ path: this.url.pathname === '/' ? '/' : this.url.pathname,
293
+ agent: false,
294
+ headers,
295
+ setNoDelay: true,
296
+ }, (res) => {
297
+ const chunks = [];
298
+ let size = 0;
299
+ let aborted = false;
300
+ res.on('data', (c) => {
301
+ size += c.length;
302
+ // A mempool verbose map is megabytes; an unbounded accumulation is a
303
+ // memory leak with our name on it.
304
+ if (size > 512 * 1024 * 1024) { aborted = true; res.destroy(); reject(new RpcError('response too large', { kind: 'transport' })); return; }
305
+ chunks.push(c);
306
+ });
307
+ res.on('end', () => {
308
+ if (aborted) return;
309
+ resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') });
310
+ });
311
+ res.on('error', (e) => reject(new RpcError(e.message, { kind: 'transport' })));
312
+ });
313
+ req.on('error', (e) => reject(new RpcError(e.message, { kind: 'transport' })));
314
+ req.setTimeout(timeoutMs ?? this.cfg.timeoutMs, () => {
315
+ req.destroy();
316
+ reject(new RpcError(`rpc timeout after ${timeoutMs ?? this.cfg.timeoutMs}ms`, { kind: 'timeout' }));
317
+ });
318
+ req.end(bodyStr);
319
+ });
320
+ }
321
+
322
+ // One connection, one or many methods. Returns an array of
323
+ // {ok, result|error} in request order.
324
+ // `key` makes this poll-coalescable: pass the same key for a recurring tier so
325
+ // a fresh request supersedes one still waiting. Never pass a key for a
326
+ // user-initiated call -- those must each be answered.
327
+ async batch(calls, { timeoutMs, heavy = false, key = null, maxWaitMs = null, priority = 5 } = {}) {
328
+ if (!calls.length) return [];
329
+ const idOf = (i) => `c${i}`;
330
+ const payload = calls.map((c, i) => ({ jsonrpc: '1.0', id: idOf(i), method: c.method, params: c.params ?? [] }));
331
+ const single = calls.length === 1;
332
+ const body = single ? JSON.stringify(payload[0]) : JSON.stringify(payload);
333
+
334
+ const job = async () => {
335
+ const t0 = performance.now();
336
+ const to = timeoutMs ?? (heavy ? this.cfg.heavyTimeoutMs : this.cfg.timeoutMs);
337
+ let res;
338
+ try {
339
+ res = await this._raw(body, { timeoutMs: to });
340
+ } catch (err) {
341
+ this.lastError = { at: Date.now(), message: err.message, kind: err.kind };
342
+ this.lane.noteFailure(Math.round(performance.now() - t0), calls.length);
343
+ throw err;
344
+ }
345
+ if (res.status === 401) {
346
+ this.lane.stats.authRetries += 1;
347
+ // Cookie likely rotated under us (a restart). Re-read once, then retry.
348
+ const fresh = this._credentials(true);
349
+ if (fresh) {
350
+ const retry = await this._raw(body, { timeoutMs: to });
351
+ if (retry.status !== 401) { res = retry; }
352
+ else {
353
+ this.lastError = { at: Date.now(), message: 'RPC 401 unauthorized (cookie rejected after refresh)', kind: 'auth' };
354
+ throw new RpcError('RPC authentication failed (cookie rejected)', { kind: 'auth', httpStatus: 401 });
355
+ }
356
+ } else {
357
+ this.lastError = { at: Date.now(), message: 'RPC 401 and no credential found', kind: 'auth' };
358
+ throw new RpcError('RPC 401 and no credential found (checked cookie paths)', { kind: 'auth', httpStatus: 401 });
359
+ }
360
+ }
361
+ if (res.status >= 400 && res.status !== 500) {
362
+ // Core answers parse errors at 500 with a JSON-RPC body; other 4xx/5xx
363
+ // are transport-level and carry no usable envelope.
364
+ this.lastError = { at: Date.now(), message: `RPC HTTP ${res.status}`, kind: 'transport' };
365
+ throw new RpcError(`RPC HTTP ${res.status}: ${res.body.slice(0, 200)}`, { kind: 'transport', httpStatus: res.status });
366
+ }
367
+ let parsed;
368
+ try {
369
+ parsed = JSON.parse(res.body);
370
+ } catch {
371
+ this.lastError = { at: Date.now(), message: 'RPC returned non-JSON', kind: 'parse' };
372
+ throw new RpcError(`RPC returned non-JSON (${res.body.slice(0, 120)})`, { kind: 'parse' });
373
+ }
374
+ const items = Array.isArray(parsed) ? parsed : [parsed];
375
+ const byId = new Map(items.map((it) => [String(it.id), it]));
376
+ const out = calls.map((c, i) => {
377
+ const it = byId.get(idOf(i)) ?? byId.get('0') ?? (single ? items[0] : undefined);
378
+ if (!it) return { ok: false, method: c.method, error: { code: null, message: 'no reply for this method in batch' } };
379
+ if (it.error) return { ok: false, method: c.method, error: it.error };
380
+ return { ok: true, method: c.method, result: it.result };
381
+ });
382
+ this.lastGoodAt = Date.now();
383
+ this.lastError = null;
384
+ this.lane.note(Math.round(performance.now() - t0), calls.length, !single);
385
+ return out;
386
+ };
387
+
388
+ return this.lane.submit(job, { key, maxWaitMs, priority, label: calls.map((c) => c.method).join('+').slice(0, 120) });
389
+ }
390
+
391
+ async call(method, params = [], opts = {}) {
392
+ const [r] = await this.batch([{ method, params }], opts);
393
+ if (!r.ok) {
394
+ throw new RpcError(r.error?.message ?? `${method} failed`, { code: r.error?.code, kind: 'rpc' });
395
+ }
396
+ return r.result;
397
+ }
398
+
399
+ telemetry() {
400
+ return {
401
+ nodeId: this.id,
402
+ url: this.node.rpcUrl,
403
+ cookieSource: this._cookieSource,
404
+ online: !!this.lastGoodAt && !this.lane.breakerOpen && (!this.lastError || (this.lastGoodAt > this.lastError.at)),
405
+ lastGoodAt: this.lastGoodAt,
406
+ lastError: this.lastError,
407
+ breakerOpen: this.lane.breakerOpen,
408
+ breaker: this.lane.breakerState(),
409
+ queued: this.lane.queued,
410
+ recent: this.recent,
411
+ ...this.lane.stats,
412
+ };
413
+ }
414
+ }
@@ -0,0 +1,148 @@
1
+ // The audit trail, with the one property it did not have: a bounded size.
2
+ //
3
+ // Why this was a real defect and not housekeeping: `audit.jsonl` records logins,
4
+ // RPC calls, action results and CSRF rejections — an append-only file with no
5
+ // rotation, on a box that has filled its disk before. A full disk is not "no audit
6
+ // log"; it is "no monitor", because the same disk holds the history snapshots the
7
+ // charts restore from, and the node's own datadir is on it too. The failure mode of
8
+ // an unbounded log is the node going down for the log's sake.
9
+ //
10
+ // Rotation is size-triggered and shifts a numbered chain (audit.jsonl ->
11
+ // audit.1.jsonl -> …) rather than deleting, so "who logged in at 03:12" survives
12
+ // long enough to be asked. `read()` walks the chain newest-first, which matters:
13
+ // rotating under the reader is exactly when someone is looking at the audit page.
14
+ import fsp from 'node:fs/promises';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { appendJsonl } from './history.js';
18
+
19
+ export class AuditLog {
20
+ constructor(file, { maxBytes = 8 * 1024 * 1024, keep = 5, log = () => {} } = {}) {
21
+ this.file = file;
22
+ this.maxBytes = maxBytes;
23
+ this.keep = Math.max(1, keep | 0);
24
+ this.log = log;
25
+ this.rotations = 0;
26
+ this.bytes = 0;
27
+ this.droppedLines = 0;
28
+ }
29
+
30
+ /** audit.jsonl, audit.1.jsonl, … newest first. */
31
+ chain() {
32
+ const out = [this.file];
33
+ for (let i = 1; i <= this.keep; i++) out.push(rotated(this.file, i));
34
+ return out;
35
+ }
36
+
37
+ async append(row) {
38
+ const line = JSON.stringify(row);
39
+ // The cheap in-memory counter decides *whether to look*; the on-disk size decides
40
+ // whether to rotate. A counter alone drifts (an entry written by another process,
41
+ // a hand-edited file after an incident) and a budget that drifts is a rumour.
42
+ const grown = this.bytes + Buffer.byteLength(line) + 1;
43
+ if (grown >= this.maxBytes) {
44
+ const onDisk = await this.currentSize();
45
+ this.bytes = Math.max(this.bytes, onDisk);
46
+ if (onDisk >= this.maxBytes) await this.rotate();
47
+ }
48
+ await appendJsonl(this.file, row);
49
+ this.bytes += Buffer.byteLength(line) + 1;
50
+ return row;
51
+ }
52
+
53
+ async currentSize() {
54
+ try { return (await fsp.stat(this.file)).size; } catch { return 0; }
55
+ }
56
+
57
+ /** Shift the chain by one and start a fresh current file. */
58
+ async rotate() {
59
+ try {
60
+ const oldest = rotated(this.file, this.keep);
61
+ if (this.keep === 1) {
62
+ await fsp.rm(this.file, { force: true });
63
+ } else {
64
+ // Walk backwards so nothing is renamed onto a file that still matters.
65
+ await fsp.rm(oldest, { force: true });
66
+ for (let i = this.keep - 1; i >= 1; i--) {
67
+ await fsp.rename(rotated(this.file, i), rotated(this.file, i + 1)).catch((e) => {
68
+ if (e.code !== 'ENOENT') throw e;
69
+ });
70
+ }
71
+ await fsp.rename(this.file, rotated(this.file, 1)).catch((e) => {
72
+ if (e.code !== 'ENOENT') throw e;
73
+ });
74
+ }
75
+ this.rotations += 1;
76
+ this.bytes = 0;
77
+ this.log({ level: 'info', msg: `audit log rotated (>${fmtBytes(this.maxBytes)}); keeping ${this.keep} previous file(s)` });
78
+ } catch (err) {
79
+ // A failed rotation must not lose the audit entry, and must not take the
80
+ // server down: keep appending, and say so every time.
81
+ this.log({ level: 'error', msg: `audit rotation failed (${err.message}); audit.jsonl keeps growing until it succeeds` });
82
+ this.rotationError = err.message;
83
+ }
84
+ }
85
+
86
+ /** Newest-first entries, crossing file boundaries so a rotation is invisible. */
87
+ async read(limit = 100) {
88
+ const out = [];
89
+ for (const file of this.chain()) {
90
+ if (out.length >= limit) break;
91
+ let raw;
92
+ try { raw = await fsp.readFile(file, 'utf8'); } catch { continue; }
93
+ const lines = raw.split('\n').filter(Boolean);
94
+ // The tail of an older file is its newest entries, which is what comes next
95
+ // after the current file runs out.
96
+ const take = lines.slice(-(limit - out.length));
97
+ for (let i = take.length - 1; i >= 0 && out.length < limit; i--) {
98
+ try { out.push(JSON.parse(take[i])); } catch { out.push({ unparsable: take[i].slice(0, 120) }); }
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** What the admin page and /api/telemetry need to know about the log itself. */
105
+ async stats() {
106
+ const files = [];
107
+ let total = 0;
108
+ for (const f of this.chain()) {
109
+ try {
110
+ const st = await fsp.stat(f);
111
+ files.push({ file: path.basename(f), bytes: st.size, mtime: st.mtimeMs });
112
+ total += st.size;
113
+ } catch { /* not present */ }
114
+ }
115
+ return {
116
+ file: path.basename(this.file),
117
+ files,
118
+ totalBytes: total,
119
+ currentBytes: files[0]?.bytes ?? 0,
120
+ maxBytes: this.maxBytes,
121
+ keep: this.keep,
122
+ rotations: this.rotations,
123
+ rotationError: this.rotationError ?? null,
124
+ // The number that makes the budget honest: how much headroom is left before
125
+ // the next rotation, and at the observed rate, when that is.
126
+ headroomBytes: Math.max(0, this.maxBytes - (files[0]?.bytes ?? 0)),
127
+ };
128
+ }
129
+
130
+ /** Adopt an existing file's size so the first append after a restart is correct. */
131
+ async adopt() {
132
+ try {
133
+ this.bytes = (await fsp.stat(this.file)).size;
134
+ return { adopted: this.bytes };
135
+ } catch { return { adopted: 0 }; }
136
+ }
137
+ }
138
+
139
+ function rotated(file, i) {
140
+ const dir = path.dirname(file);
141
+ const base = path.basename(file, '.jsonl');
142
+ return path.join(dir, `${base}.${i}.jsonl`);
143
+ }
144
+
145
+ function fmtBytes(n) {
146
+ return n >= 1048576 ? `${(n / 1048576).toFixed(0)} MB` : `${Math.round(n / 1024)} KB`;
147
+ }
148
+