// Build the browser bundles for the public register page. // // SECURITY.md rule 3 applies: no source maps, no build-machine paths, no // identity strings in anything shipped. This script bundles, then greps the // output for the things that must never leave the machine (rule 0) and for // home-directory fingerprints, and refuses to write a bundle that trips either. // // Run: node build-web.mjs import { build } from "esbuild"; import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const root = dirname(fileURLToPath(import.meta.url)); const siteJs = join(root, "..", "site", "js"); mkdirSync(siteJs, { recursive: true }); // Structural fingerprints that must never appear in a shipped artifact // (SECURITY.md rule 3). Deliberately NOT matching bare words like "secretKey": // every crypto library in the tree uses that as a local identifier, and a check // that cries wolf on nostr-tools is a check people learn to ignore. const FORBIDDEN = [ /C:\\Users\\[A-Za-z0-9._-]+/i, /\/home\/[A-Za-z0-9._-]+\//, /\/Users\/[A-Za-z0-9._-]+\//, /\bsia-s3\.json\b/i, /\bwallets\.json\b/i, /\broot-ca\.key\b/i, /BEGIN [A-Z ]*PRIVATE KEY/, ]; // The check that actually matters (rule 0): read the real secrets and prove // none of their VALUES made it into the bundle. Values, not variable names. // Nothing is ever printed — only whether a match occurred. function realSecretValues() { const values = []; const collect = (v) => { if (typeof v === "string" && v.length >= 8) values.push(v); else if (v && typeof v === "object") Object.values(v).forEach(collect); }; for (const f of ["sia-s3.json", "wallets.json", "setup/install-state.json"]) { try { collect(JSON.parse(readFileSync(join(root, f), "utf8"))); } catch { /* absent is fine */ } } try { const ca = readFileSync(join(root, "ca", "root-ca.key"), "utf8"); values.push(...ca.split("\n").filter((l) => l.length > 20)); } catch { /* absent is fine */ } return values; } const SECRETS = realSecretValues(); // libauth is shared by every bundle via the page's import map — see // src/web/libauth-entry.js for why this must not be duplicated. const SHARED = ["@bitauth/libauth"]; const targets = [ { entry: join(root, "src", "web", "libauth-entry.js"), out: join(siteJs, "libauth.js"), minify: true, note: "libauth — MIT, shared by every bundle (import map target)", }, { entry: join(root, "src", "web", "register-entry.js"), out: join(siteJs, "bns-register.js"), minify: true, external: SHARED, note: "registration core — MIT, safe to minify", }, { entry: join(root, "src", "lib", "connect", "wizardconnect.js"), out: join(siteJs, "wizardconnect.js"), // NOT MINIFIED, ON PURPOSE. @wizardconnect/* is LGPL-3.0-or-later; §4(d) // requires that a user be able to swap in their own build of the library. // Shipping it as a separate, readable ES module satisfies §4(d)(1) ("a // suitable shared library mechanism") with no relink paperwork. Minifying // or folding this into the main bundle would break that. See // Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md. minify: false, external: SHARED, note: "WizardConnect — LGPL-3.0-or-later, kept separate and unminified (§4d1)", licences: ["lgpl-3.0", "gpl-3.0"], }, ]; let failed = false; for (const t of targets) { await build({ entryPoints: [t.entry], outfile: t.out, bundle: true, format: "esm", platform: "browser", target: ["es2022"], minify: t.minify, external: t.external ?? [], sourcemap: false, legalComments: "none", // Keep the build reproducible and path-free. absWorkingDir: root, define: { "process.env.NODE_ENV": '"production"' }, }); const code = readFileSync(t.out, "utf8"); for (const re of FORBIDDEN) { const hit = code.match(re); if (hit) { console.error(`REFUSING ${t.out}: contains "${hit[0]}" (SECURITY.md rule 3)`); failed = true; } } for (const secret of SECRETS) { if (code.includes(secret)) { // Never echo the value. console.error(`REFUSING ${t.out}: contains a live secret value (SECURITY.md rule 0)`); failed = true; break; } } // LGPL §4(b)/(c): the licence texts must travel with the distribution. for (const l of t.licences ?? []) { const path = join(root, "..", "site", "vendor", `LICENSE.${l}.txt`); try { if (statSync(path).size < 1000) throw new Error("too small"); } catch { console.error(`REFUSING ${t.out}: site/vendor/LICENSE.${l}.txt is missing — LGPL §4(b) requires it`); failed = true; } } const size = statSync(t.out).size; const sha = createHash("sha256").update(readFileSync(t.out)).digest("hex"); console.log(`${t.out.replace(root, ".")} ${(size / 1024).toFixed(1)} kB`); console.log(` sha256 ${sha}`); console.log(` ${t.note}`); } // --------------------------------------------------------------------------- // Verify the shared-libauth contract. // // libauth-entry.js re-exports a hand-picked list so tree-shaking still works. // The risk is shipping a bundle (or a dependency inside one) that imports a name // the shared module does not export — a runtime failure in a path unit tests // cannot reach. So: read the imports esbuild actually emitted and prove every // one of them is satisfied. // --------------------------------------------------------------------------- { const shared = readFileSync(join(siteJs, "libauth.js"), "utf8"); // The final `export{a as b,...}` of the emitted ESM module. const provided = new Set( [...shared.matchAll(/export\s*\{([^}]*)\}/g)] .flatMap((m) => m[1].split(",")) .map((part) => part.trim().split(/\s+as\s+/).pop()) .filter(Boolean), ); const required = new Map(); // name -> which bundle wants it for (const t of targets) { if (t.out.endsWith("libauth.js")) continue; const code = readFileSync(t.out, "utf8"); for (const m of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@bitauth\/libauth["']/g)) { for (const part of m[1].split(",")) { // `import {a as b}` — `a` is the name the shared module must export. const name = part.trim().split(/\s+as\s+/)[0].trim(); if (name) required.set(name, t.out); } } } const missing = [...required].filter(([name]) => !provided.has(name)); if (missing.length) { for (const [name, who] of missing) { console.error(`MISSING from src/web/libauth-entry.js: "${name}" (needed by ${who.replace(root, ".")})`); } failed = true; } else { console.log(`\nshared libauth: ${provided.size} exports, all ${required.size} imports satisfied`); } } if (failed) process.exit(1); console.log("bundles clean (no secrets, no build-path fingerprints)");