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,212 @@
1
+ // Static file serving with a strict containment rule.
2
+ //
3
+ // The containment check resolves the symlink and compares against the real root,
4
+ // not the requested string: `/assets/../../../etc/passwd` and a symlink planted
5
+ // inside public/ are different attacks and only the realpath test stops both.
6
+ import fsp from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+
10
+ const MIME = {
11
+ '.html': 'text/html; charset=utf-8',
12
+ '.js': 'text/javascript; charset=utf-8',
13
+ '.mjs': 'text/javascript; charset=utf-8',
14
+ '.css': 'text/css; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.webp': 'image/webp',
20
+ '.ico': 'image/x-icon',
21
+ '.woff2': 'font/woff2',
22
+ '.map': 'application/json; charset=utf-8',
23
+ '.txt': 'text/plain; charset=utf-8',
24
+ };
25
+
26
+ // A page that can read node state must not be embeddable anywhere, and must not
27
+ // be able to reach off-origin scripts. Everything here is same-origin.
28
+ //
29
+ // style-src: no `'unsafe-inline'` anywhere, and none for `style-src-attr` either.
30
+ // That allowance existed because this UI drew bars and progress widths with
31
+ // data-driven `style="width:42%"` attributes injected through innerHTML, which
32
+ // `style-src 'self'` refuses. The correct fix was never to widen the policy: the
33
+ // markup now carries `data-w`/`data-left` and the width is written through the
34
+ // CSSOM (app.js `applyDataSizes`), which CSP permits, and every static `style=`
35
+ // became a class. See docs/DEFECTS.md and test/csp.test.js — a test now fails if a
36
+ // `style="` comes back into the shipped HTML or JS.
37
+ export const CSP_BASE = [
38
+ "default-src 'self'",
39
+ "script-src 'self'", // + ' nonce-…' per response, see securityHeaders()
40
+ "style-src 'self'",
41
+ "img-src 'self' data:",
42
+ "font-src 'self'",
43
+ "connect-src 'self'",
44
+ // SSE is same-origin EventSource; no third-party anything.
45
+ "frame-ancestors 'none'",
46
+ "base-uri 'self'",
47
+ "form-action 'self'",
48
+ "object-src 'none'",
49
+ ];
50
+
51
+ export const SECURITY_HEADERS = {
52
+ 'X-Content-Type-Options': 'nosniff',
53
+ 'X-Frame-Options': 'DENY',
54
+ 'Referrer-Policy': 'no-referrer',
55
+ 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=(), usb=()',
56
+ 'Content-Security-Policy': CSP_BASE.join('; '),
57
+ };
58
+
59
+ /**
60
+ * Headers for one response.
61
+ *
62
+ * `nonce` — a per-response script nonce, so an inline `<script>` in a page is
63
+ * possible at all. Until now `script-src 'self'` meant that adding one would fail
64
+ * in the browser with no server-side trace, which is the same "silently broken"
65
+ * shape as the module-level ReferenceError that blanked this UI for a day. Pages
66
+ * are rewritten through `renderHtml()` so a placeholder can receive the nonce.
67
+ *
68
+ * `tls` — HSTS is only meaningful, and only safe, over TLS. Sending it over plain
69
+ * HTTP instructs a browser to pin an upgrade path that the server cannot serve.
70
+ */
71
+ export function securityHeaders({ nonce = null, tls = false, hstsMs = 0 } = {}) {
72
+ const csp = nonce
73
+ ? [...CSP_BASE.slice(0, 1), `script-src 'self' 'nonce-${nonce}'`, ...CSP_BASE.slice(2)]
74
+ : CSP_BASE;
75
+ const h = { ...SECURITY_HEADERS, 'Content-Security-Policy': csp.join('; ') };
76
+ if (tls && hstsMs > 0) h['Strict-Transport-Security'] = `max-age=${Math.floor(hstsMs / 1000)}`;
77
+ return h;
78
+ }
79
+
80
+ /** One usable nonce per response. 16 bytes is what the CSP spec's example uses. */
81
+ export function newNonce() {
82
+ return crypto.randomBytes(16).toString('base64');
83
+ }
84
+
85
+ /**
86
+ * Rewrite an HTML document: nonce into the placeholders, build id into the asset
87
+ * URLs and the `data-blockyard-build` attribute.
88
+ *
89
+ * The cache-busting is the point of the build stamp, not a side effect: a tab that
90
+ * quietly runs yesterday's app.js was indistinguishable from one running today's,
91
+ * and every "did my fix land?" cost fifteen minutes for want of it (DEFECTS). The
92
+ * footer now compares the build this page loaded against what the server says is
93
+ * current, and says so in words when they differ.
94
+ */
95
+ export function renderHtml(src, { nonce, build }) {
96
+ return String(src)
97
+ .replace(/%BLOCKYARD_NONCE%/g, nonce ?? '')
98
+ .replace(/%BLOCKYARD_BUILD%/g, build ?? 'dev')
99
+ // Only assets get a version. Pages are `Cache-Control: no-cache` already, and
100
+ // stamping `href="/"` would put `?v=` on the dashboard link, where the query
101
+ // string is reserved for `?node=` and `?range=` -- a cosmetic rewrite that
102
+ // touches the app's own parameter space is not cosmetic.
103
+ .replace(/\b(href|src)="(\/[^"?]*\/[^"?]*\.(?:js|css|svg|png|jpe?g|webp|ico|woff2|map))"/g,
104
+ (m, attr, url) => `${attr}="${url}?v=${build}"`);
105
+ }
106
+
107
+ /**
108
+ * A build id: version + a digest over the asset file sizes and mtimes.
109
+ *
110
+ * Deliberately not a git SHA. `data/` and this tree are deployed by copy on this
111
+ * box, and an id derived from files answers the actual question — "is the code in
112
+ * this tab the code on disk?" — where a SHA would be a lie about a working tree
113
+ * that was never committed at the moment of boot.
114
+ */
115
+ export async function computeBuildId(root, version = '0.0.0') {
116
+ const h = crypto.createHash('sha256');
117
+ const walk = async (dir, prefix = '') => {
118
+ for (const ent of await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])) {
119
+ const p = path.join(dir, ent.name);
120
+ if (ent.isDirectory()) { await walk(p, `${prefix}${ent.name}/`); continue; }
121
+ const st = await fsp.stat(p).catch(() => null);
122
+ if (!st) continue;
123
+ h.update(`${prefix}${ent.name}:${st.size}:${Math.floor(st.mtimeMs)}`);
124
+ }
125
+ };
126
+ await walk(root);
127
+ return `${version}-${h.digest('hex').slice(0, 10)}`;
128
+ }
129
+
130
+ export class StaticFiles {
131
+ constructor(root, { version = '0.0.0', tls = false, hstsMs = 0 } = {}) {
132
+ this.root = path.resolve(root);
133
+ this.version = version;
134
+ this.tls = tls;
135
+ this.hstsMs = hstsMs;
136
+ this._build = null;
137
+ this._buildAt = 0;
138
+ }
139
+
140
+ /** Cached for 2 s: a page load resolves index.html, then its assets, in a burst. */
141
+ async buildId() {
142
+ if (!this._build || Date.now() - this._buildAt > 2000) {
143
+ this._build = await computeBuildId(this.root, this.version);
144
+ this._buildAt = Date.now();
145
+ }
146
+ return this._build;
147
+ }
148
+
149
+ async resolve(urlPath) {
150
+ let decoded;
151
+ try { decoded = decodeURIComponent(urlPath.split('?')[0]); } catch { return { error: 400 }; }
152
+ if (decoded.includes('\0')) return { error: 400 };
153
+ if (decoded === '/') decoded = '/index.html';
154
+ if (decoded === '/login') decoded = '/login.html';
155
+ const candidate = path.join(this.root, path.normalize(decoded));
156
+ if (candidate !== this.root && !candidate.startsWith(this.root + path.sep)) return { error: 403 };
157
+ let st;
158
+ try {
159
+ st = await fsp.stat(candidate);
160
+ } catch {
161
+ return { error: 404 };
162
+ }
163
+ if (st.isDirectory()) return { error: 404 };
164
+ // The decisive check: the resolved target must still be inside the root.
165
+ let real;
166
+ try { real = await fsp.realpath(candidate); } catch { return { error: 404 }; }
167
+ const realRoot = await fsp.realpath(this.root).catch(() => this.root);
168
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return { error: 403 };
169
+ return { file: real, stat: st, ext: path.extname(candidate).toLowerCase() };
170
+ }
171
+
172
+ async serve(req, res, urlPath, { send = true, status = 200 } = {}) {
173
+ const r = await this.resolve(urlPath);
174
+ if (r.error) return r;
175
+ const etag = `W/"${r.stat.size.toString(16)}-${Math.floor(r.stat.mtimeMs).toString(16)}"`;
176
+ // HTML is rewritten per response (nonce + build id), so its body is not the
177
+ // file on disk: no ETag conditional, and Content-Length must be recomputed.
178
+ const isHtml = r.ext === '.html';
179
+ const nonce = isHtml ? newNonce() : null;
180
+ const headers = {
181
+ ...securityHeaders({ nonce, tls: this.tls, hstsMs: this.hstsMs }),
182
+ 'Content-Type': MIME[r.ext] ?? 'application/octet-stream',
183
+ 'Cache-Control': 'no-cache',
184
+ ETag: etag,
185
+ 'Last-Modified': new Date(r.stat.mtimeMs).toUTCString(),
186
+ };
187
+ if (!isHtml && req.headers['if-none-match'] === etag) {
188
+ res.writeHead(304, { ETag: etag, 'Cache-Control': headers['Cache-Control'], ...securityHeaders({ tls: this.tls, hstsMs: this.hstsMs }) });
189
+ res.end();
190
+ return { status: 304, file: r.file };
191
+ }
192
+ if (isHtml) {
193
+ const raw = await fsp.readFile(r.file).catch(() => null);
194
+ if (!raw) return { error: 404 };
195
+ const body = renderHtml(raw.toString('utf8'), { nonce, build: await this.buildId() });
196
+ headers['Content-Length'] = Buffer.byteLength(body);
197
+ if (!send) { res.writeHead(status, headers); res.end(); return { status, file: r.file }; }
198
+ res.writeHead(status, headers);
199
+ res.end(req.method === 'HEAD' ? undefined : body);
200
+ return { status, file: r.file, etag };
201
+ }
202
+ // The caller may be serving a real 404/403 *page*; the body being a file
203
+ // that exists does not make the request successful.
204
+ if (!send) { res.writeHead(status, headers); res.end(); return { status, file: r.file }; }
205
+ res.writeHead(status, headers);
206
+ const stream = (await import('node:fs')).createReadStream(r.file);
207
+ stream.on('error', () => res.destroy());
208
+ stream.pipe(res);
209
+ return { status: 200, file: r.file, etag };
210
+ }
211
+ }
212
+