返回 CodeWhale
facts-publish.mjs
根目录 / web / scripts / facts-publish.mjs
1 #!/usr/bin/env node
2 /**
3 * CodeWhale cloud facts (facts/v1) authoring tool. Zero npm dependencies.
4 *
5 * node scripts/facts-publish.mjs keygen --key-id cwf-2026-09 --out /secure/path.key
6 * node scripts/facts-publish.mjs sign --source ../docs/cloud-facts/stable.json --channel stable \
7 * --key-id cwf-2026-09 [--facts-version N] [--out envelope.json]
8 * node scripts/facts-publish.mjs verify envelope.json [--public-key <base64>]
9 * node scripts/facts-publish.mjs emit-sql envelope.json [--published-by who] [--public-key <base64>]
10 * node scripts/facts-publish.mjs publish envelope.json [--dry-run] [--published-by who]
11 * node scripts/facts-publish.mjs revoke --channel stable --version N --reason "..." [--dry-run]
12 *
13 * Secrets are read ONLY from the environment at sign/publish time and are never
14 * printed:
15 * CODEWHALE_FACTS_SIGNING_KEY PEM (PKCS#8) Ed25519 private key contents
16 * CODEWHALE_FACTS_SIGNING_KEY_FILE path to that PEM (alternative)
17 * SUPABASE_URL https://<ref>.supabase.co (publish/revoke)
18 * SUPABASE_SERVICE_ROLE_KEY service-role key (publish/revoke only; never embed)
19 *
20 * Signing contract (must match crates/config/src/cloud_facts/verify.rs and
21 * web/lib/cloud-facts.ts): Ed25519 detached signature over
22 * "codewhale-facts/v1\0" || key_id || "\0" || payload_bytes
23 * where payload_bytes is canonical JSON (sorted keys, no whitespace, UTF-8).
24 * Clients verify the exact bytes carried in payload_b64; they never re-canonicalize.
25 */
26 import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, createHash } from "node:crypto";
27 import { readFileSync, writeFileSync, mkdirSync, openSync, closeSync, readSync, fstatSync, lstatSync, constants } from "node:fs";
28 import { dirname, resolve } from "node:path";
29 import { fileURLToPath } from "node:url";
30
31 export const DOMAIN = "codewhale-facts/v1\0";
32 export const ENVELOPE_VERSION = 1;
33 export const SCHEMA_VERSION = 1;
34 export const MAX_PAYLOAD_BYTES = 512 * 1024;
35 export const MAX_ENVELOPE_BYTES = 768 * 1024;
36 const KEY_ID_RE = /^cwf-[a-z0-9-]{1,32}$/;
37 const CHANNEL_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
38 const CI_MARKERS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "JENKINS_URL", "TF_BUILD"];
39
40 const here = dirname(fileURLToPath(import.meta.url));
41 const WEB_ROOT = resolve(here, "..");
42 const REPO_ROOT = resolve(WEB_ROOT, "..");
43
44 // ---------------------------------------------------------------------------
45 // Canonical JSON + signing primitives (exported for tests)
46 // ---------------------------------------------------------------------------
47
48 export function canonicalize(value) {
49 if (value === null || typeof value !== "object") {
50 if (typeof value === "number" && !Number.isFinite(value)) {
51 throw new Error("non-finite number in payload");
52 }
53 return JSON.stringify(value);
54 }
55 if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
56 const keys = Object.keys(value).sort();
57 const parts = [];
58 for (const key of keys) {
59 const v = value[key];
60 if (v === undefined) continue;
61 parts.push(`${JSON.stringify(key)}:${canonicalize(v)}`);
62 }
63 return `{${parts.join(",")}}`;
64 }
65
66 export function signingMessage(keyId, payloadBytes) {
67 return Buffer.concat([Buffer.from(DOMAIN, "utf8"), Buffer.from(keyId, "utf8"), Buffer.from([0]), payloadBytes]);
68 }
69
70 export function rawPublicKeyFromKeyObject(keyObject) {
71 const spki = keyObject.export({ type: "spki", format: "der" });
72 // Ed25519 SPKI DER is a fixed 12-byte prefix followed by the 32-byte key.
73 return spki.subarray(spki.length - 32);
74 }
75
76 export function publicKeyObjectFromRaw(rawB64) {
77 const raw = strictBase64(rawB64, 32);
78 if (raw.length !== 32) throw new Error("public key must decode to 32 bytes");
79 const prefix = Buffer.from("302a300506032b6570032100", "hex");
80 return createPublicKey({ key: Buffer.concat([prefix, raw]), type: "spki", format: "der" });
81 }
82
83 export function signPayload(privateKey, keyId, payloadBytes) {
84 return sign(null, signingMessage(keyId, payloadBytes), privateKey);
85 }
86
87 /** Canonical base64 is checked before decoding to bound allocation. */
88 export function strictBase64(value, maxBytes) {
89 if (typeof value !== "string" || !value.length || value.length > 4 * Math.ceil(maxBytes / 3) ||
90 (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))) throw new Error("invalid base64");
91 const bytes = Buffer.from(value, "base64");
92 if (bytes.length > maxBytes || bytes.toString("base64") !== value) throw new Error("invalid base64");
93 return bytes;
94 }
95
96 export function utcTime(value) {
97 if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return null;
98 const time = Date.parse(value);
99 return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : null;
100 }
101
102 export function verifyEnvelope(envelope, publicKeyB64) {
103 const errors = [];
104 if (!isPlainObject(envelope)) return { ok: false, errors: ["envelope must be an object"] };
105 if (envelope.envelope !== ENVELOPE_VERSION) errors.push("unsupported envelope version");
106 if (envelope.alg !== "ed25519") errors.push("unsupported signature algorithm");
107 if (typeof envelope.key_id !== "string" || !KEY_ID_RE.test(envelope.key_id)) errors.push("bad key_id");
108 if (envelope.schema_version !== SCHEMA_VERSION) errors.push("unsupported schema version");
109 if (!Number.isSafeInteger(envelope.facts_version) || envelope.facts_version <= 0) errors.push("facts_version must be a positive safe integer");
110 if (typeof envelope.channel !== "string" || !CHANNEL_RE.test(envelope.channel)) errors.push("bad channel");
111 if (typeof envelope.applies_to !== "string" || envelope.applies_to.length > 200 || !VERSION_REQ_RE.test(envelope.applies_to)) errors.push("bad applies_to");
112 if (utcTime(envelope.published_at) === null || (envelope.not_after != null && utcTime(envelope.not_after) === null)) errors.push("bad timestamp");
113 if (typeof envelope.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(envelope.sha256)) errors.push("bad sha256");
114 if (!Array.isArray(envelope.sigs) || envelope.sigs.length > 7) errors.push("bad extra signatures");
115 else for (const candidate of envelope.sigs) {
116 if (!isPlainObject(candidate) || typeof candidate.key_id !== "string" || !KEY_ID_RE.test(candidate.key_id)) { errors.push("bad extra signature"); continue; }
117 try { if (strictBase64(candidate.sig_b64, 64).length !== 64) errors.push("bad extra signature size"); }
118 catch { errors.push("bad extra signature encoding"); }
119 }
120 if (errors.length) return { ok: false, errors };
121 let payloadBytes, sig, key;
122 try {
123 payloadBytes = strictBase64(envelope.payload_b64, MAX_PAYLOAD_BYTES);
124 sig = strictBase64(envelope.sig_b64, 64);
125 if (sig.length !== 64) throw new Error("bad signature size");
126 key = publicKeyObjectFromRaw(publicKeyB64);
127 } catch { return { ok: false, errors: ["invalid payload, signature or public key encoding"] }; }
128 const sha = createHash("sha256").update(payloadBytes).digest("hex");
129 if (envelope.sha256 !== sha) return { ok: false, errors: ["sha256 mismatch"] };
130 if (!verify(null, signingMessage(envelope.key_id, payloadBytes), key, sig)) return { ok: false, errors: ["bad signature"] };
131 let payload;
132 try { payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payloadBytes)); }
133 catch { return { ok: false, errors: ["payload is not UTF-8 JSON"] }; }
134 errors.push(...validateSource(payload));
135 for (const field of ["channel", "facts_version", "applies_to", "schema_version", "published_at"]) {
136 if (envelope[field] !== payload?.[field]) errors.push(`outer ${field} differs from signed payload`);
137 }
138 if ((envelope.not_after ?? null) !== (payload?.not_after ?? null)) errors.push("outer not_after differs from signed payload");
139 if (payload?.not_after != null && utcTime(payload.not_after) <= utcTime(payload.published_at)) errors.push("not_after must follow published_at");
140 return errors.length ? { ok: false, errors } : { ok: true, errors: [], payload, sha256: sha };
141 }
142
143 // ---------------------------------------------------------------------------
144 // Source validation (docs/cloud-facts/<channel>.json)
145 // ---------------------------------------------------------------------------
146
147 const MODEL_OPS = new Set(["upsert", "deprecate", "hide"]);
148 const LEVELS = new Set(["info", "warn"]);
149 const SURFACES = new Set(["tui", "desktop", "web"]);
150 const VERSION_REQ_RE = /^(\*|(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\s*,\s*(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?)*)$/;
151
152 function isPlainObject(v) {
153 return v !== null && typeof v === "object" && !Array.isArray(v);
154 }
155
156 function optString(errors, where, obj, key, max = 500) {
157 const v = obj[key];
158 if (v === undefined || v === null) return;
159 if (typeof v !== "string" || v.length > max) errors.push(`${where}.${key} must be a string (<= ${max} chars)`);
160 }
161
162 function optVersionReq(errors, where, obj, key = "applies_to") {
163 const v = obj[key];
164 if (v === undefined || v === null) return;
165 if (typeof v !== "string" || v.length > 200 || !VERSION_REQ_RE.test(v.trim())) errors.push(`${where}.${key} is not a semver requirement: ${JSON.stringify(v)}`);
166 }
167
168 export function validateSource(source) {
169 const errors = [];
170 if (!isPlainObject(source)) return ["source must be an object"];
171 if (source.schema_version !== undefined && source.schema_version !== SCHEMA_VERSION) {
172 errors.push(`schema_version must be ${SCHEMA_VERSION}`);
173 }
174 if (source.channel !== undefined && !CHANNEL_RE.test(String(source.channel))) errors.push("channel slug invalid");
175 if (source.facts_version !== undefined && !(Number.isSafeInteger(source.facts_version) && source.facts_version > 0)) {
176 errors.push("facts_version must be a positive integer");
177 }
178 optVersionReq(errors, "root", source);
179 optString(errors, "root", source, "not_after", 40);
180 for (const field of ["published_at", "not_after"]) {
181 if (source[field] != null && utcTime(source[field]) === null) errors.push(`${field} must be a valid UTC timestamp`);
182 }
183 const models = source.models ?? [];
184 if (!Array.isArray(models)) errors.push("models must be an array");
185 else {
186 models.forEach((m, i) => {
187 const where = `models[${i}]`;
188 if (!isPlainObject(m)) return errors.push(`${where} must be an object`);
189 if (typeof m.provider !== "string" || !m.provider) errors.push(`${where}.provider required`);
190 if (typeof m.id !== "string" || !m.id) errors.push(`${where}.id required`);
191 if (m.op !== undefined && !MODEL_OPS.has(m.op)) errors.push(`${where}.op must be one of ${[...MODEL_OPS].join("/")}`);
192 for (const k of ["context_window", "max_output"]) {
193 if (m[k] !== undefined && !(Number.isSafeInteger(m[k]) && m[k] > 0)) errors.push(`${where}.${k} must be a positive integer`);
194 }
195 if (m.pricing !== undefined) {
196 if (!isPlainObject(m.pricing)) errors.push(`${where}.pricing must be an object`);
197 else for (const k of Object.keys(m.pricing)) {
198 if (!["input_per_m", "output_per_m", "cache_read_per_m"].includes(k)) errors.push(`${where}.pricing.${k} unknown`);
199 else if (typeof m.pricing[k] !== "number" || !Number.isFinite(m.pricing[k]) || m.pricing[k] < 0) errors.push(`${where}.pricing.${k} must be a non-negative number`);
200 }
201 }
202 if (m.reasoning !== undefined && typeof m.reasoning !== "boolean") errors.push(`${where}.reasoning must be boolean`);
203 // Additive field: older clients deserialize it as false and keep provider
204 // roster dominance, so an unsigned or unaware reader loses nothing.
205 if (m.allow_unlisted !== undefined) {
206 if (typeof m.allow_unlisted !== "boolean") errors.push(`${where}.allow_unlisted must be boolean`);
207 else if (m.allow_unlisted) {
208 if (m.op !== undefined && m.op !== "upsert") errors.push(`${where}.allow_unlisted requires op upsert`);
209 // The client drops the assertion in a payload that cannot expire.
210 if (!source.not_after) errors.push(`${where}.allow_unlisted requires a payload not_after`);
211 }
212 }
213 optString(errors, where, m, "display_name", 120);
214 optString(errors, where, m, "deprecated_at", 40);
215 optString(errors, where, m, "replacement", 200);
216 optString(errors, where, m, "note", 300);
217 optVersionReq(errors, where, m);
218 });
219 }
220 const defaults = source.provider_defaults ?? {};
221 if (!isPlainObject(defaults)) errors.push("provider_defaults must be an object");
222 else for (const [provider, d] of Object.entries(defaults)) {
223 const where = `provider_defaults.${provider}`;
224 if (!isPlainObject(d)) { errors.push(`${where} must be an object`); continue; }
225 optString(errors, where, d, "default_model", 200);
226 optString(errors, where, d, "base_url", 300);
227 if (d.base_url !== undefined) {
228 try {
229 const url = new URL(d.base_url);
230 if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || /[\\\s]/.test(d.base_url)) throw new Error();
231 } catch { errors.push(`${where}.base_url must be an unambiguous credential-free https URL`); }
232 }
233 optVersionReq(errors, where, d);
234 }
235 if (source.release !== undefined && source.release !== null) {
236 const r = source.release;
237 const where = "release";
238 if (!isPlainObject(r)) errors.push("release must be an object");
239 else {
240 if (typeof r.latest !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(r.latest)) errors.push("release.latest must be a semver version");
241 if (r.yanked !== undefined && !(Array.isArray(r.yanked) && r.yanked.every((v) => typeof v === "string"))) errors.push("release.yanked must be a string array");
242 optString(errors, where, r, "min_supported", 40);
243 optString(errors, where, r, "notice", 300);
244 optString(errors, where, r, "release_url", 300);
245 optVersionReq(errors, where, r);
246 }
247 }
248 const ann = source.announcements ?? [];
249 if (!Array.isArray(ann)) errors.push("announcements must be an array");
250 else {
251 const seen = new Set();
252 ann.forEach((a, i) => {
253 const where = `announcements[${i}]`;
254 if (!isPlainObject(a)) return errors.push(`${where} must be an object`);
255 if (typeof a.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(a.id)) errors.push(`${where}.id invalid`);
256 if (seen.has(a.id)) errors.push(`${where}.id duplicated`);
257 seen.add(a.id);
258 if (a.level !== undefined && !LEVELS.has(a.level)) errors.push(`${where}.level must be info|warn`);
259 if (typeof a.text !== "string" || !a.text.trim() || a.text.length > 200) errors.push(`${where}.text required (<= 200 chars)`);
260 optString(errors, where, a, "url", 300);
261 if (a.surfaces !== undefined && !(Array.isArray(a.surfaces) && a.surfaces.every((s) => SURFACES.has(s)))) errors.push(`${where}.surfaces invalid`);
262 optVersionReq(errors, where, a);
263 optString(errors, where, a, "starts_at", 40);
264 optString(errors, where, a, "expires_at", 40);
265 });
266 }
267 const allowed = new Set(["$schema", "_meta", "schema_version", "channel", "facts_version", "published_at", "not_after", "applies_to", "models", "provider_defaults", "release", "announcements"]);
268 for (const k of Object.keys(source)) if (!allowed.has(k)) errors.push(`unknown top-level field ${k}`);
269 return errors;
270 }
271
272 /** Build the signed payload object (no signing) from a source file. */
273 export function buildPayload(source, { channel, factsVersion, publishedAt }) {
274 const errors = validateSource(source);
275 if (errors.length) throw new Error(`source invalid:\n - ${errors.join("\n - ")}`);
276 const payload = {
277 schema_version: SCHEMA_VERSION,
278 channel,
279 facts_version: factsVersion,
280 published_at: publishedAt,
281 applies_to: typeof source.applies_to === "string" ? source.applies_to.trim() : "*",
282 models: source.models ?? [],
283 provider_defaults: source.provider_defaults ?? {},
284 release: source.release ?? null,
285 announcements: source.announcements ?? [],
286 };
287 if (source.not_after) payload.not_after = source.not_after;
288 const payloadErrors = validateSource(payload);
289 if (payloadErrors.length || utcTime(publishedAt) === null || !Number.isSafeInteger(factsVersion) || factsVersion <= 0 || !CHANNEL_RE.test(channel)) {
290 throw new Error("invalid signed payload metadata");
291 }
292 return payload;
293 }
294
295 export function buildEnvelope({ privateKey, keyId, payload }) {
296 if (!KEY_ID_RE.test(keyId)) throw new Error(`key_id must match ${KEY_ID_RE}`);
297 const payloadBytes = Buffer.from(canonicalize(payload), "utf8");
298 if (payloadBytes.length > MAX_PAYLOAD_BYTES) throw new Error(`payload exceeds ${MAX_PAYLOAD_BYTES} bytes`);
299 const sig = signPayload(privateKey, keyId, payloadBytes);
300 const sha256 = createHash("sha256").update(payloadBytes).digest("hex");
301 const envelope = {
302 envelope: ENVELOPE_VERSION,
303 channel: payload.channel,
304 facts_version: payload.facts_version,
305 schema_version: payload.schema_version,
306 key_id: keyId,
307 alg: "ed25519",
308 applies_to: payload.applies_to,
309 published_at: payload.published_at,
310 payload_b64: payloadBytes.toString("base64"),
311 sig_b64: sig.toString("base64"),
312 sigs: [],
313 sha256,
314 };
315 if (payload.not_after != null) envelope.not_after = payload.not_after;
316 const pub = rawPublicKeyFromKeyObject(createPublicKey(privateKey)).toString("base64");
317 const check = verifyEnvelope(envelope, pub);
318 if (!check.ok) throw new Error(`self-verify failed: ${check.errors.join("; ")}`);
319 return envelope;
320 }
321
322 // ---------------------------------------------------------------------------
323 // CLI helpers
324 // ---------------------------------------------------------------------------
325
326 function parseArgs(argv) {
327 const positional = [];
328 const flags = {};
329 for (let i = 0; i < argv.length; i += 1) {
330 const arg = argv[i];
331 if (arg.startsWith("--")) {
332 const key = arg.slice(2);
333 const next = argv[i + 1];
334 if (next === undefined || next.startsWith("--")) flags[key] = true;
335 else { flags[key] = next; i += 1; }
336 } else positional.push(arg);
337 }
338 return { positional, flags };
339 }
340
341 /** Bounded, regular, single-link file reads; no symlink or FIFO following. */
342 export function readBoundedFile(path, maxBytes = MAX_ENVELOPE_BYTES) {
343 const before = lstatSync(path);
344 if (!before.isFile() || before.nlink !== 1) throw new Error("file is not a regular single-link file");
345 const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
346 try {
347 const stat = fstatSync(fd);
348 if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes || stat.ino !== before.ino || stat.dev !== before.dev) throw new Error("file is not a bounded regular single-link file");
349 const bytes = Buffer.alloc(maxBytes + 1);
350 let size = 0;
351 while (size <= maxBytes) {
352 const count = readSync(fd, bytes, size, maxBytes + 1 - size, null);
353 if (!count) break;
354 size += count;
355 }
356 if (size > maxBytes) throw new Error("file exceeds size limit");
357 return bytes.subarray(0, size);
358 } finally { closeSync(fd); }
359 }
360
361 function loadPrivateKeyFromEnv() {
362 refuseUnderCi();
363 let pem = process.env.CODEWHALE_FACTS_SIGNING_KEY;
364 const file = process.env.CODEWHALE_FACTS_SIGNING_KEY_FILE;
365 if (!pem && file) pem = readBoundedFile(file, 16 * 1024).toString("utf8");
366 if (!pem) throw new Error("set CODEWHALE_FACTS_SIGNING_KEY (PEM) or CODEWHALE_FACTS_SIGNING_KEY_FILE");
367 if (Buffer.byteLength(pem) > 16 * 1024) throw new Error("signing key exceeds size limit");
368 const key = createPrivateKey({ key: pem, format: "pem" });
369 if (key.asymmetricKeyType !== "ed25519") throw new Error("signing key must be Ed25519");
370 return key;
371 }
372
373 export function validateTrustedKeys(keys) {
374 const seen = new Set();
375 for (const key of keys) {
376 if (!KEY_ID_RE.test(key.keyId) || seen.has(key.keyId) || !["active", "retired"].includes(key.status) || strictBase64(key.publicKey, 32).length !== 32) throw new Error("invalid or duplicated pinned key");
377 seen.add(key.keyId);
378 }
379 return keys;
380 }
381
382 /** Deliberately narrow syntax: a changed/unparseable table must fail the gate. */
383 export function parseTsKeys(text) {
384 const source = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
385 const tables = [...source.matchAll(/^\s*export\s+const\s+TRUSTED_KEYS\s*:\s*readonly\s+TrustedKey\[\]\s*=\s*\[([\s\S]*?)\]\s*;/gm)];
386 if (tables.length !== 1) throw new Error("cannot parse exactly one TypeScript TRUSTED_KEYS table");
387 const table = tables[0];
388 const body = table[1].replace(/^\s*\/\/.*$/gm, "");
389 const keys = [];
390 const remainder = body.replace(/\{\s*keyId:\s*"([^"]+)",\s*publicKey:\s*"([^"]+)",\s*status:\s*"([^"]+)"\s*,?\s*\}/g, (_, keyId, publicKey, status) => {
391 keys.push({ keyId, publicKey, status });
392 return "";
393 });
394 if (remainder.replace(/[\s,]/g, "")) throw new Error("unparsed TypeScript TRUSTED_KEYS entry");
395 return validateTrustedKeys(keys);
396 }
397
398 function loadTrustedKeysFromRepo() {
399 const keys = parseTsKeys(readBoundedFile(resolve(WEB_ROOT, "lib/cloud-facts/keys.ts"), 64 * 1024).toString("utf8"));
400 return new Map(keys.map((key) => [key.keyId, key]));
401 }
402
403 export function activePublishingKey(envelope, keys, now = Date.now()) {
404 const key = validateTrustedKeys(keys).find((key) => key.keyId === envelope.key_id && key.status === "active");
405 if (!key) throw new Error("primary signing key is not pinned and active; refusing publication");
406 const check = verifyEnvelope(envelope, key.publicKey);
407 if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`);
408 if (!Number.isFinite(now) || utcTime(check.payload.published_at) > now + 300_000 ||
409 (check.payload.not_after != null && utcTime(check.payload.not_after) <= now)) throw new Error("publication timestamp is future or expired");
410 return { key, check };
411 }
412
413 function refuseUnderCi() {
414 for (const marker of CI_MARKERS) {
415 if (process.env[marker] && !/^(0|false|no|off)$/i.test(process.env[marker])) {
416 throw new Error(`refusing to run with a secret under CI (${marker} is set); publish from the founder's machine`);
417 }
418 }
419 }
420
421 function sqlLiteral(value) {
422 if (value === null || value === undefined) return "null";
423 return `'${String(value).replace(/'/g, "''")}'`;
424 }
425
426 export function emitSql(envelope, { publishedBy = "", publicKeyB64, notes = "" }) {
427 if (!publicKeyB64) throw new Error("public key required to emit the facts_key row");
428 const check = verifyEnvelope(envelope, publicKeyB64);
429 if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`);
430 const payloadJson = Buffer.from(envelope.payload_b64, "base64").toString("utf8");
431 return [
432 "begin;",
433 `insert into public.facts_key (key_id, scope, algorithm, public_key, status)`,
434 ` values (${sqlLiteral(envelope.key_id)}, 'global', 'ed25519', ${sqlLiteral(publicKeyB64)}, 'active')`,
435 ` on conflict (key_id) do nothing;`,
436 `insert into public.facts_release (channel_id, facts_version, schema_version, envelope_version, applies_to, key_id, payload_b64, sig_b64, sigs, payload, published_at, not_after, published_by, notes)`,
437 ` select c.id, ${envelope.facts_version}, ${envelope.schema_version}, ${envelope.envelope}, ${sqlLiteral(envelope.applies_to)}, ${sqlLiteral(envelope.key_id)},`,
438 ` ${sqlLiteral(envelope.payload_b64)}, ${sqlLiteral(envelope.sig_b64)}, ${sqlLiteral(JSON.stringify(envelope.sigs ?? []))}::jsonb,`,
439 ` ${sqlLiteral(payloadJson)}::jsonb, ${sqlLiteral(envelope.published_at)}::timestamptz, ${sqlLiteral(check.payload.not_after ?? null)}::timestamptz,`,
440 ` ${sqlLiteral(publishedBy)}, ${sqlLiteral(notes)}`,
441 ` from public.facts_channel c where c.scope = 'global' and c.slug = ${sqlLiteral(envelope.channel)};`,
442 "commit;",
443 "",
444 ].join("\n");
445 }
446
447 async function postgrest(path, { method = "GET", body, prefer } = {}) {
448 refuseUnderCi();
449 const url = process.env.SUPABASE_URL;
450 const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY;
451 if (!url || !key) throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required");
452 const endpoint = new URL(url);
453 if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error("invalid Supabase endpoint");
454 const res = await fetch(`${url.replace(/\/$/, "")}/rest/v1/${path}`, {
455 method,
456 signal: AbortSignal.timeout(30_000),
457 redirect: "error",
458 headers: {
459 apikey: key,
460 Authorization: `Bearer ${key}`,
461 "Content-Type": "application/json",
462 ...(prefer ? { Prefer: prefer } : {}),
463 },
464 body: body === undefined ? undefined : JSON.stringify(body),
465 });
466 if (!res.ok) { await res.body?.cancel(); throw new Error(`PostgREST request failed (HTTP ${res.status})`); }
467 const text = await readBoundedResponse(res);
468 return text ? JSON.parse(text) : null;
469 }
470
471 export async function readBoundedResponse(response, maxBytes = MAX_ENVELOPE_BYTES) {
472 const length = response.headers.get("content-length");
473 if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) {
474 await response.body?.cancel();
475 throw new Error("response exceeds size limit or has invalid length");
476 }
477 if (!response.body) return "";
478 const reader = response.body.getReader();
479 const chunks = [];
480 let size = 0;
481 try {
482 while (true) {
483 const { value, done } = await reader.read();
484 if (done) break;
485 size += value.byteLength;
486 if (size > maxBytes) throw new Error("response exceeds size limit");
487 chunks.push(value);
488 }
489 } catch (error) { try { await reader.cancel(); } catch { /* Keep rejection. */ } throw error; }
490 finally { reader.releaseLock(); }
491 return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, size));
492 }
493
494 function readJson(path) {
495 return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(readBoundedFile(path)));
496 }
497
498 function nowIso() {
499 return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
500 }
501
502 async function main(argv) {
503 const { positional, flags } = parseArgs(argv);
504 const cmd = positional[0];
505 if (!cmd || flags.help) {
506 console.log(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 26).join("\n"));
507 return 0;
508 }
509 if (cmd === "keygen") {
510 const keyId = String(flags["key-id"] ?? "");
511 if (!KEY_ID_RE.test(keyId)) throw new Error("--key-id must match cwf-[a-z0-9-]{1,32}");
512 const out = flags.out ? resolve(String(flags.out)) : null;
513 if (!out) throw new Error("--out <path> is required (write the private key OUTSIDE any repository)");
514 refuseUnderCi();
515 const { privateKey, publicKey } = generateKeyPairSync("ed25519");
516 mkdirSync(dirname(out), { recursive: true, mode: 0o700 });
517 const fd = openSync(out, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
518 try { writeFileSync(fd, privateKey.export({ type: "pkcs8", format: "pem" })); }
519 finally { closeSync(fd); }
520 const raw = rawPublicKeyFromKeyObject(publicKey);
521 console.log(JSON.stringify({
522 key_id: keyId,
523 algorithm: "ed25519",
524 public_key_b64: raw.toString("base64"),
525 public_key_bytes: [...raw],
526 private_key_file: out,
527 note: "Private key written with mode 0600. Move it into custody (password manager); never commit it.",
528 }, null, 2));
529 return 0;
530 }
531 if (cmd === "sign") {
532 refuseUnderCi();
533 const sourcePath = resolve(String(flags.source ?? resolve(REPO_ROOT, "docs/cloud-facts/stable.json")));
534 const source = readJson(sourcePath);
535 const channel = String(flags.channel ?? source.channel ?? "stable");
536 if (!CHANNEL_RE.test(channel)) throw new Error("bad channel slug");
537 const factsVersion = Number(flags["facts-version"] ?? source.facts_version);
538 if (!Number.isSafeInteger(factsVersion) || factsVersion <= 0) throw new Error("--facts-version (or source.facts_version) must be a positive integer");
539 const keyId = String(flags["key-id"] ?? "");
540 const privateKey = loadPrivateKeyFromEnv();
541 const publishedAt = String(flags["published-at"] ?? nowIso());
542 const payload = buildPayload(source, { channel, factsVersion, publishedAt });
543 const envelope = buildEnvelope({ privateKey, keyId, payload });
544 const text = `${JSON.stringify(envelope, null, 2)}\n`;
545 if (flags.out) {
546 writeFileSync(resolve(String(flags.out)), text);
547 console.error(`wrote ${flags.out} (channel=${channel} facts_version=${factsVersion} key_id=${keyId} sha256=${envelope.sha256})`);
548 } else process.stdout.write(text);
549 return 0;
550 }
551 if (cmd === "verify") {
552 const envelope = readJson(resolve(String(positional[1] ?? "")));
553 let pub = flags["public-key"];
554 if (!pub) {
555 const trusted = loadTrustedKeysFromRepo().get(envelope.key_id);
556 if (!trusted || trusted.status !== "active") throw new Error("key is not pinned and active; use --public-key only for explicit offline verification");
557 pub = trusted.publicKey;
558 }
559 const result = verifyEnvelope(envelope, String(pub));
560 console.log(JSON.stringify({ ok: result.ok, errors: result.errors, channel: envelope.channel, facts_version: envelope.facts_version, key_id: envelope.key_id, sha256: result.sha256 ?? null }, null, 2));
561 return result.ok ? 0 : 1;
562 }
563 if (cmd === "emit-sql") {
564 const envelope = readJson(resolve(String(positional[1] ?? "")));
565 let pub = flags["public-key"];
566 if (!pub) pub = activePublishingKey(envelope, [...loadTrustedKeysFromRepo().values()]).key.publicKey;
567 process.stdout.write(emitSql(envelope, { publishedBy: String(flags["published-by"] ?? ""), publicKeyB64: pub ? String(pub) : undefined, notes: String(flags.notes ?? "") }));
568 return 0;
569 }
570 if (cmd === "publish") {
571 const envelope = readJson(resolve(String(positional[1] ?? "")));
572 if (flags["public-key"] !== undefined) throw new Error("--public-key is only for offline verify/emit-sql; publication requires the active pinned table");
573 const { key, check } = activePublishingKey(envelope, [...loadTrustedKeysFromRepo().values()]);
574 const pub = key.publicKey;
575 const row = {
576 facts_version: envelope.facts_version,
577 schema_version: envelope.schema_version,
578 envelope_version: envelope.envelope,
579 applies_to: envelope.applies_to,
580 key_id: envelope.key_id,
581 payload_b64: envelope.payload_b64,
582 sig_b64: envelope.sig_b64,
583 sigs: envelope.sigs ?? [],
584 payload: check.payload,
585 published_at: envelope.published_at,
586 not_after: check.payload.not_after ?? null,
587 published_by: String(flags["published-by"] ?? ""),
588 notes: String(flags.notes ?? ""),
589 };
590 if (flags["dry-run"]) {
591 console.log(JSON.stringify({ dry_run: true, channel: envelope.channel, facts_key: { key_id: envelope.key_id, public_key: pub }, facts_release: { ...row, payload_b64: `<${envelope.payload_b64.length} chars>` } }, null, 2));
592 return 0;
593 }
594 const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(envelope.channel)}&select=id`);
595 if (!channels?.length) throw new Error(`channel ${envelope.channel} does not exist`);
596 await postgrest("facts_key", { method: "POST", body: { key_id: envelope.key_id, scope: "global", algorithm: "ed25519", public_key: pub, status: "active" }, prefer: "resolution=ignore-duplicates,return=minimal" });
597 const inserted = await postgrest("facts_release", { method: "POST", body: { ...row, channel_id: channels[0].id }, prefer: "return=representation" });
598 console.log(JSON.stringify({ published: true, channel: envelope.channel, facts_version: envelope.facts_version, release_id: inserted?.[0]?.id ?? null, payload_sha256: inserted?.[0]?.payload_sha256 ?? null }, null, 2));
599 return 0;
600 }
601 if (cmd === "revoke") {
602 const channel = String(flags.channel ?? "");
603 const version = Number(flags.version);
604 const reason = String(flags.reason ?? "");
605 if (!CHANNEL_RE.test(channel) || !Number.isSafeInteger(version) || version <= 0 || !reason) throw new Error("--channel, --version and --reason are required");
606 if (flags["dry-run"]) {
607 console.log(JSON.stringify({ dry_run: true, channel, facts_version: version, status: "revoked", revoke_reason: reason }, null, 2));
608 return 0;
609 }
610 const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(channel)}&select=id`);
611 if (!channels?.length) throw new Error(`channel ${channel} does not exist`);
612 const updated = await postgrest(`facts_release?channel_id=eq.${channels[0].id}&facts_version=eq.${version}`, {
613 method: "PATCH",
614 body: { status: "revoked", revoked_at: nowIso(), revoke_reason: reason },
615 prefer: "return=representation",
616 });
617 console.log(JSON.stringify({ revoked: updated?.length ?? 0, channel, facts_version: version }, null, 2));
618 return 0;
619 }
620 throw new Error(`unknown command ${cmd}`);
621 }
622
623 const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
624 if (invokedDirectly) {
625 main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
626 console.error(`facts-publish: ${err.message}`);
627 process.exit(1);
628 });
629 }
630
630 lines Plain Text