#!/usr/bin/env node /* * cloudflare-probes.mjs — the LIVE probes (kv-expiry, asset-headers) * (07-03, D-01, T-07-01/T-07-16). No in-repo analog existed — the only live-KV * precedent is the manual command recorded in 06-DEVICE-GATE.md step 7. * --------------------------------------------------------------------------- * A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, * web/src, routing/src, or search/src imports it. GPL-3.0: forks rerun the * probes against their OWN namespace (read from their own bot/wrangler.jsonc). * * LIVE ONLY: both probes need credentials + a deployed origin. Without them * each probe degrades to `probeUnavailable(...)` — { pass:false, notRun:true } * — and the CLI maps notRun to exit code 2 (environment drift). A skipped * probe is NEVER mistaken for a pass (the no-silent-passes contract). * * Category-only output law: the probe's evidence NEVER prints a raw KV key * name (session: / loc: carry identifiers) — only family * counts, expiry bounds, and header lines. * * Pagination (07-RESEARCH Pitfall 7 — key presence alone does not prove * TTL-only): the wrangler envelope shape carries `list_complete` + `cursor` * and the cursor is FOLLOWED until list_complete; a bare-array shape means * the CLI paginated internally and returned the complete set (wrangler's * documented behavior for `kv key list`), and the evidence says which shape * was seen. */ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT; const WRANGLER_JSONC = path.join(REPO_ROOT, "bot", "wrangler.jsonc"); /** The four TTL-only key families (the KV storage contract). An unexpected * family is REPORTED, never assumed absent (07-RESEARCH A5). */ export const KEY_FAMILIES = ["session:", "chat:", "loc:", "guidance:"]; /** A missing environment is the exit-2 class: pass:false + notRun:true. The * evidence carries the REASON only — the CLI prints the NOT RUN status. */ export function probeUnavailable(reason) { return { id: "probe", pass: false, notRun: true, evidence: reason }; } function notRun(id, reason) { return Object.assign(probeUnavailable(reason), { id }); } /** Read the KV namespace id from bot/wrangler.jsonc AT RUNTIME (never * hard-coded twice). jsonc: strip line comments without eating `https://` * inside values — the `(^|[^:])//` guard (the repo stripper's line rule). */ function readNamespaceId() { if (!existsSync(WRANGLER_JSONC)) return null; const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); return stripped.match(/kv_namespaces[\s\S]*?"id"\s*:\s*"([0-9a-f]+)"/)?.[1] ?? null; } /** Read the deployed Mini App origin from bot/wrangler.jsonc vars (public * configuration — never a secret). */ function readMiniAppUrl() { if (!existsSync(WRANGLER_JSONC)) return null; const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); return stripped.match(/"MINI_APP_URL"\s*:\s*"([^"]+)"/)?.[1] ?? null; } function wranglerAvailable() { if (existsSync(path.join(REPO_ROOT, "bot", "node_modules", ".bin", "wrangler"))) return true; try { execFileSync("wrangler", ["--version"], { encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] }); return true; } catch { return false; } } /** * The live KV listing probe: `npx wrangler kv key list --namespace-id=` * (the CURRENT wrangler syntax — the deprecated `kv:key` colon form is * forbidden). Parses each key's `name`, optional `expiration`, optional * `metadata`, the `list_complete` flag and the `cursor`; follows the cursor * until list_complete; asserts (1) every key's family prefix is one of the * four TTL-only families, (2) every listed key carries an `expiration`, * (3) reports the OLDEST and NEWEST expiry it saw. */ export function runKvExpiryProbe(opts = {}) { const id = "kv-expiry"; const missing = []; if (!process.env.CLOUDFLARE_API_TOKEN || !process.env.CLOUDFLARE_ACCOUNT_ID) missing.push("Cloudflare credentials"); const namespaceId = opts.namespaceId ?? readNamespaceId(); if (!namespaceId) missing.push("KV namespace id (bot/wrangler.jsonc)"); if (!wranglerAvailable()) missing.push("wrangler CLI"); if (missing.length > 0) return notRun(id, `missing ${missing.join(" + ")}`); /** @type {Array<{name: string, expiration?: number, metadata?: unknown}>} */ const keys = []; let cursor; let listComplete = null; // null = the shape carried no list_complete metadata try { do { const args = ["wrangler", "kv", "key", "list", `--namespace-id=${namespaceId}`]; if (cursor !== undefined) args.push(`--cursor=${cursor}`); const out = execFileSync("npx", args, { cwd: path.join(REPO_ROOT, "bot"), encoding: "utf8", timeout: 120000, maxBuffer: 1 << 26, stdio: ["ignore", "pipe", "pipe"], env: process.env, }); const parsed = JSON.parse(extractJson(out)); if (Array.isArray(parsed)) { keys.push(...parsed); // bare array — wrangler paginated internally (documented CLI behavior) } else if (Array.isArray(parsed?.keys)) { keys.push(...parsed.keys); listComplete = parsed.list_complete ?? null; cursor = parsed.cursor ?? undefined; } else { return notRun(id, "unrecognized wrangler kv key list output shape"); } } while (cursor !== undefined && listComplete === false); } catch (err) { return notRun(id, `wrangler kv key list failed (${categoryOf(err)})`); } const familyCounts = new Map(); const violations = []; const expirations = []; for (const key of keys) { const name = typeof key?.name === "string" ? key.name : ""; const family = name.slice(0, name.indexOf(":") + 1); const familyLabel = family || "(no family prefix)"; if (!KEY_FAMILIES.includes(family)) { // A5: report the unexpected FAMILY (the prefix only — never the raw // key name, which carries a token or a chat id). if (!familyCounts.has(familyLabel)) familyCounts.set(familyLabel, 0); familyCounts.set(familyLabel, familyCounts.get(familyLabel) + 1); violations.push(`unexpected key family: ${familyLabel}`); continue; } familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1); if (typeof key.expiration !== "number" || key.expiration <= 0) { violations.push(`a key carries NO expiration (TTL-less live key)`); } else { expirations.push(key.expiration); } } const oldest = expirations.length > 0 ? new Date(Math.min(...expirations) * 1000).toISOString() : "n/a"; const newest = expirations.length > 0 ? new Date(Math.max(...expirations) * 1000).toISOString() : "n/a"; const shape = listComplete === null ? "wrangler returned the complete set (it paginates internally; list_complete/cursor metadata not exposed by this CLI shape)" : `list_complete=${listComplete}${cursor !== undefined ? " (cursor followed)" : ""}`; const counts = [...familyCounts.entries()].map(([f, n]) => `${f}${n}`).join(", ") || "no keys"; const pass = violations.length === 0; const evidence = pass ? `${keys.length} live key(s) [${counts}]; every key carries an expiration; oldest expiry ${oldest}, newest ${newest}; ${shape}` : `${keys.length} live key(s) [${counts}]; ${shape}; ${violations.length} violation(s): ${violations.join("; ")}`; return { id, pass, evidence, violations: violations.length }; } /** Pull the outermost JSON value out of wrangler's stdout (it may interleave * non-JSON banner lines around the payload). */ function extractJson(out) { const start = out.search(/[[{]/); const end = Math.max(out.lastIndexOf("]"), out.lastIndexOf("}")); return out.slice(start, end + 1); } function categoryOf(err) { if (err?.code === "ETIMEDOUT" || /timed? ?out/i.test(String(err?.message))) return "timed out"; return "non-zero exit / network error"; // category only — never err text (it can echo env/paths) } /** * The live static-asset header probe: `curl -sSI` the HTML entry and one * hashed /assets/* file, reporting the observed `cache-control` and * `cf-cache-status` headers VERBATIM. CF-Cache-Status is probabilistic per * Cloudflare's own footnote — the evidence prints it, it does not interpret * it as a guarantee (07-RESEARCH Pitfall 10). * * Honesty laws: every leg's HTTP status is read BEFORE any header is * interpreted — a non-2xx is deployment/environment drift (the notRun * class), never a cache-header violation. The immutable `/assets/*` policy * itself is `web/public/_headers`' claim (07-04) — this probe quotes, it * does not assert the policy. */ export function runAssetHeaderProbe(opts = {}) { const id = "asset-headers"; const baseUrl = opts.baseUrl ?? process.env.MINI_APP_URL ?? readMiniAppUrl(); if (!baseUrl) return notRun(id, "no deployed origin (MINI_APP_URL / bot/wrangler.jsonc vars)"); if (!curlAvailable()) return notRun(id, "curl CLI not available"); const dist = path.join(REPO_ROOT, "web", "dist"); let assetPath = null; if (existsSync(path.join(dist, "assets"))) { const asset = readdirSync(path.join(dist, "assets")).find((f) => f.endsWith(".js")); if (asset) assetPath = `/assets/${asset}`; } const lines = []; const observed = []; const targets = [["HTML entry /", `${baseUrl}/`]]; if (assetPath !== null) targets.push([`hashed asset ${assetPath}`, `${baseUrl}${assetPath}`]); try { for (const [label, url] of targets) { const out = execFileSync("curl", ["-sSI", "--max-time", "20", url], { encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"], }); const status = Number(out.match(/^HTTP\/[\d.]+\s+(\d+)/m)?.[1] ?? 0); const cacheControl = out.match(/^cache-control:.*$/im)?.[0]?.trim() ?? null; const cfStatus = out.match(/^cf-cache-status:.*$/im)?.[0]?.trim() ?? null; observed.push( `${label}: HTTP ${status || "unknown"}${cacheControl !== null ? ` | ${cacheControl}` : " | no cache-control"}${cfStatus !== null ? ` | ${cfStatus}` : ""}`, ); if (!Number.isInteger(status) || status < 200 || status >= 400) { return notRun( id, `deployment drift — ${label} answered HTTP ${status || "unknown"} ` + `(the deployed origin does not match web/dist); observed: ${observed.join(" || ")}`, ); } if (cacheControl === null) lines.push(`${label}: NO cache-control header observed`); else lines.push(`${label}: ${cacheControl} | ${cfStatus ?? "cf-cache-status not reported"}`); } } catch (err) { return notRun(id, `origin unreachable (${categoryOf(err)})`); } const violations = lines.filter((l) => l.includes("NO cache-control")).length; const pass = violations === 0; const evidence = pass ? `${targets.length} response header(s), VERBATIM (cf-cache-status is probabilistic — quoted, never interpreted as a guarantee): ${lines.join(" || ")}` : `${violations} violation(s): ${lines.join(" || ")}`; return { id, pass, evidence, violations }; } function curlAvailable() { try { execFileSync("curl", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }); return true; } catch { return false; } }