argus/build-web.mjs
Local Dev a45320db7d Initial commit — Silent Mode baseline (2026-07-29)
Snapshot of the decentralized-web stack at the point of the resolver+Theseus
rebuild deploy. Includes:

- Argus (BNS engine + resolver daemon + Sia gateway)
- AriadneResolver (Windows Inno installer bundle + Android APK sources +
  Firefox extension)
- TheseusNavigator (Electron browser)
- site/ (silentmode.st content, deployed to Sia at bns/silentmode/)
- design docs, roadmap, protocol spec

Secrets excluded via .gitignore: Argus/sia-s3.json, Argus/wallets.json,
Argus/ca/*.key,*.crt. Build outputs, node_modules, and bundled runtimes
also excluded.

Shipped hashes on dl.silentmode.st at this commit:
  AriadneResolver-Setup-0.1.0.exe    5bcb216eef31ea28ed767e4134ab74bd5ac69dfbd365fd249e9e6938e55c986a
  TheseusNavigator-Setup-0.0.1.exe   7c735e88bad2da3347145adba3016c8f626a18b8422289c8c6ba471972e2952b
  TheseusNavigator-0.0.1-portable.exe 008fd84445babeabb401b2bca40ea9466b24b0e6d6c85104da7640c5c5c84521
  ariadne-v0.2.apk                   635c8f04d44ef855a8390b9eeddb8cd2d50622e81cc4e5004e8daffc1bb0425c
2026-07-29 13:54:34 +02:00

186 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)");