diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index c3d6d1f589..d9312ad86c 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -281,6 +281,26 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER", firstReference: "src/selfhost/ai.ts", }, + { + name: "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID", + firstReference: "src/selfhost/preflight.ts", + }, + { + name: "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER", + firstReference: "src/selfhost/preflight.ts", + }, + { + name: "LOOPOVER_LEDGER_ANCHOR_GIT_REPO", + firstReference: "src/selfhost/preflight.ts", + }, + { + name: "LOOPOVER_LEDGER_ANCHOR_KEYS", + firstReference: "src/selfhost/preflight.ts", + }, + { + name: "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY", + firstReference: "src/selfhost/preflight.ts", + }, { name: "LOOPOVER_MCP_TOKEN", firstReference: "src/selfhost/preflight.ts", @@ -759,6 +779,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `LOOPOVER_CENTRAL_POSTHOG_KEY` | `src/selfhost/posthog.ts` |", "| `LOOPOVER_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |", "| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |", + "| `LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_LEDGER_ANCHOR_GIT_OWNER` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_LEDGER_ANCHOR_GIT_REPO` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_LEDGER_ANCHOR_KEYS` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_METRICS_REPO_LABELS` | `src/server.ts` |", "| `LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS` | `src/selfhost/inert-config.ts` |", diff --git a/src/selfhost/preflight.ts b/src/selfhost/preflight.ts index a2d664f7d7..5b69153bdc 100644 --- a/src/selfhost/preflight.ts +++ b/src/selfhost/preflight.ts @@ -1,5 +1,6 @@ import { createPrivateKey } from "node:crypto"; import { CRON_INTERVAL_MIN_MS } from "./cron-alignment"; +import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor"; export type SelfHostPreflightProblem = { var: string; @@ -70,6 +71,83 @@ function isGitHubAppPrivateKey(value: string): boolean { } } +/** + * Ledger anchoring (#9769) is OPT-IN, so "nothing configured" is deliberately not a problem. What IS a + * problem is PARTIAL configuration, because it fails completely silently: runScheduledLedgerAnchor logs + * `ledger_anchor_skipped_unconfigured` and returns (ledger-anchor-scheduler.ts), while the job keeps firing + * every couple of minutes. An operator who set half the vars has no signal at all that anchoring never runs + * -- and a self-host container DOES run the scheduler and DOES have a populated decision_ledger, so it is + * genuinely one keypair away from working. + * + * The published-key checks call the real `parseAnchorPublicKeys`/`currentAnchorKey` rather than + * re-validating the shape here, so preflight can never disagree with what the scheduler will actually do. + */ +function checkLedgerAnchorConfig(problems: SelfHostPreflightProblem[], env: SelfHostPreflightEnv): void { + const rawKeys = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_KEYS"]); + const privateKey = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY"]); + + if (rawKeys && !privateKey) { + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY", + "LOOPOVER_LEDGER_ANCHOR_KEYS is set but the private half is not, so anchoring silently never runs. Set the P-256 PKCS8 private key, or unset LOOPOVER_LEDGER_ANCHOR_KEYS to disable anchoring.", + ); + } + if (privateKey && !rawKeys) { + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_KEYS", + "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY is set but no public key list is published, so anchors would be unverifiable and anchoring silently never runs. Publish the matching key, or unset the private key to disable anchoring.", + ); + } + if (rawKeys) { + const keys = parseAnchorPublicKeys(rawKeys); + if (keys.length === 0) { + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_KEYS", + "No usable entries parsed. Expected a JSON array of { keyId, publicKeySpki, notBefore, notAfter }; malformed JSON and entries missing any required field are dropped silently at runtime.", + ); + } else if (currentAnchorKey(keys) === null) { + const current = keys.filter((key) => key.notAfter === null).length; + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_KEYS", + current === 0 + ? "No entry has notAfter: null, so there is no current signing key and anchoring silently never runs. Exactly one entry must be open-ended." + : `${current} entries have notAfter: null. An ambiguous rotation fails closed rather than guessing which key signs, so anchoring silently never runs. Exactly one entry must be open-ended.`, + ); + } + } + + // The git backend is independently optional, but owner+repo without an installation id means job-dispatch + // resolves `submitGit` to null and the backend is skipped without comment. + const gitOwner = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_OWNER"]); + const gitRepo = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_REPO"]); + const installationId = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID"]); + if (gitOwner && gitRepo && !installationId) { + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID", + "A git anchor target is configured but no installation id is set, so no write token can be minted and the git backend is skipped silently. Set the installation id, or unset the git owner/repo.", + ); + } + if (installationId && !/^[1-9][0-9]*$/.test(installationId)) { + addProblem( + problems, + "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID", + "Must be a positive integer; any other value resolves to no git submitter and the backend is skipped silently.", + ); + } + if ((gitOwner && !gitRepo) || (gitRepo && !gitOwner)) { + addProblem( + problems, + gitOwner ? "LOOPOVER_LEDGER_ANCHOR_GIT_REPO" : "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER", + "A git anchor target needs BOTH owner and repo; with only one set the backend is skipped silently.", + ); + } +} + function addProblem( problems: SelfHostPreflightProblem[], name: string, @@ -266,6 +344,8 @@ export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult positiveInteger(problems, env, "PORT", 1, 65_535); positiveInteger(problems, env, "GITHUB_CACHE_TTL_SECONDS", 0, 86_400); + checkLedgerAnchorConfig(problems, env); + return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems }; } diff --git a/test/unit/selfhost-preflight.test.ts b/test/unit/selfhost-preflight.test.ts index 6396ba118c..97ea827563 100644 --- a/test/unit/selfhost-preflight.test.ts +++ b/test/unit/selfhost-preflight.test.ts @@ -440,3 +440,101 @@ describe("self-host environment preflight (#2080)", () => { ); }); }); + +describe("ledger-anchor configuration preflight (#9769)", () => { + const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const base = { REDIS_URL: "redis://redis:6379", GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: privateKey }; + const key = (over: Record = {}) => JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null, ...over }]); + const anchorProblems = (env: Record) => { + const result = preflightEnv({ ...base, ...env }); + return result.problems.filter((p: SelfHostPreflightProblem) => p.var.startsWith("LOOPOVER_LEDGER_ANCHOR")); + }; + + it("INVARIANT: anchoring is opt-in — configuring none of it is never a problem", () => { + expect(preflightEnv(base)).toEqual({ ok: true, problems: [] }); + }); + + it("accepts a fully configured Rekor-only setup", () => { + expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key(), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([]); + }); + + it("REGRESSION: catches a published key with no private half — silently disables anchoring today", () => { + expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key() })).toEqual([ + expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY" }), + ]); + }); + + it("REGRESSION: catches a private key with nothing published — anchors would be unverifiable", () => { + expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([ + expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_KEYS" }), + ]); + }); + + it("catches a key list that parses to nothing (malformed JSON, or entries missing required fields)", () => { + for (const raw of ["not json", "{}", "[]", JSON.stringify([{ keyId: "k1" }])]) { + expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: raw, LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([ + expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_KEYS", message: expect.stringContaining("No usable entries") }), + ]); + } + }); + + it("catches a key list with no open-ended entry — no current signing key", () => { + const problems = anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key({ notAfter: "2026-06-01T00:00:00.000Z" }), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" }); + expect(problems[0]?.message).toContain("No entry has notAfter: null"); + }); + + it("catches an AMBIGUOUS rotation — more than one open-ended entry fails closed at runtime", () => { + const twoOpen = JSON.stringify([ + { keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }, + { keyId: "k2", publicKeySpki: "c3BraR==", notBefore: "2026-02-01T00:00:00.000Z", notAfter: null }, + ]); + const problems = anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: twoOpen, LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" }); + expect(problems[0]?.message).toContain("2 entries have notAfter: null"); + }); + + it("catches a git target with no installation id — the backend is skipped silently", () => { + expect( + anchorProblems({ + LOOPOVER_LEDGER_ANCHOR_KEYS: key(), + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem", + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", + LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors", + }), + ).toEqual([expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID" })]); + }); + + it("catches a non-positive-integer installation id", () => { + for (const bad of ["0", "-1", "abc", "1.5"]) { + const problems = anchorProblems({ + LOOPOVER_LEDGER_ANCHOR_KEYS: key(), + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem", + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", + LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors", + LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: bad, + }); + expect(problems.some((p) => p.message.includes("positive integer"))).toBe(true); + } + }); + + it("catches half a git target in either direction", () => { + const shared = { LOOPOVER_LEDGER_ANCHOR_KEYS: key(), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem", LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: "42" }; + expect(anchorProblems({ ...shared, LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme" })).toEqual([ + expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_REPO" }), + ]); + expect(anchorProblems({ ...shared, LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors" })).toEqual([ + expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER" }), + ]); + }); + + it("accepts a fully configured git + Rekor setup", () => { + expect( + anchorProblems({ + LOOPOVER_LEDGER_ANCHOR_KEYS: key(), + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem", + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", + LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors", + LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: "42", + }), + ).toEqual([]); + }); +});