187 lines
7 KiB
JavaScript
187 lines
7 KiB
JavaScript
|
|
// 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)");
|