From 9e83f8184b38ba3a1080284fca8d05c5a3a7d315 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sun, 30 Aug 2026 11:48:20 -0700 Subject: [PATCH 1/7] feat(facts): signed cloud facts authoring tool, stable source, and test fixtures Add web/scripts/facts-publish.mjs (zero-dependency Node: keygen, sign, verify, emit-sql, publish, revoke; private key and service key only from env at run time, refuses under CI) and docs/cloud-facts/stable.json as the human-edited source of truth for the facts/v1 stable channel. Signing contract: Ed25519 over "codewhale-facts/v1\0" || key_id || "\0" || canonical-JSON payload. docs/cloud-facts/fixtures/ carries a deliberately public TEST-ONLY keypair and two envelopes signed with it (v7 all-sections, v8 applies_to >=99.0.0) shared by the Rust and web test suites; the test-only key is never pinned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014aDEyM2a4pPZ9qqMDrP5YX Signed-off-by: CodeWhale Bot --- .../fixtures/envelope-future-only-v8.json | 14 + .../fixtures/envelope-stable-v7.json | 14 + docs/cloud-facts/fixtures/source-fixture.json | 27 + .../fixtures/source-future-only.json | 7 + .../fixtures/test-only-signing-key.pem | 3 + docs/cloud-facts/fixtures/test-only.pub | 1 + docs/cloud-facts/stable.json | 32 ++ web/scripts/facts-publish.mjs | 498 ++++++++++++++++++ 8 files changed, 596 insertions(+) create mode 100644 docs/cloud-facts/fixtures/envelope-future-only-v8.json create mode 100644 docs/cloud-facts/fixtures/envelope-stable-v7.json create mode 100644 docs/cloud-facts/fixtures/source-fixture.json create mode 100644 docs/cloud-facts/fixtures/source-future-only.json create mode 100644 docs/cloud-facts/fixtures/test-only-signing-key.pem create mode 100644 docs/cloud-facts/fixtures/test-only.pub create mode 100644 docs/cloud-facts/stable.json create mode 100644 web/scripts/facts-publish.mjs diff --git a/docs/cloud-facts/fixtures/envelope-future-only-v8.json b/docs/cloud-facts/fixtures/envelope-future-only-v8.json new file mode 100644 index 0000000000..48272a1922 --- /dev/null +++ b/docs/cloud-facts/fixtures/envelope-future-only-v8.json @@ -0,0 +1,14 @@ +{ + "envelope": 1, + "channel": "stable", + "facts_version": 8, + "schema_version": 1, + "key_id": "cwf-test-only", + "alg": "ed25519", + "applies_to": ">=99.0.0", + "published_at": "2026-08-30T00:00:00Z", + "payload_b64": "eyJhbm5vdW5jZW1lbnRzIjpbXSwiYXBwbGllc190byI6Ij49OTkuMC4wIiwiY2hhbm5lbCI6InN0YWJsZSIsImZhY3RzX3ZlcnNpb24iOjgsIm1vZGVscyI6W10sInByb3ZpZGVyX2RlZmF1bHRzIjp7fSwicHVibGlzaGVkX2F0IjoiMjAyNi0wOC0zMFQwMDowMDowMFoiLCJyZWxlYXNlIjp7ImxhdGVzdCI6Ijk5LjAuMCJ9LCJzY2hlbWFfdmVyc2lvbiI6MX0=", + "sig_b64": "xJ12O6H7Aw32DH5ox0QOFXF7FhJza0u0r0YaQSeIrx2pZbijy0Nlya1hTIG/blVdj3ERTMBIyNCTwWUFHmRRAw==", + "sigs": [], + "sha256": "5f5709697d99710f3c0e6588ff19e67a16c04b63bf0d81a835f3980b0ab6cdef" +} diff --git a/docs/cloud-facts/fixtures/envelope-stable-v7.json b/docs/cloud-facts/fixtures/envelope-stable-v7.json new file mode 100644 index 0000000000..c42458a0b3 --- /dev/null +++ b/docs/cloud-facts/fixtures/envelope-stable-v7.json @@ -0,0 +1,14 @@ +{ + "envelope": 1, + "channel": "stable", + "facts_version": 7, + "schema_version": 1, + "key_id": "cwf-test-only", + "alg": "ed25519", + "applies_to": "*", + "published_at": "2026-08-30T00:00:00Z", + "payload_b64": "eyJhbm5vdW5jZW1lbnRzIjpbeyJpZCI6ImZpeHR1cmUtbGl2ZSIsImxldmVsIjoiaW5mbyIsInN1cmZhY2VzIjpbInR1aSJdLCJ0ZXh0IjoiZml4dHVyZSBhbm5vdW5jZW1lbnQifSx7ImV4cGlyZXNfYXQiOiIyMDAwLTAxLTAxVDAwOjAwOjAwWiIsImlkIjoiZml4dHVyZS1leHBpcmVkIiwibGV2ZWwiOiJ3YXJuIiwidGV4dCI6ImV4cGlyZWQifSx7ImlkIjoiZml4dHVyZS1mdXR1cmUiLCJsZXZlbCI6ImluZm8iLCJzdGFydHNfYXQiOiIyOTk5LTAxLTAxVDAwOjAwOjAwWiIsInRleHQiOiJub3QgeWV0In0seyJhcHBsaWVzX3RvIjoiPj05OS4wLjAiLCJpZCI6ImZpeHR1cmUtb3RoZXItYnVpbGQiLCJsZXZlbCI6ImluZm8iLCJ0ZXh0Ijoib3RoZXIgYnVpbGQifV0sImFwcGxpZXNfdG8iOiIqIiwiY2hhbm5lbCI6InN0YWJsZSIsImZhY3RzX3ZlcnNpb24iOjcsIm1vZGVscyI6W3siY29udGV4dF93aW5kb3ciOjI2MjE0NCwiaWQiOiJkZWVwc2Vlay12NC1wcm8iLCJtYXhfb3V0cHV0IjozMjc2OCwibm90ZSI6ImZpeHR1cmUgcGF0Y2giLCJvcCI6InVwc2VydCIsInByb3ZpZGVyIjoiZGVlcHNlZWsifSx7ImNvbnRleHRfd2luZG93IjoxMjgwMDAsImlkIjoiZml4dHVyZS1uZXctbW9kZWwiLCJvcCI6InVwc2VydCIsInByb3ZpZGVyIjoiZGVlcHNlZWsiLCJyZWFzb25pbmciOnRydWV9LHsiaWQiOiJmaXh0dXJlLW5lZWRzLWNvbnRleHQiLCJub3RlIjoibm8gY29udGV4dF93aW5kb3c6IHNraXBwZWQiLCJvcCI6InVwc2VydCIsInByb3ZpZGVyIjoiZGVlcHNlZWsifSx7ImRlcHJlY2F0ZWRfYXQiOiIyMDI2LTA5LTAxIiwiaWQiOiJkZWVwc2Vlay1jaGF0Iiwib3AiOiJkZXByZWNhdGUiLCJwcm92aWRlciI6ImRlZXBzZWVrIiwicmVwbGFjZW1lbnQiOiJkZWVwc2Vlay12NC1wcm8ifSx7ImlkIjoiZGVlcHNlZWstcmVhc29uZXIiLCJvcCI6ImhpZGUiLCJwcm92aWRlciI6ImRlZXBzZWVrIn0seyJhcHBsaWVzX3RvIjoiPj05OS4wLjAiLCJjb250ZXh0X3dpbmRvdyI6MSwiaWQiOiJmdXR1cmUtb25seSIsIm9wIjoidXBzZXJ0IiwicHJvdmlkZXIiOiJvcGVuYWkifV0sInByb3ZpZGVyX2RlZmF1bHRzIjp7ImRlZXBzZWVrIjp7ImJhc2VfdXJsIjoiaHR0cHM6Ly9hcGkuZGVlcHNlZWsuY29tL2JldGEiLCJkZWZhdWx0X21vZGVsIjoiZGVlcHNlZWstdjQtcHJvIn0sIm9sbGFtYSI6eyJiYXNlX3VybCI6Imh0dHBzOi8vbG9jYWxob3N0OjExNDM0L3YxIn0sIm9wZW5haSI6eyJiYXNlX3VybCI6Imh0dHBzOi8vZXZpbC5leGFtcGxlL3YxIn19LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTA4LTMwVDAwOjAwOjAwWiIsInJlbGVhc2UiOnsibGF0ZXN0IjoiMC45LjExIiwibm90aWNlIjoiZml4dHVyZSBub3RpY2UiLCJ5YW5rZWQiOlsiMC45LjEwIl19LCJzY2hlbWFfdmVyc2lvbiI6MX0=", + "sig_b64": "0ks1/j558IzFOLIa2Sh6TuBtlZB1CEMYjN/YBK+N5CJz6XGduOTlnvprBIRabMZKKwFbsbSxQ8rQ2jICIuyvBg==", + "sigs": [], + "sha256": "b33d24d9bab42dfe098c4eaaadc70fa6bf19fefa5a0fc59d008e230b3710fdfd" +} diff --git a/docs/cloud-facts/fixtures/source-fixture.json b/docs/cloud-facts/fixtures/source-fixture.json new file mode 100644 index 0000000000..bf6671503f --- /dev/null +++ b/docs/cloud-facts/fixtures/source-fixture.json @@ -0,0 +1,27 @@ +{ + "_meta": { "purpose": "TEST-ONLY fixture source. Signed with docs/cloud-facts/fixtures/test-only-signing-key.pem (key id cwf-test-only), which is deliberately public and never pinned in a shipped binary." }, + "schema_version": 1, + "channel": "stable", + "facts_version": 7, + "applies_to": "*", + "models": [ + { "provider": "deepseek", "id": "deepseek-v4-pro", "op": "upsert", "context_window": 262144, "max_output": 32768, "note": "fixture patch" }, + { "provider": "deepseek", "id": "fixture-new-model", "op": "upsert", "context_window": 128000, "reasoning": true }, + { "provider": "deepseek", "id": "fixture-needs-context", "op": "upsert", "note": "no context_window: skipped" }, + { "provider": "deepseek", "id": "deepseek-chat", "op": "deprecate", "deprecated_at": "2026-09-01", "replacement": "deepseek-v4-pro" }, + { "provider": "deepseek", "id": "deepseek-reasoner", "op": "hide" }, + { "provider": "openai", "id": "future-only", "op": "upsert", "context_window": 1, "applies_to": ">=99.0.0" } + ], + "provider_defaults": { + "deepseek": { "default_model": "deepseek-v4-pro", "base_url": "https://api.deepseek.com/beta" }, + "openai": { "base_url": "https://evil.example/v1" }, + "ollama": { "base_url": "https://localhost:11434/v1" } + }, + "release": { "latest": "0.9.11", "yanked": ["0.9.10"], "notice": "fixture notice" }, + "announcements": [ + { "id": "fixture-live", "level": "info", "text": "fixture announcement", "surfaces": ["tui"] }, + { "id": "fixture-expired", "level": "warn", "text": "expired", "expires_at": "2000-01-01T00:00:00Z" }, + { "id": "fixture-future", "level": "info", "text": "not yet", "starts_at": "2999-01-01T00:00:00Z" }, + { "id": "fixture-other-build", "level": "info", "text": "other build", "applies_to": ">=99.0.0" } + ] +} diff --git a/docs/cloud-facts/fixtures/source-future-only.json b/docs/cloud-facts/fixtures/source-future-only.json new file mode 100644 index 0000000000..a527c3ae6f --- /dev/null +++ b/docs/cloud-facts/fixtures/source-future-only.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "channel": "stable", + "facts_version": 8, + "applies_to": ">=99.0.0", + "release": { "latest": "99.0.0" } +} diff --git a/docs/cloud-facts/fixtures/test-only-signing-key.pem b/docs/cloud-facts/fixtures/test-only-signing-key.pem new file mode 100644 index 0000000000..2713eeb5cc --- /dev/null +++ b/docs/cloud-facts/fixtures/test-only-signing-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIF8UORNdUPizHE1iSVfKptmpkWiPU/U3U5qqDiU9C/KM +-----END PRIVATE KEY----- diff --git a/docs/cloud-facts/fixtures/test-only.pub b/docs/cloud-facts/fixtures/test-only.pub new file mode 100644 index 0000000000..d2b4f2502f --- /dev/null +++ b/docs/cloud-facts/fixtures/test-only.pub @@ -0,0 +1 @@ +8+FLDW4OorUETUVks0hpQAi5Lj4wg3kjKjfYFzLbJ7U= diff --git a/docs/cloud-facts/stable.json b/docs/cloud-facts/stable.json new file mode 100644 index 0000000000..a0dfb998f4 --- /dev/null +++ b/docs/cloud-facts/stable.json @@ -0,0 +1,32 @@ +{ + "_meta": { + "purpose": "Human-edited source of truth for the CodeWhale cloud facts `stable` channel (facts/v1). Sign and publish with `node web/scripts/facts-publish.mjs sign|emit-sql|publish`. `npm run check:facts` validates this file and cross-checks release.latest against web/data/latest-published-release.json.", + "rules": [ + "Bundled facts are the floor: everything here is an additive, verified overlay.", + "Never set provider_defaults to move a user off an explicitly configured model; defaults only apply when nothing is configured.", + "base_url values must be https on the provider's official host family or the client drops them.", + "announcements are one line, <= 200 chars, info|warn only, no actions." + ] + }, + "schema_version": 1, + "channel": "stable", + "facts_version": 1, + "applies_to": ">=0.9.0, <1.0.0", + "models": [], + "provider_defaults": {}, + "release": { + "latest": "0.9.11", + "yanked": [], + "release_url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.11" + }, + "announcements": [ + { + "id": "cloud-facts-dogfood-2026-08", + "level": "info", + "text": "Cloud facts channel is live (dogfood). Bundled facts remain the floor; see docs/CLOUD_FACTS.md.", + "url": "https://github.com/Hmbown/CodeWhale/blob/main/docs/CLOUD_FACTS.md", + "surfaces": ["tui"], + "expires_at": "2026-12-31T00:00:00Z" + } + ] +} diff --git a/web/scripts/facts-publish.mjs b/web/scripts/facts-publish.mjs new file mode 100644 index 0000000000..e10c3c1e7e --- /dev/null +++ b/web/scripts/facts-publish.mjs @@ -0,0 +1,498 @@ +#!/usr/bin/env node +/** + * CodeWhale cloud facts (facts/v1) authoring tool. Zero npm dependencies. + * + * node scripts/facts-publish.mjs keygen --key-id cwf-2026-09 --out /secure/path.key + * node scripts/facts-publish.mjs sign --source ../docs/cloud-facts/stable.json --channel stable \ + * --key-id cwf-2026-09 [--facts-version N] [--out envelope.json] + * node scripts/facts-publish.mjs verify envelope.json [--public-key ] + * node scripts/facts-publish.mjs emit-sql envelope.json [--published-by who] [--public-key ] + * node scripts/facts-publish.mjs publish envelope.json [--dry-run] [--published-by who] + * node scripts/facts-publish.mjs revoke --channel stable --version N --reason "..." [--dry-run] + * + * Secrets are read ONLY from the environment at sign/publish time and are never + * printed: + * CODEWHALE_FACTS_SIGNING_KEY PEM (PKCS#8) Ed25519 private key contents + * CODEWHALE_FACTS_SIGNING_KEY_FILE path to that PEM (alternative) + * SUPABASE_URL https://.supabase.co (publish/revoke) + * SUPABASE_SERVICE_ROLE_KEY service-role key (publish/revoke only; never embed) + * + * Signing contract (must match crates/config/src/cloud_facts/verify.rs and + * web/lib/cloud-facts.ts): Ed25519 detached signature over + * "codewhale-facts/v1\0" || key_id || "\0" || payload_bytes + * where payload_bytes is canonical JSON (sorted keys, no whitespace, UTF-8). + * Clients verify the exact bytes carried in payload_b64; they never re-canonicalize. + */ +import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, createHash } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const DOMAIN = "codewhale-facts/v1\0"; +export const ENVELOPE_VERSION = 1; +export const SCHEMA_VERSION = 1; +export const MAX_PAYLOAD_BYTES = 512 * 1024; +const KEY_ID_RE = /^cwf-[a-z0-9-]{1,32}$/; +const CHANNEL_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; +const CI_MARKERS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "JENKINS_URL", "TF_BUILD"]; + +const here = dirname(fileURLToPath(import.meta.url)); +const WEB_ROOT = resolve(here, ".."); +const REPO_ROOT = resolve(WEB_ROOT, ".."); + +// --------------------------------------------------------------------------- +// Canonical JSON + signing primitives (exported for tests) +// --------------------------------------------------------------------------- + +export function canonicalize(value) { + if (value === null || typeof value !== "object") { + if (typeof value === "number" && !Number.isFinite(value)) { + throw new Error("non-finite number in payload"); + } + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + const keys = Object.keys(value).sort(); + const parts = []; + for (const key of keys) { + const v = value[key]; + if (v === undefined) continue; + parts.push(`${JSON.stringify(key)}:${canonicalize(v)}`); + } + return `{${parts.join(",")}}`; +} + +export function signingMessage(keyId, payloadBytes) { + return Buffer.concat([Buffer.from(DOMAIN, "utf8"), Buffer.from(keyId, "utf8"), Buffer.from([0]), payloadBytes]); +} + +export function rawPublicKeyFromKeyObject(keyObject) { + const spki = keyObject.export({ type: "spki", format: "der" }); + // Ed25519 SPKI DER is a fixed 12-byte prefix followed by the 32-byte key. + return spki.subarray(spki.length - 32); +} + +export function publicKeyObjectFromRaw(rawB64) { + const raw = Buffer.from(rawB64, "base64"); + if (raw.length !== 32) throw new Error("public key must decode to 32 bytes"); + const prefix = Buffer.from("302a300506032b6570032100", "hex"); + return createPublicKey({ key: Buffer.concat([prefix, raw]), type: "spki", format: "der" }); +} + +export function signPayload(privateKey, keyId, payloadBytes) { + return sign(null, signingMessage(keyId, payloadBytes), privateKey); +} + +export function verifyEnvelope(envelope, publicKeyB64) { + const errors = []; + if (envelope.envelope !== ENVELOPE_VERSION) errors.push(`envelope version ${envelope.envelope} != ${ENVELOPE_VERSION}`); + if (envelope.alg !== "ed25519") errors.push(`alg ${envelope.alg} != ed25519`); + if (!KEY_ID_RE.test(String(envelope.key_id))) errors.push("bad key_id"); + const payloadBytes = Buffer.from(String(envelope.payload_b64), "base64"); + if (payloadBytes.length === 0 || payloadBytes.length > MAX_PAYLOAD_BYTES) errors.push("payload size out of range"); + const sig = Buffer.from(String(envelope.sig_b64), "base64"); + if (sig.length !== 64) errors.push("signature must be 64 bytes"); + const sha = createHash("sha256").update(payloadBytes).digest("hex"); + if (envelope.sha256 && envelope.sha256 !== sha) errors.push("sha256 mismatch"); + if (errors.length) return { ok: false, errors }; + let payload; + try { + payload = JSON.parse(payloadBytes.toString("utf8")); + } catch (err) { + return { ok: false, errors: [`payload is not JSON: ${err.message}`] }; + } + for (const field of ["channel", "facts_version", "applies_to", "schema_version"]) { + if (envelope[field] !== undefined && envelope[field] !== payload[field]) { + errors.push(`outer ${field} (${envelope[field]}) != inner (${payload[field]})`); + } + } + if (errors.length) return { ok: false, errors }; + const key = publicKeyObjectFromRaw(publicKeyB64); + const ok = verify(null, signingMessage(envelope.key_id, payloadBytes), key, sig); + if (!ok) return { ok: false, errors: ["bad signature"] }; + return { ok: true, errors: [], payload, sha256: sha }; +} + +// --------------------------------------------------------------------------- +// Source validation (docs/cloud-facts/.json) +// --------------------------------------------------------------------------- + +const MODEL_OPS = new Set(["upsert", "deprecate", "hide"]); +const LEVELS = new Set(["info", "warn"]); +const SURFACES = new Set(["tui", "desktop", "web"]); +const VERSION_REQ_RE = /^(\*|[<>=^~]*\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\s*,\s*[<>=^~]*\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?)*)$/; + +function isPlainObject(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function optString(errors, where, obj, key, max = 500) { + const v = obj[key]; + if (v === undefined || v === null) return; + if (typeof v !== "string" || v.length > max) errors.push(`${where}.${key} must be a string (<= ${max} chars)`); +} + +function optVersionReq(errors, where, obj, key = "applies_to") { + const v = obj[key]; + if (v === undefined || v === null) return; + if (typeof v !== "string" || !VERSION_REQ_RE.test(v.trim())) errors.push(`${where}.${key} is not a semver requirement: ${JSON.stringify(v)}`); +} + +export function validateSource(source) { + const errors = []; + if (!isPlainObject(source)) return ["source must be an object"]; + if (source.schema_version !== undefined && source.schema_version !== SCHEMA_VERSION) { + errors.push(`schema_version must be ${SCHEMA_VERSION}`); + } + if (source.channel !== undefined && !CHANNEL_RE.test(String(source.channel))) errors.push("channel slug invalid"); + if (source.facts_version !== undefined && !(Number.isInteger(source.facts_version) && source.facts_version > 0)) { + errors.push("facts_version must be a positive integer"); + } + optVersionReq(errors, "root", source); + optString(errors, "root", source, "not_after", 40); + const models = source.models ?? []; + if (!Array.isArray(models)) errors.push("models must be an array"); + else { + models.forEach((m, i) => { + const where = `models[${i}]`; + if (!isPlainObject(m)) return errors.push(`${where} must be an object`); + if (typeof m.provider !== "string" || !m.provider) errors.push(`${where}.provider required`); + if (typeof m.id !== "string" || !m.id) errors.push(`${where}.id required`); + if (m.op !== undefined && !MODEL_OPS.has(m.op)) errors.push(`${where}.op must be one of ${[...MODEL_OPS].join("/")}`); + for (const k of ["context_window", "max_output"]) { + if (m[k] !== undefined && !(Number.isInteger(m[k]) && m[k] > 0)) errors.push(`${where}.${k} must be a positive integer`); + } + if (m.pricing !== undefined) { + if (!isPlainObject(m.pricing)) errors.push(`${where}.pricing must be an object`); + else for (const k of Object.keys(m.pricing)) { + if (!["input_per_m", "output_per_m", "cache_read_per_m"].includes(k)) errors.push(`${where}.pricing.${k} unknown`); + else if (typeof m.pricing[k] !== "number" || m.pricing[k] < 0) errors.push(`${where}.pricing.${k} must be a non-negative number`); + } + } + if (m.reasoning !== undefined && typeof m.reasoning !== "boolean") errors.push(`${where}.reasoning must be boolean`); + optString(errors, where, m, "display_name", 120); + optString(errors, where, m, "deprecated_at", 40); + optString(errors, where, m, "replacement", 200); + optString(errors, where, m, "note", 300); + optVersionReq(errors, where, m); + }); + } + const defaults = source.provider_defaults ?? {}; + if (!isPlainObject(defaults)) errors.push("provider_defaults must be an object"); + else for (const [provider, d] of Object.entries(defaults)) { + const where = `provider_defaults.${provider}`; + if (!isPlainObject(d)) { errors.push(`${where} must be an object`); continue; } + optString(errors, where, d, "default_model", 200); + optString(errors, where, d, "base_url", 300); + if (typeof d.base_url === "string" && !/^https:\/\//.test(d.base_url)) errors.push(`${where}.base_url must be https`); + optVersionReq(errors, where, d); + } + if (source.release !== undefined && source.release !== null) { + const r = source.release; + const where = "release"; + if (!isPlainObject(r)) errors.push("release must be an object"); + else { + if (typeof r.latest !== "string" || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(r.latest)) errors.push("release.latest must be a semver version"); + if (r.yanked !== undefined && !(Array.isArray(r.yanked) && r.yanked.every((v) => typeof v === "string"))) errors.push("release.yanked must be a string array"); + optString(errors, where, r, "min_supported", 40); + optString(errors, where, r, "notice", 300); + optString(errors, where, r, "release_url", 300); + optVersionReq(errors, where, r); + } + } + const ann = source.announcements ?? []; + if (!Array.isArray(ann)) errors.push("announcements must be an array"); + else { + const seen = new Set(); + ann.forEach((a, i) => { + const where = `announcements[${i}]`; + if (!isPlainObject(a)) return errors.push(`${where} must be an object`); + if (typeof a.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(a.id)) errors.push(`${where}.id invalid`); + if (seen.has(a.id)) errors.push(`${where}.id duplicated`); + seen.add(a.id); + if (a.level !== undefined && !LEVELS.has(a.level)) errors.push(`${where}.level must be info|warn`); + if (typeof a.text !== "string" || !a.text.trim() || a.text.length > 200) errors.push(`${where}.text required (<= 200 chars)`); + optString(errors, where, a, "url", 300); + if (a.surfaces !== undefined && !(Array.isArray(a.surfaces) && a.surfaces.every((s) => SURFACES.has(s)))) errors.push(`${where}.surfaces invalid`); + optVersionReq(errors, where, a); + optString(errors, where, a, "starts_at", 40); + optString(errors, where, a, "expires_at", 40); + }); + } + const allowed = new Set(["$schema", "_meta", "schema_version", "channel", "facts_version", "published_at", "not_after", "applies_to", "models", "provider_defaults", "release", "announcements"]); + for (const k of Object.keys(source)) if (!allowed.has(k)) errors.push(`unknown top-level field ${k}`); + return errors; +} + +/** Build the signed payload object (no signing) from a source file. */ +export function buildPayload(source, { channel, factsVersion, publishedAt }) { + const errors = validateSource(source); + if (errors.length) throw new Error(`source invalid:\n - ${errors.join("\n - ")}`); + const payload = { + schema_version: SCHEMA_VERSION, + channel, + facts_version: factsVersion, + published_at: publishedAt, + applies_to: typeof source.applies_to === "string" ? source.applies_to.trim() : "*", + models: source.models ?? [], + provider_defaults: source.provider_defaults ?? {}, + release: source.release ?? null, + announcements: source.announcements ?? [], + }; + if (source.not_after) payload.not_after = source.not_after; + return payload; +} + +export function buildEnvelope({ privateKey, keyId, payload }) { + if (!KEY_ID_RE.test(keyId)) throw new Error(`key_id must match ${KEY_ID_RE}`); + const payloadBytes = Buffer.from(canonicalize(payload), "utf8"); + if (payloadBytes.length > MAX_PAYLOAD_BYTES) throw new Error(`payload exceeds ${MAX_PAYLOAD_BYTES} bytes`); + const sig = signPayload(privateKey, keyId, payloadBytes); + const sha256 = createHash("sha256").update(payloadBytes).digest("hex"); + const envelope = { + envelope: ENVELOPE_VERSION, + channel: payload.channel, + facts_version: payload.facts_version, + schema_version: payload.schema_version, + key_id: keyId, + alg: "ed25519", + applies_to: payload.applies_to, + published_at: payload.published_at, + payload_b64: payloadBytes.toString("base64"), + sig_b64: sig.toString("base64"), + sigs: [], + sha256, + }; + const pub = rawPublicKeyFromKeyObject(createPublicKey(privateKey)).toString("base64"); + const check = verifyEnvelope(envelope, pub); + if (!check.ok) throw new Error(`self-verify failed: ${check.errors.join("; ")}`); + return envelope; +} + +// --------------------------------------------------------------------------- +// CLI helpers +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) flags[key] = true; + else { flags[key] = next; i += 1; } + } else positional.push(arg); + } + return { positional, flags }; +} + +function loadPrivateKeyFromEnv() { + let pem = process.env.CODEWHALE_FACTS_SIGNING_KEY; + const file = process.env.CODEWHALE_FACTS_SIGNING_KEY_FILE; + if (!pem && file) pem = readFileSync(file, "utf8"); + if (!pem) throw new Error("set CODEWHALE_FACTS_SIGNING_KEY (PEM) or CODEWHALE_FACTS_SIGNING_KEY_FILE"); + const key = createPrivateKey({ key: pem, format: "pem" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("signing key must be Ed25519"); + return key; +} + +function loadTrustedKeysFromRepo() { + const src = readFileSync(resolve(WEB_ROOT, "lib/cloud-facts/keys.ts"), "utf8"); + const out = new Map(); + const re = /keyId:\s*"([^"]+)",\s*publicKey:\s*"([^"]+)",\s*status:\s*"([^"]+)"/g; + let m; + while ((m = re.exec(src))) out.set(m[1], { publicKey: m[2], status: m[3] }); + return out; +} + +function refuseUnderCi() { + for (const marker of CI_MARKERS) { + if (process.env[marker] && !/^(0|false|no|off)$/i.test(process.env[marker])) { + throw new Error(`refusing to run with a secret under CI (${marker} is set); publish from the founder's machine`); + } + } +} + +function sqlLiteral(value) { + if (value === null || value === undefined) return "null"; + return `'${String(value).replace(/'/g, "''")}'`; +} + +export function emitSql(envelope, { publishedBy = "", publicKeyB64, notes = "" }) { + if (!publicKeyB64) throw new Error("public key required to emit the facts_key row"); + const check = verifyEnvelope(envelope, publicKeyB64); + if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`); + const payloadJson = Buffer.from(envelope.payload_b64, "base64").toString("utf8"); + return [ + "begin;", + `insert into public.facts_key (key_id, scope, algorithm, public_key, status)`, + ` values (${sqlLiteral(envelope.key_id)}, 'global', 'ed25519', ${sqlLiteral(publicKeyB64)}, 'active')`, + ` on conflict (key_id) do nothing;`, + `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)`, + ` select c.id, ${envelope.facts_version}, ${envelope.schema_version}, ${envelope.envelope}, ${sqlLiteral(envelope.applies_to)}, ${sqlLiteral(envelope.key_id)},`, + ` ${sqlLiteral(envelope.payload_b64)}, ${sqlLiteral(envelope.sig_b64)}, ${sqlLiteral(JSON.stringify(envelope.sigs ?? []))}::jsonb,`, + ` ${sqlLiteral(payloadJson)}::jsonb, ${sqlLiteral(envelope.published_at)}::timestamptz, ${sqlLiteral(check.payload.not_after ?? null)}::timestamptz,`, + ` ${sqlLiteral(publishedBy)}, ${sqlLiteral(notes)}`, + ` from public.facts_channel c where c.scope = 'global' and c.slug = ${sqlLiteral(envelope.channel)};`, + "commit;", + "", + ].join("\n"); +} + +async function postgrest(path, { method = "GET", body, prefer } = {}) { + refuseUnderCi(); + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SECRET_KEY; + if (!url || !key) throw new Error("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required"); + const res = await fetch(`${url.replace(/\/$/, "")}/rest/v1/${path}`, { + method, + headers: { + apikey: key, + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + ...(prefer ? { Prefer: prefer } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`PostgREST ${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function nowIso() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +async function main(argv) { + const { positional, flags } = parseArgs(argv); + const cmd = positional[0]; + if (!cmd || flags.help) { + console.log(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 26).join("\n")); + return 0; + } + if (cmd === "keygen") { + const keyId = String(flags["key-id"] ?? ""); + if (!KEY_ID_RE.test(keyId)) throw new Error("--key-id must match cwf-[a-z0-9-]{1,32}"); + const out = flags.out ? resolve(String(flags.out)) : null; + if (!out) throw new Error("--out is required (write the private key OUTSIDE any repository)"); + if (existsSync(out)) throw new Error(`${out} already exists; refusing to overwrite a private key`); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + mkdirSync(dirname(out), { recursive: true, mode: 0o700 }); + writeFileSync(out, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 }); + const raw = rawPublicKeyFromKeyObject(publicKey); + console.log(JSON.stringify({ + key_id: keyId, + algorithm: "ed25519", + public_key_b64: raw.toString("base64"), + public_key_bytes: [...raw], + private_key_file: out, + note: "Private key written with mode 0600. Move it into custody (password manager); never commit it.", + }, null, 2)); + return 0; + } + if (cmd === "sign") { + const sourcePath = resolve(String(flags.source ?? resolve(REPO_ROOT, "docs/cloud-facts/stable.json"))); + const source = readJson(sourcePath); + const channel = String(flags.channel ?? source.channel ?? "stable"); + if (!CHANNEL_RE.test(channel)) throw new Error("bad channel slug"); + const factsVersion = Number(flags["facts-version"] ?? source.facts_version); + if (!Number.isInteger(factsVersion) || factsVersion <= 0) throw new Error("--facts-version (or source.facts_version) must be a positive integer"); + const keyId = String(flags["key-id"] ?? ""); + const privateKey = loadPrivateKeyFromEnv(); + const publishedAt = String(flags["published-at"] ?? nowIso()); + const payload = buildPayload(source, { channel, factsVersion, publishedAt }); + const envelope = buildEnvelope({ privateKey, keyId, payload }); + const text = `${JSON.stringify(envelope, null, 2)}\n`; + if (flags.out) { + writeFileSync(resolve(String(flags.out)), text); + console.error(`wrote ${flags.out} (channel=${channel} facts_version=${factsVersion} key_id=${keyId} sha256=${envelope.sha256})`); + } else process.stdout.write(text); + return 0; + } + if (cmd === "verify") { + const envelope = readJson(resolve(String(positional[1] ?? ""))); + let pub = flags["public-key"]; + if (!pub) { + const trusted = loadTrustedKeysFromRepo().get(envelope.key_id); + if (!trusted) throw new Error(`key_id ${envelope.key_id} is not pinned in web/lib/cloud-facts/keys.ts; pass --public-key`); + pub = trusted.publicKey; + } + const result = verifyEnvelope(envelope, String(pub)); + 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)); + return result.ok ? 0 : 1; + } + if (cmd === "emit-sql") { + const envelope = readJson(resolve(String(positional[1] ?? ""))); + let pub = flags["public-key"]; + if (!pub) pub = loadTrustedKeysFromRepo().get(envelope.key_id)?.publicKey; + process.stdout.write(emitSql(envelope, { publishedBy: String(flags["published-by"] ?? ""), publicKeyB64: pub ? String(pub) : undefined, notes: String(flags.notes ?? "") })); + return 0; + } + if (cmd === "publish") { + const envelope = readJson(resolve(String(positional[1] ?? ""))); + const trusted = loadTrustedKeysFromRepo().get(envelope.key_id); + const pub = flags["public-key"] ? String(flags["public-key"]) : trusted?.publicKey; + if (!pub) throw new Error(`key_id ${envelope.key_id} is not pinned; refusing to publish an unpinned key`); + const check = verifyEnvelope(envelope, pub); + if (!check.ok) throw new Error(`envelope does not verify: ${check.errors.join("; ")}`); + const row = { + facts_version: envelope.facts_version, + schema_version: envelope.schema_version, + envelope_version: envelope.envelope, + applies_to: envelope.applies_to, + key_id: envelope.key_id, + payload_b64: envelope.payload_b64, + sig_b64: envelope.sig_b64, + sigs: envelope.sigs ?? [], + payload: check.payload, + published_at: envelope.published_at, + not_after: check.payload.not_after ?? null, + published_by: String(flags["published-by"] ?? ""), + notes: String(flags.notes ?? ""), + }; + if (flags["dry-run"]) { + 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)); + return 0; + } + const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(envelope.channel)}&select=id`); + if (!channels?.length) throw new Error(`channel ${envelope.channel} does not exist`); + 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" }); + const inserted = await postgrest("facts_release", { method: "POST", body: { ...row, channel_id: channels[0].id }, prefer: "return=representation" }); + 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)); + return 0; + } + if (cmd === "revoke") { + const channel = String(flags.channel ?? ""); + const version = Number(flags.version); + const reason = String(flags.reason ?? ""); + if (!CHANNEL_RE.test(channel) || !Number.isInteger(version) || !reason) throw new Error("--channel, --version and --reason are required"); + if (flags["dry-run"]) { + console.log(JSON.stringify({ dry_run: true, channel, facts_version: version, status: "revoked", revoke_reason: reason }, null, 2)); + return 0; + } + const channels = await postgrest(`facts_channel?scope=eq.global&slug=eq.${encodeURIComponent(channel)}&select=id`); + if (!channels?.length) throw new Error(`channel ${channel} does not exist`); + const updated = await postgrest(`facts_release?channel_id=eq.${channels[0].id}&facts_version=eq.${version}`, { + method: "PATCH", + body: { status: "revoked", revoked_at: nowIso(), revoke_reason: reason }, + prefer: "return=representation", + }); + console.log(JSON.stringify({ revoked: updated?.length ?? 0, channel, facts_version: version }, null, 2)); + return 0; + } + throw new Error(`unknown command ${cmd}`); +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => { + console.error(`facts-publish: ${err.message}`); + process.exit(1); + }); +} From 78639e70997a15868524cbf1cd568d9dd5d74f5b Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Sun, 30 Aug 2026 11:48:23 -0700 Subject: [PATCH 2/7] feat(config): cloud facts verification, scoping, overlay, and catalog layer 15 codewhale_config::cloud_facts is network-free: payload types (schema_version 1), pinned Ed25519 trust anchors (TRUSTED_KEYS; the dogfood key cwf-dogfood-2026-08 generated 2026-08-30, private half held outside every repo), verify_envelope (size cap -> shape -> pinned key -> ring Ed25519 -> parse -> cross-checks -> channel -> schema -> applies_to semver scope -> rollback -> not_after grace), scoped_view (per-item applies_to, announcement windows, https + official-host allowlist for base_url), a process-wide overlay with /status provenance, and field-level catalog patch semantics. CatalogSource::CloudFacts / CatalogCompiler::with_cloud_facts sit at layer 15 (above bundled + live models.dev, below provider /v1/models, config, user). PricingProvenance gains CloudFacts. Cloud default_model is consulted only after CLI/env/config declined, so an explicit choice is never overridden. cargo test -p codewhale-config: 642 passed; 0 failed; 1 ignored. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014aDEyM2a4pPZ9qqMDrP5YX Signed-off-by: CodeWhale Bot --- crates/config/Cargo.toml | 4 + crates/config/src/catalog.rs | 35 +- .../config/src/cloud_facts/catalog_patch.rs | 149 +++++ crates/config/src/cloud_facts/keys.rs | 70 +++ crates/config/src/cloud_facts/mod.rs | 55 ++ crates/config/src/cloud_facts/overlay.rs | 96 +++ crates/config/src/cloud_facts/provenance.rs | 140 +++++ crates/config/src/cloud_facts/scope.rs | 203 ++++++ crates/config/src/cloud_facts/tests.rs | 588 ++++++++++++++++++ crates/config/src/cloud_facts/types.rs | 160 +++++ crates/config/src/cloud_facts/verify.rs | 355 +++++++++++ crates/config/src/lib.rs | 8 +- crates/config/src/pricing.rs | 13 +- 13 files changed, 1870 insertions(+), 6 deletions(-) create mode 100644 crates/config/src/cloud_facts/catalog_patch.rs create mode 100644 crates/config/src/cloud_facts/keys.rs create mode 100644 crates/config/src/cloud_facts/mod.rs create mode 100644 crates/config/src/cloud_facts/overlay.rs create mode 100644 crates/config/src/cloud_facts/provenance.rs create mode 100644 crates/config/src/cloud_facts/scope.rs create mode 100644 crates/config/src/cloud_facts/tests.rs create mode 100644 crates/config/src/cloud_facts/types.rs create mode 100644 crates/config/src/cloud_facts/verify.rs diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index bc3c8ad652..68c74bfdd5 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -12,11 +12,15 @@ workspace = true [dependencies] anyhow.workspace = true +base64 = "0.22.1" codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } codewhale-paths = { path = "../paths", version = "0.9.11" } codewhale-secrets = { path = "../secrets", version = "0.9.11" } fd-lock = "4.0.4" libc = "0.2" +# Ed25519 verification for cloud facts; already in the graph via rustls. +ring = "0.17.14" +semver.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index c37bc5d4f6..5c066ada34 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -61,6 +61,13 @@ pub enum CatalogSource { ModelsDevLive { fetched_at: u64 }, /// `config.toml` `[providers.*]` override (layer 30). ConfigOverride, + /// Verified cloud facts patch (layer 15): above bundled / live models.dev, + /// below provider `/v1/models`, config, and user rows. + CloudFacts { + facts_version: u64, + key_id: String, + fetched_at: u64, + }, } /// One catalog-layer offering row. @@ -627,6 +634,7 @@ impl CatalogSnapshot { /// ```text /// 0 bundled committed models.dev-shaped snapshot /// 10 live models.dev models.dev refresh +/// 15 cloud facts verified, signed field-level patches (off by default) /// 20 provider per-provider /v1/models refresh /// 30 config config.toml [providers.*] overrides /// 40 user user approved set (Phase 3 hook; empty in Phase 1) @@ -641,6 +649,7 @@ pub struct CatalogCompiler { bundled: Vec, models_dev_live: Vec, live: Vec, + cloud_facts: Option<(crate::cloud_facts::ScopedFacts, u64)>, provider_live: Vec, config: Vec, overrides: Vec, @@ -687,6 +696,18 @@ impl CatalogCompiler { self } + /// Apply verified cloud facts patches (layer 15). `fetched_at` stamps the + /// resulting rows' provenance. + #[must_use] + pub fn with_cloud_facts( + mut self, + facts: &crate::cloud_facts::ScopedFacts, + fetched_at: u64, + ) -> Self { + self.cloud_facts = Some((facts.clone(), fetched_at)); + self + } + /// Add per-provider `/v1/models` refresh rows (layer 20). #[must_use] pub fn with_provider_live(mut self, rows: Vec) -> Self { @@ -724,7 +745,19 @@ impl CatalogCompiler { .into_iter() .chain(self.models_dev_live) .chain(self.live) - .chain(self.provider_live) + { + merged.insert(row.merge_key(), row); + } + if let Some((facts, fetched_at)) = &self.cloud_facts { + let _skipped = crate::cloud_facts::catalog_patch::apply_model_patches( + &mut merged, + facts, + *fetched_at, + ); + } + for row in self + .provider_live + .into_iter() .chain(self.config) .chain(self.overrides) { diff --git a/crates/config/src/cloud_facts/catalog_patch.rs b/crates/config/src/cloud_facts/catalog_patch.rs new file mode 100644 index 0000000000..135d365626 --- /dev/null +++ b/crates/config/src/cloud_facts/catalog_patch.rs @@ -0,0 +1,149 @@ +//! Apply cloud model patches to a catalog layer map (layer 15: above bundled +//! and live models.dev, below provider `/v1/models`, config, and user rows). +//! +//! Patch semantics: +//! - `Upsert`: only the fields the patch sets shadow the row; a patch for a +//! row that does not exist is materialized only when it carries a context +//! window (otherwise skipped with a receipt). +//! - `Deprecate`: annotates (the note is carried in `reasoning_options` as a +//! `{"cloud_facts": {...}}` marker); never removes. +//! - `Hide`: removes the row only when it came from the bundled or live +//! models.dev layers. Provider-live/config/user rows are never hidden. + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::scope::ScopedFacts; +use super::types::{ModelFact, ModelOp}; +use crate::catalog::{CatalogOffering, CatalogSource}; +use crate::models_dev::ModelsDevCost; + +/// Merge key used by the catalog compiler. +type Key = (String, String); + +/// Receipt for one patch that changed nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedPatch { + pub provider: String, + pub id: String, + pub reason: String, +} + +/// Apply every model patch in `facts` to `rows`, returning skip receipts. +pub fn apply_model_patches( + rows: &mut BTreeMap, + facts: &ScopedFacts, + fetched_at: u64, +) -> Vec { + let mut skipped = Vec::new(); + let source = CatalogSource::CloudFacts { + facts_version: facts.facts_version, + key_id: facts.key_id.clone(), + fetched_at, + }; + for patch in &facts.models { + let key = (patch.provider.clone(), patch.id.clone()); + match patch.op { + ModelOp::Hide => match rows.get(&key) { + Some(row) + if matches!( + row.source, + CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } + ) => + { + rows.remove(&key); + } + Some(_) => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "hide ignored: row comes from a higher layer".into(), + }), + None => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "hide ignored: no such row".into(), + }), + }, + ModelOp::Deprecate => match rows.get_mut(&key) { + Some(row) => { + annotate(row, patch, "deprecated"); + } + None => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "deprecate ignored: no such row".into(), + }), + }, + ModelOp::Upsert => { + if let Some(row) = rows.get_mut(&key) { + patch_fields(row, patch); + row.source = source.clone(); + } else if patch.context_window.is_some() { + let mut row = CatalogOffering { + provider: patch.provider.clone(), + wire_model_id: patch.id.clone(), + endpoint_key: "chat".to_string(), + source: source.clone(), + ..CatalogOffering::default() + }; + patch_fields(&mut row, patch); + rows.insert(key, row); + } else { + skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "upsert ignored: new row needs context_window".into(), + }); + } + } + } + } + skipped +} + +fn patch_fields(row: &mut CatalogOffering, patch: &ModelFact) { + if patch.context_window.is_some() || patch.max_output.is_some() { + let mut limit = row.limit.clone().unwrap_or_default(); + if let Some(context) = patch.context_window { + limit.context = Some(context); + } + if let Some(output) = patch.max_output { + limit.output = Some(output); + } + row.limit = Some(limit); + } + if let Some(pricing) = &patch.pricing { + let mut cost: ModelsDevCost = row.cost.clone().unwrap_or_default(); + if pricing.input_per_m.is_some() { + cost.input = pricing.input_per_m; + } + if pricing.output_per_m.is_some() { + cost.output = pricing.output_per_m; + } + if pricing.cache_read_per_m.is_some() { + cost.cache_read = pricing.cache_read_per_m; + } + row.cost = Some(cost); + } + if patch.reasoning.is_some() { + row.reasoning = patch.reasoning; + } + if patch.display_name.is_some() || patch.note.is_some() { + annotate(row, patch, "upsert"); + } +} + +fn annotate(row: &mut CatalogOffering, patch: &ModelFact, kind: &str) { + row.reasoning_options + .retain(|value| value.get("cloud_facts").is_none()); + row.reasoning_options.push(json!({ + "cloud_facts": { + "op": kind, + "display_name": patch.display_name, + "deprecated_at": patch.deprecated_at, + "replacement": patch.replacement, + "note": patch.note, + } + })); +} diff --git a/crates/config/src/cloud_facts/keys.rs b/crates/config/src/cloud_facts/keys.rs new file mode 100644 index 0000000000..1db138b567 --- /dev/null +++ b/crates/config/src/cloud_facts/keys.rs @@ -0,0 +1,70 @@ +//! Trust anchors for the cloud facts channel. +//! +//! Keys are pinned in the binary. The Supabase `facts_key` table and the +//! website mirror (`web/lib/cloud-facts/keys.ts`) are informational; a facts +//! envelope is accepted only when its signature verifies under an `Active` key +//! listed here. `web/scripts/check-cloud-facts.mjs` fails CI if this table and +//! the TypeScript mirror diverge. +//! +//! Rotation (two-release rule): pin the new key here → ship → sign with both +//! keys (`sigs`) → mark the old key `Retired` → ship → drop it. Compromise: +//! revoke every release signed by the key server-side, ship a binary without +//! the key. There is deliberately no in-band "distrust this key" message. + +/// Domain separator prefixed to every signed message. +/// +/// Message = `DOMAIN || key_id || 0x00 || payload_bytes`. +pub const DOMAIN: &[u8] = b"codewhale-facts/v1\0"; + +/// Transport envelope version this client understands. +pub const ENVELOPE_VERSION: u64 = 1; + +/// Highest signed-payload `schema_version` this client understands. Newer +/// payloads are rejected as `SchemaTooNew` and the bundled facts stay in use. +pub const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// Hard cap on the decoded payload; enforced before any crypto runs. +pub const MAX_PAYLOAD_BYTES: usize = 512 * 1024; + +/// Hard cap on the raw envelope document (payload base64 + metadata). +pub const MAX_ENVELOPE_BYTES: usize = 768 * 1024; + +/// Whether a pinned key may still authenticate new releases. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStatus { + /// Accepts signatures. + Active, + /// Still listed so `/status` can name it, but no longer accepted. + Retired, +} + +/// One pinned Ed25519 verifying key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrustedKey { + /// `cwf-