blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,272 @@
1
+ // Refresh the README/docs screenshots against a running monitor.
2
+ //
3
+ // (operator, 2026-09-13: "We should probably take updated screenshots for everything with
4
+ // the new defaults" -- the shipped look changed that day: stars on, a dense sky with the
5
+ // spiral arms on, markets opening on 24h in the FLAT view, and the courts' own grids.)
6
+ //
7
+ // THREE RULES THIS FILE OBEYS, each learned the hard way in this repo:
8
+ //
9
+ // 1. NAVIGATE BY CLICKING, never by setting the hash. A hash-only navigation against a
10
+ // warm document is a no-op, and every reading taken that way here was the wrong page
11
+ // measured (motion-check.mjs). Capturing 11 pages by hash would quietly produce 11
12
+ // copies of the overview. Every shot re-asserts which page it landed on.
13
+ // 2. HEIGHT IS MEASURED, NOT INHERITED. The old markets.jpg was 1600x2400 because the
14
+ // page then stacked a 3D board above the flat chart; with 2D as the default it is
15
+ // 1423 tall, and capturing at 2400 gives a screenshot that is half dead black.
16
+ // 3. A SETTINGS TOGGLE NOW WRITES THROUGH TO THE SERVER. The neon shot changes
17
+ // space.neon, which POSTs to /api/settings and rewrites config/blockyard.json -- the
18
+ // operator's own saved settings. It is captured last, and restored, and the caller
19
+ // checks the file's md5 against the baseline afterwards.
20
+ //
21
+ // Zero dependencies: chromium speaks CDP over a plain WebSocket and Node 22 ships one.
22
+ //
23
+ // BLOCKYARD_BASE=http://127.0.0.1:21000 [BROWSER_CDP=http://127.0.0.1:9445] [BLOCKYARD_NODE=main] \
24
+ // node scripts/shots.mjs [name ...]
25
+ import { writeFileSync } from 'node:fs';
26
+
27
+ const BASE = process.env.BLOCKYARD_BASE ?? 'http://127.0.0.1:21000';
28
+ const CDP = process.env.BROWSER_CDP ?? 'http://127.0.0.1:9445';
29
+ const QUALITY = Number(process.env.SHOT_QUALITY ?? 82);
30
+ const W = 1600;
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ const targets = JSON.parse(await (await fetch(`${CDP}/json/list`)).text());
34
+ const target = targets.find((t) => t.type === 'page');
35
+ if (!target) { console.log('no page target; is chromium running with --remote-debugging-port?'); process.exit(1); }
36
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
37
+ await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
38
+ let nextId = 1;
39
+ const pending = new Map();
40
+ ws.onmessage = (ev) => {
41
+ const m = JSON.parse(ev.data);
42
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
43
+ };
44
+ const send = (method, params = {}) => new Promise((res, rej) => {
45
+ const id = nextId++;
46
+ pending.set(id, (m) => (m.error ? rej(new Error(`${method}: ${m.error.message}`)) : res(m.result)));
47
+ ws.send(JSON.stringify({ id, method, params }));
48
+ setTimeout(() => { if (pending.has(id)) { pending.delete(id); rej(new Error(`${method}: timeout`)); } }, 40000);
49
+ });
50
+ const evl = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })).result?.value;
51
+
52
+ // NOT document.body.scrollHeight. scrollHeight is never smaller than the viewport, so
53
+ // measuring from a tall viewport returns the viewport -- the first cut measured at the
54
+ // 2600 cap and every page came back "2600 tall", which is how four screenshots ended up
55
+ // as half a page of content over a page of black. The visible sections' lowest edge is
56
+ // the only content-driven number here.
57
+ const CONTENT_H = `(() => {
58
+ const secs=[...document.querySelectorAll('main section, .app > section')].filter(s=>s.offsetParent!==null);
59
+ const bottom=Math.max(0,...secs.map(s=>s.getBoundingClientRect().bottom+window.scrollY));
60
+ return Math.max(600, Math.ceil(bottom) + 12);
61
+ })()`;
62
+
63
+ const metrics = (h) => send('Emulation.setDeviceMetricsOverride', { width: W, height: h, deviceScaleFactor: 1, mobile: false });
64
+ const page = () => evl(`(document.querySelector('#nav button.on')?.dataset.page) ?? '(none)'`);
65
+
66
+ async function nav(p) {
67
+ const ok = await evl(`(() => { const b=document.querySelector('#nav button[data-page="${p}"]'); if(!b) return false; b.click(); return true; })()`);
68
+ if (!ok) throw new Error(`no nav button for "${p}"`);
69
+ await sleep(1500);
70
+ const on = await page();
71
+ if (on !== p) throw new Error(`asked for "${p}", landed on "${on}"`);
72
+ }
73
+
74
+ async function shoot(name, { settle = 6000, height = null, cap = 2600 } = {}) {
75
+ // MEASURE FROM A KNOWN VIEWPORT, not from whatever the previous shot left behind.
76
+ // The first cut measured while the page was still laid out at the PREVIOUS shot's
77
+ // height, so the overview's 1788 propagated into markets, kiosk and the explorer --
78
+ // three screenshots padded with dead black, which is the exact defect the measuring
79
+ // was introduced to fix. The reset is tall so a long page is never measured while
80
+ // clipped; CONTENT_H reads the sections' lowest edge, so a tall viewport does not
81
+ // inflate the answer the way scrollHeight did.
82
+ if (height == null) {
83
+ await metrics(cap);
84
+ await sleep(900);
85
+ }
86
+ await sleep(settle);
87
+ const h = Math.min(height ?? await evl(CONTENT_H), cap);
88
+ await metrics(h);
89
+ await sleep(1400); // let the board re-fit to the new viewport
90
+ const shot = await send('Page.captureScreenshot', { format: 'jpeg', quality: QUALITY, captureBeyondViewport: false });
91
+ const buf = Buffer.from(shot.data, 'base64');
92
+ writeFileSync(`docs/images/${name}.jpg`, buf);
93
+ console.log(` ok ${name.padEnd(20)} ${W}x${h} ${String(Math.round(buf.length / 1024)).padStart(4)} KB`);
94
+ return { name, ok: true };
95
+ }
96
+
97
+ await send('Page.enable');
98
+ await send('Runtime.enable');
99
+ await metrics(1000);
100
+ // WHICH NODE. The header remembers the last node picked, per browser, in localStorage; a fresh
101
+ // profile gets the first node in the config. On the operator's box that is the Umbrel over the
102
+ // LAN, whose RPC answers in ~30 s: the 2026-09-14 run photographed an empty Block space board,
103
+ // "no block template yet" and a column of collector errors -- true of that node, and useless as
104
+ // a picture of the product. BLOCKYARD_NODE names the node to shoot against (the local Core here).
105
+ const NODE = process.env.BLOCKYARD_NODE ?? null;
106
+ if (NODE) {
107
+ await send('Page.navigate', { url: `${BASE}/?shots=${Date.now()}` });
108
+ await sleep(3000);
109
+ await evl(`localStorage.setItem('blockyard.node', ${JSON.stringify(NODE)})`);
110
+ }
111
+ await send('Page.navigate', { url: `${BASE}/?shots=${Date.now()}#overview` });
112
+ await sleep(9000);
113
+ const onNode = await evl(`document.getElementById('nodeSel')?.value ?? '(none)'`);
114
+ if (NODE && onNode !== NODE) { console.log(`asked for node "${NODE}", the header shows "${onNode}"`); process.exit(1); }
115
+ console.log(`base ${BASE} node ${onNode}`);
116
+
117
+ const want = new Set(process.argv.slice(2));
118
+ const doing = (n) => !want.size || want.has(n);
119
+ const done = [];
120
+ const fail = (n, e) => { console.log(` !! ${n.padEnd(20)} ${e.message}`); done.push({ name: n, ok: false }); };
121
+
122
+ // ---------------------------------------------------------------- plain pages
123
+ for (const [name, p, settle, height = null] of [
124
+ ['overview', 'overview', 9000],
125
+ ['markets', 'markets', 9000],
126
+ // KIOSK TAKES A FIXED HEIGHT. It is the wall view: its panels are sized to fill the
127
+ // viewport, so measuring it returns whatever viewport it was measured in -- reset to
128
+ // 2600 and the panels honestly report 2600. Every other page here is content-sized and
129
+ // measures fine; this one is told what it is.
130
+ ['kiosk', 'kiosk', 12000, 1000],
131
+ ['explorer-home', 'explorer', 6000],
132
+ ]) {
133
+ if (!doing(name)) continue;
134
+ try { await nav(p); done.push(await shoot(name, { settle, height })); } catch (e) { fail(name, e); }
135
+ }
136
+
137
+ // ------------------------------------------------------- block space, 2 modes
138
+ // The settles are LONG because switching vmode restarts the flight: the first cut gave
139
+ // mode2 16s and photographed a near-empty lattice under a header claiming 5,415
140
+ // transactions -- the stones were still in the air.
141
+ for (const [name, vmode, settle] of [['block-space-mode1', '1', 20000], ['block-space-mode2', '2', 28000]]) {
142
+ if (!doing(name)) continue;
143
+ try {
144
+ await nav('space');
145
+ const set = await evl(`(() => { const b=document.querySelector('[data-vmode="${vmode}"]'); if(!b) return false; b.click(); return true; })()`);
146
+ if (!set) throw new Error(`no [data-vmode="${vmode}"] control`);
147
+ done.push(await shoot(name, { settle, height: 1009 }));
148
+ } catch (e) { fail(name, e); }
149
+ }
150
+
151
+ // ------------------------------------------------------------ explorer detail
152
+ if (doing('explorer-block')) {
153
+ try {
154
+ await nav('explorer'); await sleep(2500);
155
+ const ok = await evl(`(() => { const a=document.querySelector('a.xlink[href*="#explorer/block/"]'); if(!a) return false; a.click(); return true; })()`);
156
+ if (!ok) throw new Error('no block link on the explorer');
157
+ done.push(await shoot('explorer-block', { settle: 5000 }));
158
+ } catch (e) { fail('explorer-block', e); }
159
+ }
160
+ if (doing('explorer-tx')) {
161
+ try {
162
+ // THE SECOND transaction, not the first: the first is the coinbase, whose page says "fee:
163
+ // none" and shows one input -- a poor picture of a transaction page. The second is an
164
+ // ordinary transaction with inputs, a fee and a fee rate.
165
+ const ok = await evl(`(() => { const as=document.querySelectorAll('a[href*="#explorer/tx/"]'); const a=as[1]??as[0]; if(!a) return false; a.click(); return true; })()`);
166
+ if (!ok) throw new Error('no tx link on the block page (is a block page open?)');
167
+ done.push(await shoot('explorer-tx', { settle: 5000 }));
168
+ } catch (e) { fail('explorer-tx', e); }
169
+ }
170
+
171
+ // -------------------------------------------------------------------- settings
172
+ if (doing('settings')) {
173
+ try {
174
+ // Let the BOARD settle before opening the panel: the panel covers a third of the
175
+ // frame and the rest is the board, so a half-drawn court behind it spoils the shot.
176
+ await nav('space');
177
+ await evl(`document.querySelector('[data-vmode="1"]')?.click()`);
178
+ await sleep(20000);
179
+ await evl(`document.getElementById('btnSettings')?.click()`);
180
+ await sleep(1800);
181
+ const open = await evl(`(()=>{const w=document.getElementById('settingsWrap');return w? !w.classList.contains('hidden'):false})()`);
182
+ if (!open) throw new Error('the settings panel did not open');
183
+ done.push(await shoot('settings', { settle: 2500, height: 1000 }));
184
+ await evl(`document.getElementById('btnSettings')?.click()`);
185
+ await sleep(800);
186
+ } catch (e) { fail('settings', e); }
187
+ }
188
+
189
+ // ----------------------------------------------------------------------- about
190
+ // Not in the README; captured so the page can be looked at after a change (the donation address).
191
+ if (doing('about')) {
192
+ try {
193
+ await evl(`document.getElementById('brandAbout')?.click()`);
194
+ await sleep(3000);
195
+ // About is not a nav tab, so page() cannot see it: ask the section itself
196
+ const open = await evl(`(() => { const s=document.querySelector('section.page[data-page="about"]'); return !!s && s.offsetParent !== null; })()`);
197
+ if (!open) throw new Error('the brand button did not open About');
198
+ done.push(await shoot('about', { settle: 2500, height: 900 }));
199
+ } catch (e) { fail('about', e); }
200
+ }
201
+
202
+ // --------------------------------------------------------------------- tetrust
203
+ if (doing('tetrust')) {
204
+ try {
205
+ await evl(`document.getElementById('navDivBtn')?.click()`);
206
+ await sleep(700);
207
+ const ok = await evl(`(() => { const b=document.querySelector('button[data-page="tetrust"]'); if(!b) return false; b.click(); return true; })()`);
208
+ if (!ok) throw new Error('no tetrust button behind Diversions');
209
+ await sleep(3000);
210
+ await metrics(1000);
211
+ // G.running starts false: a key both starts the game and then plays it. Keys go to
212
+ // document, so dispatching to the page is enough -- no element focus needed.
213
+ const key = async (k, code, vk) => {
214
+ for (const type of ['keyDown', 'keyUp']) {
215
+ await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk });
216
+ }
217
+ await sleep(260);
218
+ };
219
+ await key('Enter', 'Enter', 13);
220
+ await sleep(1500);
221
+ for (const [k, c, v] of [['ArrowLeft', 'ArrowLeft', 37], ['ArrowDown', 'ArrowDown', 40], ['ArrowRight', 'ArrowRight', 39],
222
+ ['ArrowUp', 'ArrowUp', 38], ['ArrowDown', 'ArrowDown', 40], ['ArrowLeft', 'ArrowLeft', 37],
223
+ ['ArrowDown', 'ArrowDown', 40], ['ArrowRight', 'ArrowRight', 39], ['ArrowDown', 'ArrowDown', 40]]) {
224
+ await key(k, c, v);
225
+ }
226
+ await sleep(4000);
227
+ done.push(await shoot('tetrust', { settle: 1200 }));
228
+ } catch (e) { fail('tetrust', e); }
229
+ }
230
+
231
+ // -------------------------------------------------- neon: LAST, and it is restored
232
+ if (doing('block-space-neon')) {
233
+ let toggled = false;
234
+ try {
235
+ await nav('space'); await sleep(2000);
236
+ // BACK TO THE CUBE BOARD FIRST. The neon finish is a treatment on the stones, so in
237
+ // Detailed mode (which the mode2 shot leaves behind) it photographs as a flat treemap
238
+ // and the shot says nothing about neon at all.
239
+ await evl(`document.querySelector('[data-vmode="1"]')?.click()`);
240
+ await sleep(3000);
241
+ await evl(`document.getElementById('btnSettings')?.click()`);
242
+ await sleep(1200);
243
+ // space.neon lives in the Block space group; find its control by data-cfg.
244
+ const on = await evl(`(() => {
245
+ const el=document.querySelector('[data-cfg="space.neon"]');
246
+ if(!el) return false;
247
+ if(!el.checked){ el.click(); return 'clicked'; }
248
+ return 'already';
249
+ })()`);
250
+ if (!on) throw new Error('no [data-cfg="space.neon"] control found');
251
+ toggled = on === 'clicked';
252
+ await evl(`document.getElementById('btnSettings')?.click()`);
253
+ await sleep(1000);
254
+ // A LONG settle: switching vmode and toggling neon each restart the choreography, and
255
+ // a shot taken during it shows an empty court with stones still in the air over a
256
+ // header claiming six thousand transactions.
257
+ done.push(await shoot('block-space-neon', { settle: 26000, height: 1009 }));
258
+ } catch (e) { fail('block-space-neon', e); }
259
+ finally {
260
+ if (toggled) {
261
+ await evl(`document.getElementById('btnSettings')?.click()`);
262
+ await sleep(1000);
263
+ const back = await evl(`(() => { const el=document.querySelector('[data-cfg="space.neon"]'); if(!el) return false; if(el.checked){ el.click(); } return !el.checked; })()`);
264
+ await evl(`document.getElementById('btnSettings')?.click()`);
265
+ console.log(` -- space.neon restored to off: ${back} (debounced push takes ~0.5s)`);
266
+ await sleep(2500);
267
+ }
268
+ }
269
+ }
270
+
271
+ console.log(`\n${done.filter((d) => d.ok).length}/${done.length} captured`);
272
+ ws.close();
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end smoke test: boots the real server against the fake node on spare
3
+ # ports and asserts the API contract. `npm test` covers units; this covers the
4
+ # thing units cannot see -- wiring, auth, CSRF, the SSE frame, the RPC guard.
5
+ #
6
+ # Tallies failures and exits non-zero if any check failed; always reaps the servers it
7
+ # started. It deliberately does NOT `set -e`: an abort would skip the cleanup trap and
8
+ # leave a server and a temp dir behind. The cost of that choice, found 2026-09-14: a
9
+ # line reading `OPID is reaped by the single cleanup trap at the top` -- a comment with
10
+ # no `#` -- was executed as a command and printed "OPID: command not found" while the
11
+ # run still reported 109 passed. A harness that cannot fail on its own broken line is
12
+ # worth less than it looks, so `check()` is the only thing that may decide the exit.
13
+ set -uo pipefail
14
+
15
+ PORT="${BLOCKYARD_SMOKE_PORT:-18099}"
16
+ FAKE_PORT="${BLOCKYARD_SMOKE_FAKE:-18461}"
17
+ DIR="$(mktemp -d /tmp/blockyard-smoke.XXXXXX)"
18
+ PW='smoke-test password length ok'
19
+ BASE="http://127.0.0.1:${PORT}"
20
+ PASS=0; FAIL=0
21
+ SRV=""
22
+
23
+ # ONE trap handler for everything, on EXIT *and* on signals. Two separate
24
+ # `trap ... EXIT` lines do not stack in bash -- the second replaces the first, which
25
+ # is how the open-access instance's cleanup silently stopped reaping the main server
26
+ # and left it holding port 18099 for the next run (which then tested that one).
27
+ # SIGTERM is trapped too: `timeout 400 bash scripts/smoke.sh` sends exactly that, and
28
+ # an EXIT trap does not run when the shell dies of an untrapped signal.
29
+ cleanup() {
30
+ for pid in "$SRV" "$OPID"; do
31
+ [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && kill -TERM "$pid" 2>/dev/null
32
+ done
33
+ sleep 0.4
34
+ for pid in "$SRV" "$OPID"; do
35
+ [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
36
+ done
37
+ rm -rf "$DIR" "$OPEN_DIR" 2>/dev/null
38
+ return 0
39
+ }
40
+ trap cleanup EXIT INT TERM
41
+
42
+ ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
43
+ bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
44
+ check(){ [ "$2" = "$3" ] && ok "$1" || bad "$1" "want [$3] got [$2]"; }
45
+
46
+ cd "$(dirname "$0")/.."
47
+
48
+ # Fail fast if the port is already taken. Without this the readiness loop below finds
49
+ # SOMEONE's server answering, and every assertion then runs against a leftover
50
+ # instance from an earlier run -- which is how a script that "passed" was really
51
+ # testing a server whose login bucket was already drained.
52
+ port_free() { ! ss -ltn 2>/dev/null | grep -q ":$1 "; }
53
+ for p in "$PORT" "$FAKE_PORT" "${BLOCKYARD_SMOKE_OPEN_PORT:-18199}" "$((FAKE_PORT + 1))"; do
54
+ port_free "$p" || { echo "FAIL port $p is already in use -- refusing to test someone else's server."; echo " look for a leftover: pgrep -af 'server/main.js'"; exit 1; }
55
+ done
56
+
57
+ echo "== booting server on :${PORT} (fake node :${FAKE_PORT}) =="
58
+ # BLOCKYARD_CONFIG=none: do not read config/local.json. It holds this box's deployment
59
+ # choices -- including which addresses to bind -- and inheriting them made this script
60
+ # curl 127.0.0.1 against a server listening elsewhere (54 failures, 2 passes).
61
+ # BLOCKYARD_BIND: pin it explicitly anyway, so a future default cannot repeat that.
62
+ # BLOCKYARD_AUTH=1: accounts are OFF by default now, and this script's bulk is the
63
+ # signed-in contract (sessions, CSRF, per-user audit, RBAC). The open posture gets its
64
+ # own instance further down, on its own port, so both are asserted rather than one
65
+ # replacing the other.
66
+ BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 BLOCKYARD_AUTH=1 \
67
+ BLOCKYARD_DATA="$DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$PORT" BLOCKYARD_ADMIN_PASSWORD="$PW" \
68
+ BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$FAKE_PORT" node server/main.js >"$DIR/server.log" 2>&1 &
69
+ SRV=$!
70
+
71
+ READY=0
72
+ for _ in $(seq 1 60); do
73
+ curl -fs --max-time 1 "$BASE/api/health" >/dev/null 2>&1 && { READY=1; break; }
74
+ kill -0 "$SRV" 2>/dev/null || { echo "server died:"; cat "$DIR/server.log"; exit 1; }
75
+ sleep 0.3
76
+ done
77
+ # Without this, an unreachable port silently becomes 54 "FAIL" lines about passwords
78
+ # and audit entries -- a wall of noise that hides the one fact: nothing answered.
79
+ if [ "$READY" != "1" ]; then
80
+ echo "FAIL server never answered at $BASE -- not a contract failure, a wiring one."
81
+ echo " The process is alive, so most likely it is not listening on loopback."
82
+ echo " This script sets BLOCKYARD_BIND=127.0.0.1 and BLOCKYARD_CONFIG=none; if that"
83
+ echo " changed, server.hosts from config/local.json is pointing elsewhere."
84
+ echo " --- server log ---"; cat "$DIR/server.log"; exit 1
85
+ fi
86
+
87
+ echo "== health and the public surface =="
88
+ H=$(curl -s --max-time 5 "$BASE/api/health")
89
+ check "health ok:true" "$(echo "$H" | grep -c '"ok":true')" "1"
90
+ check "health reports a node online" "$(echo "$H" | grep -c '"online":true')" "1"
91
+ check "unauthenticated /api/state is 401" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/state")" "401"
92
+ check "unauthenticated /api/peers is 401" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/peers")" "401"
93
+ check "index.html served" "$([ "$(curl -s --max-time 5 "$BASE/" | grep -c 'data-sync-hero')" -ge 1 ] && echo 1 || echo 0)" "1"
94
+ # the endpoint is called from /js/login.js (CSP forbids inline scripts), so
95
+ # assert on what login.html actually contains plus that its script is fetchable
96
+ check "login.html served at /login" "$([ "$(curl -s --max-time 5 "$BASE/login" | grep -c 'name=\"password\"')" -ge 1 ] && echo 1 || echo 0)" "1"
97
+ check "login.js is fetchable" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/js/login.js")" "200"
98
+ check "login.js calls the endpoint" "$(curl -s "$BASE/js/login.js" | grep -c '/api/login')" "1"
99
+ check "CSP header present" "$(curl -s -D- -o /dev/null "$BASE/" | grep -c "Content-Security-Policy")" "1"
100
+ check "no server version leak" "$(curl -s -D- -o /dev/null "$BASE/" | grep -ci 'x-powered-by')" "0"
101
+
102
+ echo "== path containment =="
103
+ check "dotdot to /etc/passwd blocked" "$(curl -s --path-as-is -o /dev/null -w '%{http_code}' "$BASE/../../etc/passwd")" "404"
104
+ check "encoded traversal blocked" "$(curl -s --path-as-is -o /dev/null -w '%{http_code}' "$BASE/%2e%2e%2f%2e%2e%2fetc/passwd")" "404"
105
+ check "absolute path blocked" "$(curl -s --path-as-is -o /dev/null -w '%{http_code}' "$BASE//etc/passwd")" "404"
106
+ check "a 404 page answers 404, not 200" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/no-such-page")" "404"
107
+ check "no body leak on blocked traversal" "$(curl -s --path-as-is "$BASE/../../etc/passwd" | grep -c 'root:')" "0"
108
+
109
+ echo "== authentication =="
110
+ CODE=$(curl -s -o "$DIR/login.json" -w '%{http_code}' -c "$DIR/ck" -X POST "$BASE/api/login" \
111
+ -H 'content-type: application/json' -d "{\"username\":\"admin\",\"password\":\"$PW\"}")
112
+ check "correct password accepted" "$CODE" "200"
113
+ check "session cookie is HttpOnly" "$(grep -c '#HttpOnly_' "$DIR/ck")" "1"
114
+ check "csrf cookie is readable by design" "$(grep -c 'blockyard_csrf' "$DIR/ck")" "1"
115
+ CSRF=$(awk '/blockyard_csrf/{print $7}' "$DIR/ck")
116
+ [ -n "$CSRF" ] && ok "csrf token present" || bad "csrf token present" "empty"
117
+ check "wrong password rejected" "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/login" -H 'content-type: application/json' -d '{"username":"admin","password":"not the password at all"}')" "401"
118
+ check "unknown user gives the same message" "$(curl -s -X POST "$BASE/api/login" -H 'content-type: application/json' -d '{"username":"nosuchuser","password":"whatever whatever"}' | grep -c 'invalid username or password')" "1"
119
+
120
+ # The per-address login throttle. LoginGuard answers "this username keeps failing";
121
+ # the per-user token bucket cannot apply to a request with no user. Each attempt costs
122
+ # a scrypt KDF on the request thread, so an unthrottled endpoint is a cheap CPU
123
+ # denial of service wearing a login form.
124
+ THROTTLED=0
125
+ for i in $(seq 1 22); do
126
+ C=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/login" -H 'content-type: application/json' -d "{\"username\":\"grinder\",\"password\":\"wrong passphrase $i\"}")
127
+ [ "$C" = "429" ] && THROTTLED=$((THROTTLED+1))
128
+ done
129
+ [ "$THROTTLED" -ge 8 ] && ok "login is throttled per address ($THROTTLED of 22 refused)" || bad "login is throttled per address" "only $THROTTLED refused"
130
+ check "the throttle says when to come back" "$(curl -s -X POST "$BASE/api/login" -H 'content-type: application/json' -d '{"username":"grinder","password":"wrong passphrase again"}' | grep -c 'retry in')" "1"
131
+ check "the throttle is a distinct code from the lockout" "$(curl -s -X POST "$BASE/api/login" -H 'content-type: application/json' -d '{"username":"grinder","password":"wrong passphrase again"}' | grep -c '"code":"throttled"')" "1"
132
+
133
+ # Some answers only exist after a few poll cycles: the ETA needs two height
134
+ # samples, and the nettotals-zero flag only fires on the second pass (it has to
135
+ # see the counter stay at zero rather than merely be zero the first time).
136
+ sleep 9
137
+
138
+ echo "== the sync bar's data contract (the point of this app) =="
139
+ S=$(curl -s -b "$DIR/ck" "$BASE/api/sync")
140
+ for f in state height headers behind pct verificationProgress etaBasis caveats; do
141
+ check "sync.${f} present" "$(echo "$S" | grep -c "\"$f\"")" "1"
142
+ done
143
+ PCT=$(echo "$S" | tr ',' '\n' | grep '"pct":' | head -1 | tr -dc '0-9.')
144
+ awk -v p="$PCT" 'BEGIN{exit !(p>=0 && p<=100)}' && ok "sync.pct within 0..100 ($PCT)" || bad "sync.pct within 0..100" "$PCT"
145
+ check "fake node reports IBD" "$(echo "$S" | grep -c '"state":"ibd"')" "1"
146
+ # Early on there must be NO ETA: the node downloads in bursts, and a rate from a
147
+ # 9-second window produced a 23-day ETA against a node that finished in hours.
148
+ # Silence plus an explanation is the required behaviour; a confident wrong
149
+ # number is the bug.
150
+ check "no ETA before the window has span" "$(echo "$S" | grep -c '"eta":null')" "1"
151
+ check "the absence of an ETA is explained" "$(echo "$S" | tr ',' '\n' | grep -c 'caveats')" "1"
152
+ echo " .. waiting for a rate window with real span (up to 75s)"
153
+ ETA=""
154
+ for _ in $(seq 1 15); do
155
+ ETA=$(curl -s -b "$DIR/ck" "$BASE/api/sync" | tr ',' '\n' | grep -c '"eta":"[0-9][0-9]:')
156
+ [ "$ETA" -ge 1 ] && break
157
+ sleep 5
158
+ done
159
+ check "an ETA appears once a window has >=60s of span" "$([ "$ETA" -ge 1 ] && echo 1 || echo 0)" "1"
160
+ check "the ETA names the window it came from" "$(curl -s -b "$DIR/ck" "$BASE/api/sync" | grep -c 'measured .* blocks/s over')" "1"
161
+
162
+ echo "== the RPC lane's manners =="
163
+ FULL=$(curl -s -b "$DIR/ck" "$BASE/api/state" | wc -c)
164
+ LEAN=$(curl -s -b "$DIR/ck" "$BASE/api/state?series=none" | wc -c)
165
+ [ "$LEAN" -lt "$FULL" ] && ok "series=none is smaller ($LEAN < $FULL bytes)" || bad "series=none smaller" "$LEAN vs $FULL"
166
+ check "poll cadence reported" "$(curl -s -b "$DIR/ck" "$BASE/api/telemetry" | grep -c 'busyMsPerSec')" "1"
167
+ check "stale-drop counter exposed" "$(curl -s -b "$DIR/ck" "$BASE/api/telemetry" | grep -c 'staleDropped')" "1"
168
+
169
+ echo "== the RPC allowlist (default deny) =="
170
+ DENY=$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"sendrawtransaction","params":["00"]}')
171
+ check "broadcast refused from the UI" "$(echo "$DENY" | grep -c rpc_denied)" "1"
172
+ check "getnewaddress refused despite its prefix" "$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getnewaddress","params":[]}' | grep -c rpc_denied)" "1"
173
+ check "rescanblockchain refused (monopolises the server)" "$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"rescanblockchain","params":[]}' | grep -c rpc_denied)" "1"
174
+ check "a read method is allowed" "$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getconnectioncount","params":[]}' | grep -c '"ok":true')" "1"
175
+
176
+ echo "== CSRF: the cookie alone must not be enough =="
177
+ NOHDR=$(curl -s -b "$DIR/ck" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getconnectioncount","params":[]}')
178
+ check "cookie-only POST rejected" "$(echo "$NOHDR" | grep -c '"kind":"csrf"')" "1"
179
+ check "header + cookie accepted" "$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getconnectioncount","params":[]}' | grep -c '"ok":true')" "1"
180
+ check "wrong token rejected" "$(curl -s -b "$DIR/ck" -H 'X-CSRF-Token: not-the-token' -X POST "$BASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getconnectioncount","params":[]}' | grep -c '"kind":"csrf"')" "1"
181
+
182
+ echo "== node write actions are off unless enabled =="
183
+ A=$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/action" -H 'content-type: application/json' -d '{"action":"savemempool","confirm":"savemempool"}')
184
+ check "disabled by default" "$(echo "$A" | grep -c action_denied)" "1"
185
+
186
+ echo "== the live frame actually arrives over SSE =="
187
+ FRAMES=$(curl -s -N -b "$DIR/ck" --max-time 8 "$BASE/api/stream" 2>/dev/null | head -c 400000 | grep -c 'event: snapshot')
188
+ [ "$FRAMES" -ge 1 ] && ok "SSE delivered a snapshot frame" || bad "SSE delivered a snapshot frame" "0 frames in 6s"
189
+ check "SSE requires authentication" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$BASE/api/stream")" "401"
190
+ # An unknown node on the stream used to be ACCEPTED: the hub stored nodeId and then
191
+ # filtered out every frame, so a tab left pointing at a removed node showed a "live"
192
+ # badge over frozen numbers forever, with nothing in any log. That is the exact shape
193
+ # of the "nothing refreshes" report -- so it must fail where it can be seen.
194
+ UNKNOWN=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 -b "$DIR/ck" "$BASE/api/stream?node=not-a-node")
195
+ check "SSE refuses an unknown node instead of streaming silence" "$UNKNOWN" "404"
196
+ check "the refusal says which nodes exist" "$(curl -s --max-time 3 -b "$DIR/ck" "$BASE/api/stream?node=not-a-node" | grep -c unknown_node)" "1"
197
+ # And the known node still streams, so the refusal is not a blanket block.
198
+ NFRAMES=$(curl -s -N -b "$DIR/ck" --max-time 8 "$BASE/api/stream?node=main" 2>/dev/null | head -c 400000 | grep -c 'event: snapshot')
199
+ [ "$NFRAMES" -ge 1 ] && ok "SSE still delivers for a known node" || bad "SSE still delivers for a known node" "0 frames"
200
+ # The same unknown node on the read model must also say 404, not serve the primary.
201
+ check "/api/state refuses an unknown node" "$(curl -s -o /dev/null -w '%{http_code}' -b "$DIR/ck" "$BASE/api/state?node=not-a-node")" "404"
202
+
203
+ echo "== provenance is stated, not implied =="
204
+ CFG=$(curl -s -b "$DIR/ck" "$BASE/api/config")
205
+ # Mode-aware on purpose: which sources are in effect is a fact about the deployment, so
206
+ # the check reads the posture from the payload and asserts the table matches it. A fixed
207
+ # string here passed while the code was right and failed while the code was right --
208
+ # which is what a check that remembers instead of observes does.
209
+ LOG_ON=$(echo "$CFG" | grep -c '"enabled":true')
210
+ if [ "$LOG_ON" != "0" ]; then
211
+ check "log mode: bandwidth source is the node log" "$(echo "$CFG" | grep -c 'node log')" "1"
212
+ check "log mode: peer identity names the log" "$(echo "$CFG" | grep -c 'node log (relay legs')" "1"
213
+ else
214
+ check "RPC mode: bandwidth source is getnettotals" "$(echo "$CFG" | grep -c 'getnettotals delta rate')" "1"
215
+ check "RPC mode: peer identity names getpeerinfo" "$(echo "$CFG" | grep -c 'getpeerinfo')" "1"
216
+ fi
217
+ check "quality flags surfaced" "$(curl -s -b "$DIR/ck" "$BASE/api/state?series=none" | grep -c 'getnettotals reports 0')" "1"
218
+
219
+ echo "== user management =="
220
+ check "viewer cannot reach /api/users" "$(curl -s -o /dev/null -w '%{http_code}' -b "$DIR/ck" "$BASE/api/users")" "200"
221
+ GEN=$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/users/generate" -H 'content-type: application/json' -d '{"username":"smoketester","role":"viewer"}')
222
+ check "admin can create a user" "$(echo "$GEN" | grep -c '"ok":true')" "1"
223
+ check "generated password shown exactly once" "$(echo "$GEN" | grep -c 'shown once')" "1"
224
+ check "weak password refused" "$(curl -s -b "$DIR/ck" -H "X-CSRF-Token: $CSRF" -X POST "$BASE/api/users" -H 'content-type: application/json' -d '{"username":"shorty","password":"abcd","role":"viewer"}' | grep -c 'at least')" "1"
225
+ check "audit recorded the admin action" "$(curl -s -b "$DIR/ck" "$BASE/api/audit?limit=10" | grep -c user-create)" "1"
226
+
227
+ echo "== the served page knows which build it is =="
228
+ # A tab quietly running yesterday's app.js is indistinguishable from one running
229
+ # today's, and every "did the fix land?" on 2026-09-08 cost fifteen minutes for want
230
+ # of a stamp. The page carries the build id, its assets are versioned by it, and
231
+ # /api/build tells the page whether it is still current.
232
+ BUILD=$(curl -s "$BASE/api/build" | sed -n 's/.*"build":"\([^"]*\)".*/\1/p')
233
+ [ -n "$BUILD" ] && ok "/api/build reports a build id ($BUILD)" || bad "/api/build reports a build id" "empty"
234
+ PAGE=$(curl -s "$BASE/")
235
+ check "the page carries that build id" "$(echo "$PAGE" | grep -c "data-blockyard-build=\"$BUILD\"")" "1"
236
+ check "the entry module is cache-busted by build" "$(echo "$PAGE" | grep -c "js/app.js?v=$BUILD")" "1"
237
+ check "the stylesheet is cache-busted too" "$(echo "$PAGE" | grep -c "css/app.css?v=$BUILD")" "1"
238
+ check "a stale tab can be told it is stale" "$(curl -s "$BASE/api/build?build=0.0.0-deadbeef" | grep -c '"matchesClient":false')" "1"
239
+ check "and a current one is confirmed" "$(curl -s "$BASE/api/build?build=$BUILD" | grep -c '"matchesClient":true')" "1"
240
+
241
+ CSP=$(curl -s -D - -o /dev/null "$BASE/" | grep -i '^content-security-policy:' | tr -d '\r')
242
+ check "CSP offers a per-response script nonce" "$(echo "$CSP" | grep -c "nonce-")" "1"
243
+ check "CSP carries no inline-style allowance" "$(echo "$CSP" | grep -c 'unsafe-inline')" "0"
244
+ check "no served HTML carries a style attribute" "$(echo "$PAGE" | grep -cE '<[a-zA-Z][^>]* style="')" "0"
245
+ check "plaintext does not claim HSTS" "$(curl -s -D - -o /dev/null "$BASE/" | grep -ci 'strict-transport-security')" "0"
246
+
247
+ # The same nonce must not be reused across responses: a constant nonce is an open
248
+ # door with a documentation comment attached.
249
+ N1=$(curl -s -D - -o /dev/null "$BASE/login" | grep -io 'nonce-[^;]*' | head -1)
250
+ N2=$(curl -s -D - -o /dev/null "$BASE/login" | grep -io 'nonce-[^;]*' | head -1)
251
+ [ -n "$N1" ] && [ "$N1" != "$N2" ] && ok "the nonce differs between responses" || bad "the nonce differs between responses" "$N1 vs $N2"
252
+
253
+ echo "== drill-down: one block, one transaction =="
254
+ # "Which transaction?" used to mean typing an RPC into the console. The routes ask
255
+ # verbosity 1 + getblockstats, never verbosity 2, which this node answers with 11 MB
256
+ # of hex per block.
257
+ TIP=$(curl -s -b "$DIR/ck" "$BASE/api/sync" | sed -n 's/.*"height":\([0-9]*\).*/\1/p' | head -1)
258
+ BLK=$(curl -s -b "$DIR/ck" "$BASE/api/block?height=$TIP")
259
+ check "a block resolves by height" "$(echo "$BLK" | grep -c '"ok":true')" "1"
260
+ check "the header carries its hash" "$(echo "$BLK" | grep -c '"hash":"[0-9a-f]\{64\}"')" "1"
261
+ check "stats come from getblockstats, not from fetching the block" "$(echo "$BLK" | grep -c '"totalfee"')" "1"
262
+ check "txids are a page, not the whole block" "$(echo "$BLK" | grep -c '"txidsShown"')" "1"
263
+ check "the route says why it stopped at verbosity 1" "$(echo "$BLK" | grep -c 'verbosity=2')" "1"
264
+ check "a malformed hash is our error, not the node's" "$(curl -s -o /dev/null -w '%{http_code}' -b "$DIR/ck" "$BASE/api/block?hash=nope")" "400"
265
+ check "a block the node does not have is passed through" "$(curl -s -b "$DIR/ck" "$BASE/api/block?hash=$(printf '0%.0s' $(seq 64))" | grep -c '"ok":false')" "1"
266
+ TXID=$(echo "$BLK" | sed -n 's/.*"txids":\["\([0-9a-f]*\)".*/\1/p')
267
+ BHASH=$(echo "$BLK" | sed -n 's/.*"hash":"\([0-9a-f]*\)".*/\1/p')
268
+ TX=$(curl -s -b "$DIR/ck" "$BASE/api/tx?txid=$TXID&block=$BHASH")
269
+ check "a transaction decodes" "$(echo "$TX" | grep -c '"ok":true')" "1"
270
+ check "its outputs are listed" "$(echo "$TX" | grep -c '"outputsTotal"')" "1"
271
+ check "the fee it cannot have is named, not guessed" "$(echo "$TX" | grep -c 'fee / feerate')" "1"
272
+ check "a malformed txid is refused" "$(curl -s -o /dev/null -w '%{http_code}' -b "$DIR/ck" "$BASE/api/tx?txid=abc")" "400"
273
+ check "the mempool says it is a poll, not a stream" "$(curl -s -b "$DIR/ck" "$BASE/api/mempool" | grep -c '"kind":"poll"')" "1"
274
+
275
+ echo "== the audit trail has a size, and the breaker has a name =="
276
+ AUD=$(curl -s -b "$DIR/ck" "$BASE/api/audit?limit=5")
277
+ check "audit reports its own size" "$(echo "$AUD" | grep -c '"maxBytes"')" "1"
278
+ check "audit rotation is configured, not infinite" "$(echo "$AUD" | grep -c '"keep"')" "1"
279
+ TEL=$(curl -s -b "$DIR/ck" "$BASE/api/telemetry")
280
+ check "telemetry reports the breaker state" "$(echo "$TEL" | grep -c '"breaker"')" "1"
281
+ check "the breaker names its threshold" "$(echo "$TEL" | grep -c '"threshold":3')" "1"
282
+ check "telemetry reports the audit log too" "$(echo "$TEL" | grep -c '"rotationError"')" "1"
283
+ check "the peer identity table has data to draw" "$(curl -s -b "$DIR/ck" "$BASE/api/peers" | grep -c '"identity"')" "1"
284
+
285
+ echo "== open access: the default posture, no sign-in =="
286
+ # Accounts are OFF by default. A second instance on its own port asserts what that
287
+ # grants (reads, the read-only RPC console, the stream) and, more importantly, what it
288
+ # does NOT: user admin, the audit trail, node writes. Keeping this a separate boot is
289
+ # the point -- the signed-in checks above must not be quietly replaced by open ones.
290
+ OPEN_PORT="${BLOCKYARD_SMOKE_OPEN_PORT:-18199}"
291
+ OPEN_DIR="$(mktemp -d /tmp/blockyard-open.XXXXXX)"
292
+ OPID=""
293
+ # OPID is reaped by the single cleanup trap at the top
294
+ BLOCKYARD_CONFIG=none BLOCKYARD_BIND=127.0.0.1 \
295
+ BLOCKYARD_DATA="$OPEN_DIR" BLOCKYARD_FAKE_NODE=1 BLOCKYARD_PORT="$OPEN_PORT" \
296
+ BLOCKYARD_LOG_LEVEL=warn FAKE_PORT="$((FAKE_PORT + 1))" node server/main.js >"$OPEN_DIR/server.log" 2>&1 &
297
+ OPID=$!
298
+ OBASE="http://127.0.0.1:$OPEN_PORT"
299
+ for _ in $(seq 1 60); do curl -fs --max-time 1 "$OBASE/api/health" >/dev/null 2>&1 && break; sleep 0.3; done
300
+
301
+ check "no cookie needed for /api/state" "$(curl -s -o /dev/null -w '%{http_code}' "$OBASE/api/state?series=none")" "200"
302
+ check "the anonymous identity is viewer" "$(curl -s "$OBASE/api/state?series=none" | grep -c '"username":"anonymous"')" "1"
303
+ check "health says auth is not required" "$(curl -s "$OBASE/api/health" | grep -c '"authRequired":false')" "1"
304
+ check "/api/me reports the posture in words" "$(curl -s "$OBASE/api/me" | grep -c '"accounts":false')" "1"
305
+ check "config exposes the access mode" "$(curl -s "$OBASE/api/config" | grep -c '"mode":"open"')" "1"
306
+ # >= 1, not == 1: however many frames arrive in six seconds is fine, the point is that
307
+ # an anonymous socket gets frames at all.
308
+ SNAP=$(curl -s -N --max-time 6 "$OBASE/api/stream" 2>/dev/null | head -c 400000 | grep -c 'event: snapshot')
309
+ [ "$SNAP" -ge 1 ] && ok "the stream works without a session ($SNAP frames)" || bad "the stream works without a session" "0 frames in 6s"
310
+ check "/login redirects home instead of a dead form" "$(curl -s -o /dev/null -w '%{http_code}' "$OBASE/login")" "302"
311
+ check "login refuses rather than guessing passwords" "$(curl -s -X POST "$OBASE/api/login" -H 'content-type: application/json' -d '{"username":"admin","password":"***"}' | grep -c accounts_disabled)" "1"
312
+ check "no session cookie is minted" "$(curl -s -D - -o /dev/null "$OBASE/api/state?series=none" | grep -ci 'set-cookie')" "0"
313
+ check "user admin stays closed" "$(curl -s -o /dev/null -w '%{http_code}' "$OBASE/api/users")" "403"
314
+ check "the refusal names the switch" "$(curl -s "$OBASE/api/users" | grep -c 'accounts are disabled')" "1"
315
+ check "creating accounts is refused" "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$OBASE/api/users" -H 'content-type: application/json' -d '{"username":"planted","password":"***","role":"admin"}')" "403"
316
+ check "the read-only RPC console works with no CSRF token" "$(curl -s -X POST "$OBASE/api/rpc" -H 'content-type: application/json' -d '{"method":"getconnectioncount","params":[]}' | grep -c '"ok":true')" "1"
317
+ check "open does not mean unguarded: writes still denied" "$(curl -s -X POST "$OBASE/api/rpc" -H 'content-type: application/json' -d '{"method":"sendrawtransaction","params":["00"]}' | grep -c rpc_denied)" "1"
318
+ check "node writes are refused with no identity" "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$OBASE/api/action" -H 'content-type: application/json' -d '{"action":"savemempool","confirm":"savemempool"}')" "403"
319
+ # "at least one", not "==1": the switch is named in the boot warning AND the banner,
320
+ # and an exact-count assertion here would just track how many places mention it.
321
+ N=$(grep -c 'NO SIGN-IN' "$OPEN_DIR/server.log"); [ "$N" -ge 1 ] && ok "the boot announces the posture" || bad "the boot announces the posture" "0 lines"
322
+ N=$(grep -c 'BLOCKYARD_AUTH=1' "$OPEN_DIR/server.log"); [ "$N" -ge 1 ] && ok "and names the switch that closes it" || bad "and names the switch that closes it" "0 lines"
323
+
324
+ echo
325
+ echo "passed: $PASS failed: $FAIL"
326
+ [ "$FAIL" -eq 0 ] || { echo; echo "server log tail:"; tail -20 "$DIR/server.log"; exit 1; }
327
+ echo "smoke: all green"