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.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- 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();
|
package/scripts/smoke.sh
ADDED
|
@@ -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"
|