From 36d88f036aa7dd74b3221563127dfaf66b2afd17 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:33:50 -0700 Subject: [PATCH] fix(selfhost): fail preflight on half-configured ledger anchoring instead of silently skipping it Anchoring has nine LOOPOVER_LEDGER_ANCHOR_* vars, all documented in env.d.ts and all genuinely read, and not one of them appeared in any self-host preflight or config-lint. An operator got zero boot-time guidance. That matters more than a normal missing-config warning because the failure is completely silent: runScheduledLedgerAnchor logs ledger_anchor_skipped_unconfigured and returns, while the job keeps firing every couple of minutes doing two queries and nothing else. A self-host container DOES run the scheduler and DOES have a populated decision_ledger, so it is genuinely one keypair away from working -- and nothing tells the operator that. Anchoring stays OPT-IN: configuring none of it is deliberately not a problem, and there is an invariant test pinning that so a future edit cannot turn an optional feature into a boot requirement. What now fails preflight is PARTIAL configuration, every case of which silently disables anchoring while looking configured: - a published key list with no private half, or a private key with nothing published (anchors would be unverifiable) - a key list that parses to zero usable entries -- malformed JSON and entries missing a required field are both dropped silently at runtime - no entry with notAfter: null, or MORE than one: currentAnchorKey fails closed on an ambiguous rotation rather than guessing which key signs - a git owner/repo without an installation id (no write token can be minted, so job-dispatch resolves submitGit to null), a non-positive-integer id, or half a git target The key checks call the real parseAnchorPublicKeys/currentAnchorKey rather than re-validating the shape locally, so preflight can never disagree with what the scheduler will actually do -- a second hand-written copy of those rules is exactly how this drifts back apart. Regenerating the self-host env reference is a side benefit worth naming: because preflight now reads these vars under src/selfhost/**, all five appear in the generated operator-facing env documentation for the first time. Closes #9769 --- .../src/lib/selfhost-env-reference.ts | 25 +++++ src/selfhost/preflight.ts | 80 +++++++++++++++ test/unit/selfhost-preflight.test.ts | 98 +++++++++++++++++++ 3 files changed, 203 insertions(+) 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([]); + }); +});