diff --git a/policy-site/tests/check-csp.mjs b/policy-site/tests/check-csp.mjs
new file mode 100644
index 00000000..30ca0db6
--- /dev/null
+++ b/policy-site/tests/check-csp.mjs
@@ -0,0 +1,291 @@
+// The built page and the policy it is served under are a pair, and neither
+// half can be checked alone. The Content-Security-Policy lives in
+// infra/policy-site/main.tf, so a build that starts needing something the
+// policy does not grant passes every other test and then fails in an
+// operator's browser, where the only trace is a console warning.
+//
+// So this reads the policy from infra/ (never a copy of it) and holds dist/ to
+// it:
+//
+// - no inline script or style of any shape, since no directive grants
+// 'unsafe-inline';
+// - no JavaScript built from a string, since script-src grants
+// 'wasm-unsafe-eval' and not 'unsafe-eval';
+// - every resource the HTML and CSS load comes from the page's own origin,
+// or is a data: URL where the governing directive allows one, and that
+// directive grants it.
+//
+// It uses node's standard library only, so it runs whatever policy-site/
+// installs. Its helpers live in this file for the same reason.
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { dirname, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const repoRoot = resolve(siteRoot, "..");
+const distDir = join(siteRoot, "dist");
+const mainTf = join(repoRoot, "infra", "policy-site", "main.tf");
+
+let failures = 0;
+function fail(message) {
+ failures += 1;
+ console.error(`FAIL: ${message}`);
+}
+
+function walk(dir) {
+ const out = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...walk(full));
+ else out.push(full);
+ }
+ return out;
+}
+
+try {
+ statSync(distDir);
+} catch {
+ throw new Error(`${distDir} does not exist — run "npm run build" in policy-site/ before the tests`);
+}
+
+// The policy as the distribution sends it: the `content_security_policy`
+// local is a join of one quoted directive per line.
+const tf = readFileSync(mainTf, "utf8");
+const block = tf.match(/content_security_policy\s*=\s*join\("\s*",\s*\[([\s\S]*?)\]\s*\)/);
+const domain = tf.match(/^\s*domain\s*=\s*"([^"]+)"/m)?.[1];
+if (!block || !domain) {
+ throw new Error(`${relative(repoRoot, mainTf)} no longer has a content_security_policy = join(...) ` +
+ "local and a domain local; this test reads the deployed policy from there");
+}
+const policy = [...block[1].matchAll(/"([^"]*)"/g)].map(([, line]) => line).join(" ");
+const origin = `https://${domain}`;
+
+const directives = new Map();
+for (const part of policy.split(";")) {
+ const [name, ...sources] = part.trim().split(/\s+/);
+ if (name) directives.set(name.toLowerCase(), sources);
+}
+
+// The directive a browser consults for a resource type, falling back as CSP
+// Level 3 says. base-uri and form-action never fall back to default-src.
+const FALLBACKS = {
+ "script-src-elem": ["script-src-elem", "script-src"],
+ "style-src-elem": ["style-src-elem", "style-src"],
+ "frame-src": ["frame-src", "child-src"],
+ "worker-src": ["worker-src", "child-src", "script-src"],
+};
+function governing(directive) {
+ for (const name of FALLBACKS[directive] ?? [directive]) {
+ if (directives.has(name)) return { name, sources: directives.get(name) };
+ }
+ if (directive === "base-uri" || directive === "form-action") return null;
+ return { name: "default-src", sources: directives.get("default-src") ?? null };
+}
+
+// Checks one reference from `source` (a dist-relative file) against the
+// directive that governs it. A null directive checks the origin alone.
+function checkReference(source, directive, reference) {
+ const ref = reference.trim();
+ if (!ref) return;
+ const where = `${source}: ${ref.length > 80 ? `${ref.slice(0, 77)}...` : ref}`;
+ let url;
+ try {
+ url = new URL(ref, `${origin}/${source}`);
+ } catch {
+ fail(`${where} is not a URL`);
+ return;
+ }
+ if (url.protocol === "javascript:") {
+ fail(`${where} is a javascript: URL, which runs as inline script`);
+ return;
+ }
+ const rule = directive ? governing(directive) : null;
+ const sources = rule?.sources;
+ if (url.protocol === "data:") {
+ if (sources && !sources.includes("data:")) {
+ fail(`${where} is a data: URL, which ${rule.name} (${sources.join(" ")}) refuses`);
+ }
+ return;
+ }
+ if (url.origin !== origin) {
+ fail(`${where} is on ${url.origin}; this page loads from its own origin only`);
+ return;
+ }
+ if (sources && !sources.includes("'self'")) {
+ fail(`${where} is refused by ${rule.name} (${sources.join(" ")}), which does not grant 'self'`);
+ }
+}
+
+// ---- HTML -------------------------------------------------------------------
+
+const ATTRIBUTE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
+const TAG = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
+
+function attributes(text) {
+ const out = new Map();
+ for (const [, name, double, single, bare] of (text ?? "").matchAll(ATTRIBUTE)) {
+ const value = double ?? single ?? bare ?? "";
+ out.set(name.toLowerCase(), value.replaceAll("&", "&"));
+ }
+ return out;
+}
+
+// The directive a is fetched under, by its rel and, for a preload, its
+// `as`. A rel that loads nothing is still held to the page's own origin.
+const PRELOAD_AS = {
+ script: "script-src-elem",
+ style: "style-src-elem",
+ image: "img-src",
+ font: "font-src",
+ fetch: "connect-src",
+ audio: "media-src",
+ video: "media-src",
+ track: "media-src",
+ worker: "worker-src",
+};
+function linkDirective(attrs) {
+ const rels = (attrs.get("rel") ?? "").toLowerCase().split(/\s+/);
+ if (rels.includes("stylesheet")) return "style-src-elem";
+ if (rels.includes("modulepreload")) return "script-src-elem";
+ if (rels.includes("manifest")) return "manifest-src";
+ if (rels.some((rel) => rel.endsWith("icon"))) return "img-src";
+ if (rels.includes("preload")) return PRELOAD_AS[(attrs.get("as") ?? "").toLowerCase()] ?? "default-src";
+ return null;
+}
+
+// Which attributes of which elements load a resource, and under what.
+const LOADS = {
+ script: [["src", "script-src-elem"]],
+ img: [["src", "img-src"], ["srcset", "img-src"]],
+ video: [["src", "media-src"], ["poster", "img-src"]],
+ audio: [["src", "media-src"]],
+ track: [["src", "media-src"]],
+ iframe: [["src", "frame-src"]],
+ frame: [["src", "frame-src"]],
+ object: [["data", "object-src"]],
+ embed: [["src", "object-src"]],
+ form: [["action", "form-action"]],
+ button: [["formaction", "form-action"]],
+ input: [["formaction", "form-action"], ["src", "img-src"]],
+ base: [["href", "base-uri"]],
+};
+
+function srcsetUrls(value) {
+ return value.split(",").map((candidate) => candidate.trim().split(/\s+/)[0]);
+}
+
+function checkHtml(path) {
+ const source = relative(distDir, path);
+ let html = readFileSync(path, "utf8").replace(//g, "");
+
+ // Script and style bodies first, then blanked, so markup-shaped text inside
+ // them is not read as markup.
+ html = html.replace(/<(script|style)\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi, (whole, tag, attrText, body) => {
+ const attrs = attributes(attrText);
+ if (tag.toLowerCase() === "style") {
+ fail(`${source}: inline