Skip to content

Commit 81cecee

Browse files
authored
fix(selfhost): fail preflight on half-configured ledger anchoring instead of silently skipping it (#9771)
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
1 parent 8e02fab commit 81cecee

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

apps/loopover-ui/src/lib/selfhost-env-reference.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,26 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
281281
name: "LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER",
282282
firstReference: "src/selfhost/ai.ts",
283283
},
284+
{
285+
name: "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID",
286+
firstReference: "src/selfhost/preflight.ts",
287+
},
288+
{
289+
name: "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER",
290+
firstReference: "src/selfhost/preflight.ts",
291+
},
292+
{
293+
name: "LOOPOVER_LEDGER_ANCHOR_GIT_REPO",
294+
firstReference: "src/selfhost/preflight.ts",
295+
},
296+
{
297+
name: "LOOPOVER_LEDGER_ANCHOR_KEYS",
298+
firstReference: "src/selfhost/preflight.ts",
299+
},
300+
{
301+
name: "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY",
302+
firstReference: "src/selfhost/preflight.ts",
303+
},
284304
{
285305
name: "LOOPOVER_MCP_TOKEN",
286306
firstReference: "src/selfhost/preflight.ts",
@@ -759,6 +779,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
759779
"| `LOOPOVER_CENTRAL_POSTHOG_KEY` | `src/selfhost/posthog.ts` |",
760780
"| `LOOPOVER_ENABLE_PAGERDUTY` | `src/services/notify-pagerduty.ts` |",
761781
"| `LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER` | `src/selfhost/ai.ts` |",
782+
"| `LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID` | `src/selfhost/preflight.ts` |",
783+
"| `LOOPOVER_LEDGER_ANCHOR_GIT_OWNER` | `src/selfhost/preflight.ts` |",
784+
"| `LOOPOVER_LEDGER_ANCHOR_GIT_REPO` | `src/selfhost/preflight.ts` |",
785+
"| `LOOPOVER_LEDGER_ANCHOR_KEYS` | `src/selfhost/preflight.ts` |",
786+
"| `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` | `src/selfhost/preflight.ts` |",
762787
"| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |",
763788
"| `LOOPOVER_METRICS_REPO_LABELS` | `src/server.ts` |",
764789
"| `LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS` | `src/selfhost/inert-config.ts` |",

src/selfhost/preflight.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createPrivateKey } from "node:crypto";
22
import { CRON_INTERVAL_MIN_MS } from "./cron-alignment";
3+
import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor";
34

45
export type SelfHostPreflightProblem = {
56
var: string;
@@ -70,6 +71,83 @@ function isGitHubAppPrivateKey(value: string): boolean {
7071
}
7172
}
7273

74+
/**
75+
* Ledger anchoring (#9769) is OPT-IN, so "nothing configured" is deliberately not a problem. What IS a
76+
* problem is PARTIAL configuration, because it fails completely silently: runScheduledLedgerAnchor logs
77+
* `ledger_anchor_skipped_unconfigured` and returns (ledger-anchor-scheduler.ts), while the job keeps firing
78+
* every couple of minutes. An operator who set half the vars has no signal at all that anchoring never runs
79+
* -- and a self-host container DOES run the scheduler and DOES have a populated decision_ledger, so it is
80+
* genuinely one keypair away from working.
81+
*
82+
* The published-key checks call the real `parseAnchorPublicKeys`/`currentAnchorKey` rather than
83+
* re-validating the shape here, so preflight can never disagree with what the scheduler will actually do.
84+
*/
85+
function checkLedgerAnchorConfig(problems: SelfHostPreflightProblem[], env: SelfHostPreflightEnv): void {
86+
const rawKeys = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_KEYS"]);
87+
const privateKey = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY"]);
88+
89+
if (rawKeys && !privateKey) {
90+
addProblem(
91+
problems,
92+
"LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY",
93+
"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.",
94+
);
95+
}
96+
if (privateKey && !rawKeys) {
97+
addProblem(
98+
problems,
99+
"LOOPOVER_LEDGER_ANCHOR_KEYS",
100+
"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.",
101+
);
102+
}
103+
if (rawKeys) {
104+
const keys = parseAnchorPublicKeys(rawKeys);
105+
if (keys.length === 0) {
106+
addProblem(
107+
problems,
108+
"LOOPOVER_LEDGER_ANCHOR_KEYS",
109+
"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.",
110+
);
111+
} else if (currentAnchorKey(keys) === null) {
112+
const current = keys.filter((key) => key.notAfter === null).length;
113+
addProblem(
114+
problems,
115+
"LOOPOVER_LEDGER_ANCHOR_KEYS",
116+
current === 0
117+
? "No entry has notAfter: null, so there is no current signing key and anchoring silently never runs. Exactly one entry must be open-ended."
118+
: `${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.`,
119+
);
120+
}
121+
}
122+
123+
// The git backend is independently optional, but owner+repo without an installation id means job-dispatch
124+
// resolves `submitGit` to null and the backend is skipped without comment.
125+
const gitOwner = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_OWNER"]);
126+
const gitRepo = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_REPO"]);
127+
const installationId = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID"]);
128+
if (gitOwner && gitRepo && !installationId) {
129+
addProblem(
130+
problems,
131+
"LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID",
132+
"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.",
133+
);
134+
}
135+
if (installationId && !/^[1-9][0-9]*$/.test(installationId)) {
136+
addProblem(
137+
problems,
138+
"LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID",
139+
"Must be a positive integer; any other value resolves to no git submitter and the backend is skipped silently.",
140+
);
141+
}
142+
if ((gitOwner && !gitRepo) || (gitRepo && !gitOwner)) {
143+
addProblem(
144+
problems,
145+
gitOwner ? "LOOPOVER_LEDGER_ANCHOR_GIT_REPO" : "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER",
146+
"A git anchor target needs BOTH owner and repo; with only one set the backend is skipped silently.",
147+
);
148+
}
149+
}
150+
73151
function addProblem(
74152
problems: SelfHostPreflightProblem[],
75153
name: string,
@@ -266,6 +344,8 @@ export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult
266344
positiveInteger(problems, env, "PORT", 1, 65_535);
267345
positiveInteger(problems, env, "GITHUB_CACHE_TTL_SECONDS", 0, 86_400);
268346

347+
checkLedgerAnchorConfig(problems, env);
348+
269349
return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems };
270350
}
271351

test/unit/selfhost-preflight.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,101 @@ describe("self-host environment preflight (#2080)", () => {
440440
);
441441
});
442442
});
443+
444+
describe("ledger-anchor configuration preflight (#9769)", () => {
445+
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ format: "pem", type: "pkcs8" }).toString();
446+
const base = { REDIS_URL: "redis://redis:6379", GITHUB_APP_ID: "123", GITHUB_APP_PRIVATE_KEY: privateKey };
447+
const key = (over: Record<string, unknown> = {}) => JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null, ...over }]);
448+
const anchorProblems = (env: Record<string, string | undefined>) => {
449+
const result = preflightEnv({ ...base, ...env });
450+
return result.problems.filter((p: SelfHostPreflightProblem) => p.var.startsWith("LOOPOVER_LEDGER_ANCHOR"));
451+
};
452+
453+
it("INVARIANT: anchoring is opt-in — configuring none of it is never a problem", () => {
454+
expect(preflightEnv(base)).toEqual({ ok: true, problems: [] });
455+
});
456+
457+
it("accepts a fully configured Rekor-only setup", () => {
458+
expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key(), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([]);
459+
});
460+
461+
it("REGRESSION: catches a published key with no private half — silently disables anchoring today", () => {
462+
expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key() })).toEqual([
463+
expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY" }),
464+
]);
465+
});
466+
467+
it("REGRESSION: catches a private key with nothing published — anchors would be unverifiable", () => {
468+
expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([
469+
expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_KEYS" }),
470+
]);
471+
});
472+
473+
it("catches a key list that parses to nothing (malformed JSON, or entries missing required fields)", () => {
474+
for (const raw of ["not json", "{}", "[]", JSON.stringify([{ keyId: "k1" }])]) {
475+
expect(anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: raw, LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" })).toEqual([
476+
expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_KEYS", message: expect.stringContaining("No usable entries") }),
477+
]);
478+
}
479+
});
480+
481+
it("catches a key list with no open-ended entry — no current signing key", () => {
482+
const problems = anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: key({ notAfter: "2026-06-01T00:00:00.000Z" }), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" });
483+
expect(problems[0]?.message).toContain("No entry has notAfter: null");
484+
});
485+
486+
it("catches an AMBIGUOUS rotation — more than one open-ended entry fails closed at runtime", () => {
487+
const twoOpen = JSON.stringify([
488+
{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null },
489+
{ keyId: "k2", publicKeySpki: "c3BraR==", notBefore: "2026-02-01T00:00:00.000Z", notAfter: null },
490+
]);
491+
const problems = anchorProblems({ LOOPOVER_LEDGER_ANCHOR_KEYS: twoOpen, LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem" });
492+
expect(problems[0]?.message).toContain("2 entries have notAfter: null");
493+
});
494+
495+
it("catches a git target with no installation id — the backend is skipped silently", () => {
496+
expect(
497+
anchorProblems({
498+
LOOPOVER_LEDGER_ANCHOR_KEYS: key(),
499+
LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem",
500+
LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme",
501+
LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors",
502+
}),
503+
).toEqual([expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID" })]);
504+
});
505+
506+
it("catches a non-positive-integer installation id", () => {
507+
for (const bad of ["0", "-1", "abc", "1.5"]) {
508+
const problems = anchorProblems({
509+
LOOPOVER_LEDGER_ANCHOR_KEYS: key(),
510+
LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem",
511+
LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme",
512+
LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors",
513+
LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: bad,
514+
});
515+
expect(problems.some((p) => p.message.includes("positive integer"))).toBe(true);
516+
}
517+
});
518+
519+
it("catches half a git target in either direction", () => {
520+
const shared = { LOOPOVER_LEDGER_ANCHOR_KEYS: key(), LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem", LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: "42" };
521+
expect(anchorProblems({ ...shared, LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme" })).toEqual([
522+
expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_REPO" }),
523+
]);
524+
expect(anchorProblems({ ...shared, LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors" })).toEqual([
525+
expect.objectContaining({ var: "LOOPOVER_LEDGER_ANCHOR_GIT_OWNER" }),
526+
]);
527+
});
528+
529+
it("accepts a fully configured git + Rekor setup", () => {
530+
expect(
531+
anchorProblems({
532+
LOOPOVER_LEDGER_ANCHOR_KEYS: key(),
533+
LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "pem",
534+
LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme",
535+
LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors",
536+
LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: "42",
537+
}),
538+
).toEqual([]);
539+
});
540+
});

0 commit comments

Comments
 (0)