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,253 @@
1
+ // Which address this process is willing to be reached on, and what to say when it
2
+ // can't be. Kept separate from the HTTP layer because the answers are boot-time
3
+ // facts about the machine, not about requests -- and because a failed bind used to
4
+ // surface as a raw Node stack trace, which tells you nothing about the one question
5
+ // that matters: which address did you ask for, and which ones does this box have?
6
+ import os from 'node:os';
7
+
8
+ export function networkInterfaces(over = null) {
9
+ return over ?? os.networkInterfaces();
10
+ }
11
+
12
+ // Every unicast address the kernel actually has, as { name, address, family }.
13
+ export function localAddresses(ifaces = networkInterfaces()) {
14
+ const out = [];
15
+ for (const [name, list] of Object.entries(ifaces ?? {})) {
16
+ for (const a of list ?? []) {
17
+ if (a.internal && a.family !== 'IPv4') continue;
18
+ out.push({ name, address: a.address, family: a.family, internal: !!a.internal });
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export function hasAddress(addr, ifaces = networkInterfaces()) {
25
+ if (addr === '0.0.0.0' || addr === '::' || addr === '*' || addr == null) return true;
26
+ const target = String(addr).replace(/^\[|\]$/g, '');
27
+ const addrs = localAddresses(ifaces).map((a) => a.address.replace(/^\[|\]$/g, ''));
28
+ if (addrs.includes(target)) return true;
29
+ // `localhost` and 127.0.0.1 / ::1 are always bindable, present or not in the list.
30
+ if (target === 'localhost' || target === '127.0.0.1' || target === '::1') return true;
31
+ return false;
32
+ }
33
+
34
+ // Is the configured bind an address literal (or localhost)? A hostname here fails at
35
+ // listen() with a message about names, and after a reboot with a changed DNS it can
36
+ // fail at the worst possible moment, so it is worth refusing at load instead.
37
+ export function isBindableHost(host) {
38
+ if (host == null || host === '') return true;
39
+ const h = String(host).replace(/^\[|\]$/g, '');
40
+ if (h === 'localhost' || h === '0.0.0.0' || h === '::' || h === '*') return true;
41
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) {
42
+ return h.split('.').every((p) => Number(p) <= 255);
43
+ }
44
+ if (h.includes(':')) return /^[0-9a-fA-F:.%]+$/.test(h);
45
+ return false;
46
+ }
47
+
48
+
49
+ /**
50
+ * Split a bind configuration into addresses, and say which this machine can take.
51
+ *
52
+ * Multiple addresses are supported because one socket cannot express "LAN and the
53
+ * tunnel, but not the container bridges and not loopback" -- and because the
54
+ * single-address version of this locked the operator out: their browser arrives over
55
+ * the tailnet, and a LAN-only bind is unreachable from the tailnet (the tailnet does
56
+ * not advertise 192.0.2.0/24 here).
57
+ *
58
+ * Missing addresses are reported, not fatal: a tunnel interface that is not up yet at
59
+ * boot (tailscaled starting after the network) must not keep the monitor from
60
+ * serving the interfaces that ARE present. Only "none of them work" is fatal.
61
+ */
62
+ export function planBinds(hosts, ifaces = networkInterfaces()) {
63
+ const list = (Array.isArray(hosts) ? hosts : String(hosts ?? '0.0.0.0').split(','))
64
+ .map((h) => String(h).trim())
65
+ .filter(Boolean);
66
+ const bindable = [];
67
+ const missing = [];
68
+ for (const h of list) (hasAddress(h, ifaces) ? bindable : missing).push(h);
69
+ return { list, bindable, missing, noneUsable: bindable.length === 0 };
70
+ }
71
+
72
+ /**
73
+ * A human-readable explanation of a failed or impossible bind.
74
+ *
75
+ * The point is not politeness. Binding to one address is a decision with casualties
76
+ * (loopback healthchecks, other interfaces, a DHCP address that moved), and an
77
+ * operator staring at `EADDRNOTAVAIL` should not have to reconstruct that.
78
+ */
79
+ export function bindProblemMessage({ err, host, port, ifaces = networkInterfaces() }) {
80
+ const code = err?.code ?? '';
81
+ const have = localAddresses(ifaces).filter((a) => a.family === 'IPv4');
82
+ const listed = have.map((a) => `${a.address} (${a.name})`).join(', ') || 'no unicast IPv4 at all';
83
+ const where = 'config/local.json → server.host, or BLOCKYARD_BIND';
84
+
85
+ if (code === 'EADDRNOTAVAIL') {
86
+ return `cannot bind http://${host}:${port} — this machine has no address ${host}. `
87
+ + `Addresses present: ${listed}. If the interface or its lease changed, update ${where} `
88
+ + `(or set 0.0.0.0 to accept every interface, which serves whatever the box has, including tunnels).`;
89
+ }
90
+ if (code === 'EACCES') {
91
+ return `cannot bind ${host}:${port} — permission denied. Ports below 1024 need privileges; `
92
+ + `use a high port (21000) or a reverse proxy in front.`;
93
+ }
94
+ if (code === 'EADDRINUSE') {
95
+ const same = have.some((a) => a.address === host);
96
+ return `cannot bind ${host}:${port} — something is already listening there. `
97
+ + (same
98
+ ? `The address is one of ours, so this is likely a second instance of this monitor: stop the other one (or point this one at another port).`
99
+ : `${host} is not this machine's own address, so a different process or a container owns it.`)
100
+ + ` Address list: ${listed}.`;
101
+ }
102
+ return `cannot bind ${host}:${port}${code ? ` (${code})` : ''}${err?.message ? `: ${err.message}` : ''}. `
103
+ + `Addresses present: ${listed}. Override with ${where}.`;
104
+ }
105
+
106
+ // ---------------------------------------------------------------- CIDR membership
107
+ //
108
+ // This replaces prefix-TEXT matching, which over-permitted. `ipAllowed` used to
109
+ // compare `net.split('::')[0]` against the start of the client's address string,
110
+ // so `2001:db8:1::/48` accepted `2001:db8:1f::1` -- an address 14 bits outside the
111
+ // network -- because "2001:db8:1f::1" starts with the characters "2001:db8:1".
112
+ // Text has no idea where a nibble ends. This version parses both families to
113
+ // bytes and compares bit by bit.
114
+ //
115
+ // Malformed input fails CLOSED here: an entry that cannot be parsed can never
116
+ // admit a client, and `server.allowCidrs` is validated at config load, so a typo
117
+ // stops the boot rather than quietly widening (or narrowing) the gate.
118
+
119
+ /** Parse an IPv4 or IPv6 literal to bytes. Returns {family, bytes} or null. */
120
+ export function parseIp(input) {
121
+ if (typeof input !== 'string') return null;
122
+ let s = input.trim().replace(/^\[|\]$/g, '');
123
+ const zone = s.indexOf('%');
124
+ if (zone >= 0) s = s.slice(0, zone); // %eth0 is a scope, not part of the address
125
+ if (!s) return null;
126
+ if (s.includes(':')) return parseIpv6(s);
127
+ return parseIpv4(s);
128
+ }
129
+
130
+ function parseIpv4(s) {
131
+ const parts = s.split('.');
132
+ if (parts.length !== 4) return null;
133
+ const bytes = [];
134
+ for (const p of parts) {
135
+ if (!/^\d{1,3}$/.test(p)) return null;
136
+ const n = Number(p);
137
+ if (n > 255) return null;
138
+ bytes.push(n);
139
+ }
140
+ return { family: 'ipv4', bytes: new Uint8Array(bytes) };
141
+ }
142
+
143
+ function parseIpv6(s) {
144
+ if (s.includes(':::')) return null; // ':::' is not compression, it is a typo
145
+ // Trailing IPv4 form (`::ffff:192.0.2.7`) occupies the low 4 bytes.
146
+ const tail4 = /:\d{1,3}(?:\.\d{1,3}){3}$/.test(s);
147
+ let head = s;
148
+ let tailBytes = null;
149
+ if (tail4) {
150
+ const i = s.lastIndexOf(':');
151
+ const v4 = parseIpv4(s.slice(i + 1));
152
+ if (!v4) return null;
153
+ tailBytes = v4.bytes;
154
+ head = s.slice(0, i + 1) + '0'; // placeholder group, overwritten below
155
+ }
156
+ const halves = head.split('::');
157
+ if (halves.length > 2) return null;
158
+ const expand = (chunk) => {
159
+ if (chunk === '') return [];
160
+ const groups = chunk.split(':').filter((g) => g !== '');
161
+ const out = [];
162
+ for (const g of groups) {
163
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
164
+ out.push(Number.parseInt(g, 16));
165
+ }
166
+ return out;
167
+ };
168
+ let groups;
169
+ if (halves.length === 2) {
170
+ const left = expand(halves[0]);
171
+ const right = expand(halves[1]);
172
+ if (!left || !right) return null;
173
+ if (left.length + right.length > 7) return null; // '::' must stand for >=1 group
174
+ groups = [...left, ...new Array(8 - left.length - right.length).fill(0), ...right];
175
+ } else {
176
+ const only = expand(halves[0]);
177
+ if (!only) return null;
178
+ if (only.length !== 8) return null; // no '::' means all eight groups are required
179
+ groups = only;
180
+ }
181
+ const bytes = new Uint8Array(16);
182
+ groups.forEach((g, i) => { bytes[i * 2] = (g >> 8) & 0xff; bytes[i * 2 + 1] = g & 0xff; });
183
+ if (tailBytes) bytes.set(tailBytes, 12);
184
+ return { family: 'ipv6', bytes };
185
+ }
186
+
187
+ /**
188
+ * Parse one allowlist entry: a bare address (implies /32 or /128) or `addr/bits`.
189
+ * Returns {ok:true, family, bytes, bits, text} or {ok:false, reason}.
190
+ */
191
+ export function parseCidr(entry) {
192
+ const text = String(entry ?? '').trim();
193
+ if (!text) return { ok: false, reason: 'empty entry' };
194
+ const slash = text.indexOf('/');
195
+ const addrText = slash < 0 ? text : text.slice(0, slash);
196
+ const parsed = parseIp(addrText);
197
+ if (!parsed) return { ok: false, reason: `"${addrText}" is not an IPv4 or IPv6 address` };
198
+ const full = parsed.family === 'ipv4' ? 32 : 128;
199
+ let bits = full;
200
+ if (slash >= 0) {
201
+ const raw = text.slice(slash + 1);
202
+ if (!/^\d{1,3}$/.test(raw)) return { ok: false, reason: `"${raw}" is not a prefix length` };
203
+ bits = Number(raw);
204
+ if (bits > full) return { ok: false, reason: `/${bits} is wider than an ${parsed.family} address (${full} bits)` };
205
+ }
206
+ return { ok: true, family: parsed.family, bytes: parsed.bytes, bits, text };
207
+ }
208
+
209
+ /** Bit-exact membership of one address in one network. */
210
+ export function inNetwork(ipBytes, netBytes, bits) {
211
+ if (ipBytes.length !== netBytes.length) return false; // v4 never matches a v6 net, and vice versa
212
+ for (let i = 0; i < bits; i += 8) {
213
+ const take = Math.min(8, bits - i);
214
+ const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
215
+ if ((ipBytes[i / 8] & mask) !== (netBytes[i / 8] & mask)) return false;
216
+ }
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Is `ip` admitted by `cidrs`? Empty/absent list admits everything (the gate is
222
+ * opt-in). Returns a decision with the reason, because "403 from this address"
223
+ * with no explanation costs an afternoon: the message names the entry that
224
+ * matched, or that the list holds only families the client is not in.
225
+ */
226
+ export function ipDecision(ip, cidrs = []) {
227
+ if (!cidrs.length) return { allowed: true, reason: 'no allowCidrs configured' };
228
+ const parsedIp = parseIp(ip);
229
+ if (!parsedIp) return { allowed: false, reason: `client address "${ip}" could not be parsed, so nothing can be admitted` };
230
+ const bad = [];
231
+ let familyMismatch = true;
232
+ for (const entry of cidrs) {
233
+ const net = parseCidr(entry);
234
+ if (!net.ok) { bad.push(`${entry} (${net.reason})`); continue; }
235
+ if (net.family !== parsedIp.family) continue;
236
+ familyMismatch = false;
237
+ if (inNetwork(parsedIp.bytes, net.bytes, net.bits)) {
238
+ return { allowed: true, matched: net.text, reason: `matched ${net.text}` };
239
+ }
240
+ }
241
+ const why = bad.length
242
+ ? `malformed entries are never permissive: ${bad.join('; ')}`
243
+ : familyMismatch
244
+ ? `the allowlist holds only ${[...new Set(cidrs.map((c) => parseCidr(c).family).filter(Boolean))].join('/') || 'nothing parseable'}; the client is ${parsedIp.family}`
245
+ : `not inside any of: ${cidrs.join(', ')}`;
246
+ return { allowed: false, reason: why, malformed: bad };
247
+ }
248
+
249
+ /** Boolean form, kept for call sites that only branch. */
250
+ export function ipAllowed(ip, cidrs = []) {
251
+ return ipDecision(ip, cidrs).allowed;
252
+ }
253
+
@@ -0,0 +1,130 @@
1
+ // Which RPCs the web UI may call. Default DENY.
2
+ //
3
+ // A prefix rule like "starts with get" would be a hole, not a guard: this node's
4
+ // own docs (docs/RPC_LIVE_NODE.md) record that `getnewaddress` and
5
+ // `getrawchangeaddress` derive and persist new wallet keys, and that
6
+ // `rescanblockchain` blocks every other RPC for its duration on a server that
7
+ // services one connection at a time. So the rules are:
8
+ //
9
+ // 1. an explicit deny list of mutating and expensive methods, checked first;
10
+ // 2. then an allow list of read-shaped prefixes;
11
+ // 3. anything unknown is denied, and the reply says what to change to allow it.
12
+ //
13
+ // Node write operations are not in this file at all: they live behind
14
+ // config.actions.allow and are audited. See http/api.js action routes.
15
+
16
+ // Mutating, wallet-changing, or file-writing. Checked before the allow prefixes.
17
+ const DENY_EXACT = new Set([
18
+ // wallet key/materialization -- "get" prefix notwithstanding
19
+ 'getnewaddress', 'getrawchangeaddress', 'keypoolrefill', 'addhdkey',
20
+ // spends and broadcasts
21
+ 'sendtoaddress', 'sendmany', 'send', 'sendall', 'sendrawtransaction', 'submitpackage',
22
+ 'walletcreatefundedpsbt', 'fundrawtransaction', 'bumpfee', 'psbtbumpfee', 'signrawtransactionwithwallet',
23
+ // wallet state
24
+ 'walletpassphrase', 'walletlock', 'walletpassphrasechange', 'encryptwallet', 'setlabel',
25
+ 'importprivkey', 'importaddress', 'importpublickey', 'importdescriptors', 'importmempool',
26
+ 'importmulti', 'importprunedfunds', 'removeprunedfunds', 'createwallet', 'loadwallet',
27
+ 'unloadwallet', 'restorewallet', 'migratewallet', 'setwalletflag', 'backupwallet',
28
+ 'exportwatchonlywallet', 'abandontransaction', 'lockunspent', 'sethdseed', 'settxfee',
29
+ 'signmessage', 'signrawtransactionwithkey', 'walletprocesspsbt', 'descriptorprocesspsbt',
30
+ // peer / network control (the node documents these as worker-channel mutators)
31
+ 'addnode', 'removeaddednode', 'disconnectnode', 'setban', 'clearbanned', 'setnetworkactive', 'ping',
32
+ // chain / storage mutators
33
+ 'invalidateblock', 'reconsiderblock', 'preciousblock', 'pruneblockchain', 'submitblock',
34
+ 'submitheader', 'stop', 'savemempool', 'dumptxoutset', 'loadtxoutset', 'simulateutxo',
35
+ // heavy reads that monopolise a single-threaded RPC server
36
+ 'rescanblockchain', 'scanblocks', 'scantxoutset', 'getdescriptoractivity', 'verifychain',
37
+ // can switch logging off; the node refuses the mutating form anyway
38
+ 'logging',
39
+ ]);
40
+
41
+ // Bitcoin Core's own read verbs, and nothing else: an earlier node this was written against
42
+ // namespaced its own commands with a vendor prefix, and those prefixes were admitted here by
43
+ // read verb. That node is not supported (2026-09-14), and its prefixes are gone with it -- a
44
+ // prefix nobody uses is a hole waiting for a name.
45
+ const ALLOW_PREFIXES = ['get', 'list', 'estimate', 'verify', 'estimat', 'help', 'uptime', 'decoderaw', 'decodescript', 'createraw', 'analyzepsbt', 'decodepsbt', 'convertbits', 'getrpcinfo'];
46
+
47
+ // Explicitly not allowed even though they look read-shaped: they build or sign
48
+ // transactions, which is a write in every way that matters.
49
+ const DENY_PREFIXES = ['generate', 'invalidate', 'reconsider', 'import', 'send', 'set', 'unload', 'load', 'sign'];
50
+
51
+ export function classifyMethod(method) {
52
+ if (typeof method !== 'string' || !method) return { allowed: false, kind: 'unknown', reason: 'method name must be a string' };
53
+ if (DENY_EXACT.has(method)) return { allowed: false, kind: 'write-or-heavy', reason: 'this method mutates state or monopolises the node\'s single-threaded RPC server' };
54
+ for (const p of DENY_PREFIXES) if (method.startsWith(p) && !ALLOW_PREFIXES.includes(method)) return { allowed: false, kind: 'write', reason: `method starts with "${p}" and is treated as a mutation` };
55
+ if (method === 'help' || method === 'uptime' || method === 'stop') {
56
+ return method === 'stop'
57
+ ? { allowed: false, kind: 'write', reason: 'stop shuts the node down; not exposed to the web UI' }
58
+ : { allowed: true, kind: 'read' };
59
+ }
60
+ for (const p of ALLOW_PREFIXES) if (method.startsWith(p)) return { allowed: true, kind: 'read' };
61
+ return { allowed: false, kind: 'unknown', reason: 'not recognised as a read-only method; add it to server/rpc/allowlist.js if this is wrong' };
62
+ }
63
+
64
+ // Methods the node itself documents as refusing (or as needing the worker
65
+ // channel). Surfaced so the UI can label a refusal as expected rather than as a
66
+ // monitor bug. Lifted from docs/RPC_LIVE_NODE.md's refusal catalogue.
67
+ export const NODE_REFUSES = new Set([
68
+ 'loadtxoutset', 'getopenrpcinfo', 'rpc.discover', 'exportasmap', 'enumeratesigners',
69
+ 'walletdisplayaddress', 'getmempoolcluster', 'getblockfrompeer', 'preciousblock',
70
+ 'pruneblockchain', 'submitheader', 'getblockfilter',
71
+ ]);
72
+
73
+ export function allowlistSummary() {
74
+ return {
75
+ denyExactCount: DENY_EXACT.size,
76
+ allowPrefixes: [...new Set(ALLOW_PREFIXES)].sort(),
77
+ denyPrefixes: [...new Set(DENY_PREFIXES)].sort(),
78
+ defaultDecision: 'deny',
79
+ };
80
+ }
81
+
82
+ // Actions the operator can explicitly opt into. Each must appear in
83
+ // config.actions.allow to be reachable at all.
84
+ export const ACTIONS = {
85
+ broadcast: {
86
+ method: 'sendrawtransaction',
87
+ label: 'Broadcast a raw transaction',
88
+ role: 'operator',
89
+ args: ['hexstring', 'maxfeerate?'],
90
+ note: 'Pushes a signed transaction to the node, which relays it to every live peer leg. Irreversible once relayed.',
91
+ },
92
+ savemempool: {
93
+ method: 'savemempool',
94
+ label: 'Persist the mempool to mempool.dat',
95
+ role: 'operator',
96
+ args: [],
97
+ note: 'Writes mempool.dat in Core\'s format. The node refuses if persistence is disabled.',
98
+ },
99
+ testmempoolaccept: {
100
+ method: 'testmempoolaccept',
101
+ label: 'Dry-run a transaction against mempool policy',
102
+ role: 'viewer',
103
+ args: ['rawtxs', 'maxfeerate?'],
104
+ note: 'Runs the identical consensus and policy validation, stopping at the commit boundary. Accepts a package.',
105
+ },
106
+ verifychain_l1: {
107
+ method: 'verifychain',
108
+ label: 'Verify block data (checklevel <= 2)',
109
+ role: 'admin',
110
+ args: ['checklevel', 'checkdepth'],
111
+ fixed: [2, 6],
112
+ note: 'Levels 0-2 re-read and re-hash block data and check the merkle root. Levels 3-4 are refused by the node because they would disconnect and reconnect blocks.',
113
+ },
114
+ };
115
+
116
+ export function actionAllowed(cfg, name, role) {
117
+ const def = ACTIONS[name];
118
+ if (!def) return { ok: false, reason: `unknown action "${name}"` };
119
+ if (!cfg.actions.enabled) return { ok: false, reason: 'node actions are disabled (set BLOCKYARD_ENABLE_ACTIONS=1 and list BLOCKYARD_ACTIONS)' };
120
+ // With accounts off there is no role to check, so the role gate below is vacuous
121
+ // and every listed action would be reachable by whoever can open a socket. Writes
122
+ // therefore need a second, explicit acknowledgement in open mode.
123
+ if (!cfg.auth.enabled && !cfg.actions.allowWritesWithoutAuth) {
124
+ return { ok: false, reason: 'accounts are off, so there is no identity to hold a node write accountable (enable BLOCKYARD_AUTH=1, or BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1 deliberately)' };
125
+ }
126
+ if (!cfg.actions.allow.includes(name)) return { ok: false, reason: `"${name}" is not in config.actions.allow` };
127
+ const rank = { viewer: 0, operator: 1, admin: 2 };
128
+ if ((rank[role] ?? -1) < (rank[def.role] ?? 99)) return { ok: false, reason: `action "${name}" needs role ${def.role}` };
129
+ return { ok: true, def };
130
+ }