Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/loopover-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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` |",
Expand Down
80 changes: 80 additions & 0 deletions src/selfhost/preflight.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

Expand Down
98 changes: 98 additions & 0 deletions test/unit/selfhost-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null, ...over }]);
const anchorProblems = (env: Record<string, string | undefined>) => {
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([]);
});
});
Loading