Files
arah2/scripts/lib/cloudflare-probes.mjs

279 lines
13 KiB
JavaScript

#!/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:<token> / loc:<chat_id> 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, { dirname, resolve } 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 Cloudflare credentials from bot/.dev.vars (gitignored; D-15: the
* values are used for the probe's wrangler calls — never printed, never
* logged, never written anywhere). */
function readDevVarsCredentials() {
const out = {};
try {
const raw = readFileSync(resolve(dirname(WRANGLER_JSONC), ".dev.vars"), "utf8");
for (const line of raw.split("\n")) {
const eq = line.indexOf("=");
if (eq <= 0 || line.trimStart().startsWith("#")) continue;
const key = line.slice(0, eq).trim();
if (key === "CLOUDFLARE_API_TOKEN" || key === "CLOUDFLARE_ACCOUNT_ID") {
out[key] = line.slice(eq + 1).trim();
}
}
} catch {
// no .dev.vars — the caller reports the missing-credentials class
}
return out;
}
/** 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=<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 = [];
// Credentials: process.env first, then bot/.dev.vars (gitignored, D-15 —
// the launch-gate's credential home; never echoed, never committed).
const devVars = readDevVarsCredentials();
const apiToken = process.env.CLOUDFLARE_API_TOKEN ?? devVars.CLOUDFLARE_API_TOKEN;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? devVars.CLOUDFLARE_ACCOUNT_ID;
if (!apiToken || !accountId) missing.push("Cloudflare credentials (bot/.dev.vars or env)");
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(" + ")}`);
if (!opts.envOverride) { // the recorded probes run through wrangler with the env set
process.env.CLOUDFLARE_API_TOKEN = apiToken;
process.env.CLOUDFLARE_ACCOUNT_ID = accountId;
}
/** @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;
}
}