From 91c1506c10924463a502f2c2f694863806e0fe80 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:42:26 -0700 Subject: [PATCH 1/5] fix(orb): stop the installation backfill from erasing App-suspension consent (#9151) listOrbAppInstallations now parses suspended_at from GitHub's installation list, and backfillOrbInstallations writes it through instead of hardcoding NULL on every upsert. Previously a suspension recorded by the installation.suspend webhook was silently cleared by the next backfill, making the broker's suspended_at eligibility check always-passing. removed_at stays cleared: GitHub's installations list never reports an actually-uninstalled App, so every install this loop sees is by definition still installed. --- src/orb/app-auth.ts | 9 ++++++-- src/orb/installations.ts | 17 ++++++++++---- test/unit/orb-app-auth.test.ts | 42 +++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/orb/app-auth.ts b/src/orb/app-auth.ts index 57d85dde4f..d56cb71f52 100644 --- a/src/orb/app-auth.ts +++ b/src/orb/app-auth.ts @@ -31,6 +31,11 @@ export interface OrbAppInstallation { accountType: string | null; accountId: number | null; repositorySelection: string | null; + // #9151: GitHub's own suspension signal — the App can be suspended by the account owner without an + // `uninstall`, and `GET /app/installations` keeps listing a suspended install (unlike a real uninstall, + // which drops it from this list entirely) with this populated. Parsed here so the backfill can write it + // through instead of assuming every install GitHub returns is unsuspended. + suspendedAt: string | null; } /** Lists every installation of the Orb App (paginated). The backfill reads this to recover installs whose @@ -44,9 +49,9 @@ export async function listOrbAppInstallations(env: Env): Promise; + const rows = (await response.json()) as Array<{ id?: number; account?: { login?: string; type?: string; id?: number } | null; repository_selection?: string; suspended_at?: string | null }>; for (const row of rows) { - if (row.id) installs.push({ id: row.id, accountLogin: row.account?.login ?? null, accountType: row.account?.type ?? null, accountId: row.account?.id ?? null, repositorySelection: row.repository_selection ?? null }); + if (row.id) installs.push({ id: row.id, accountLogin: row.account?.login ?? null, accountType: row.account?.type ?? null, accountId: row.account?.id ?? null, repositorySelection: row.repository_selection ?? null, suspendedAt: row.suspended_at ?? null }); } if (rows.length < 100) break; // short page → last page /* v8 ignore next 2 -- runaway-loop backstop: a single App would need 1000+ installs (>10 pages) to reach this */ diff --git a/src/orb/installations.ts b/src/orb/installations.ts index 35e697b767..883a5c4acc 100644 --- a/src/orb/installations.ts +++ b/src/orb/installations.ts @@ -46,19 +46,28 @@ export async function upsertOrbInstallation(env: Env, eventName: string, payload * `installation` webhook fired before the receiver's secret was configured (so they were never recorded). Upserts * each install WITHOUT touching `registered`, so a re-run never re-trusts an opted-out install; new rows land at * the default registered=0 (the manual-onboarding gate). + * + * #9151: `suspended_at` is written through from GitHub's own `listOrbAppInstallations` response instead of being + * hardcoded to NULL — a suspension recorded by the `installation.suspend` webhook (above) is the ONLY registry-side + * signal that an account owner revoked consent (see brokerOrbToken's eligibility check, broker.ts, and the + * "Installation not active" check, oauth.ts), and no `unsuspend` webhook is guaranteed to ever arrive to restore it + * if a backfill silently erased it. `removed_at` stays cleared to NULL: unlike suspension, GitHub's + * `GET /app/installations` (what `listOrbAppInstallations` walks) never lists an actually-uninstalled App at + * all — every install this loop sees is, by definition, currently installed, so clearing `removed_at` here + * reflects reality rather than resurrecting a removed install the backfill never actually saw. */ export async function backfillOrbInstallations(env: Env): Promise<{ backfilled: number }> { const installs = await listOrbAppInstallations(env); for (const inst of installs) { await env.DB.prepare( - `INSERT INTO orb_github_installations (installation_id, account_login, account_type, account_id, repository_selection, last_event_at) - VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `INSERT INTO orb_github_installations (installation_id, account_login, account_type, account_id, repository_selection, suspended_at, last_event_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(installation_id) DO UPDATE SET account_login = excluded.account_login, account_type = excluded.account_type, account_id = excluded.account_id, - repository_selection = excluded.repository_selection, suspended_at = NULL, removed_at = NULL, + repository_selection = excluded.repository_selection, suspended_at = excluded.suspended_at, removed_at = NULL, last_event_at = CURRENT_TIMESTAMP`, ) - .bind(inst.id, inst.accountLogin, inst.accountType, inst.accountId, inst.repositorySelection) + .bind(inst.id, inst.accountLogin, inst.accountType, inst.accountId, inst.repositorySelection, inst.suspendedAt) .run(); } return { backfilled: installs.length }; diff --git a/test/unit/orb-app-auth.test.ts b/test/unit/orb-app-auth.test.ts index e0e929a3df..74c1d4f491 100644 --- a/test/unit/orb-app-auth.test.ts +++ b/test/unit/orb-app-auth.test.ts @@ -32,7 +32,16 @@ describe("listOrbAppInstallations", () => { vi.stubGlobal("fetch", async (url: RequestInfo | URL) => Response.json(String(url).includes("&page=1") ? page1 : page2)); const installs = await listOrbAppInstallations(env); expect(installs).toHaveLength(102); // 100 (full page → continue) + 101 + 102; the no-id row is skipped - expect(installs.at(-1)).toEqual({ id: 102, accountLogin: null, accountType: null, accountId: null, repositorySelection: null }); + expect(installs.at(-1)).toEqual({ id: 102, accountLogin: null, accountType: null, accountId: null, repositorySelection: null, suspendedAt: null }); + }); + + it("parses a suspended install's suspended_at instead of discarding it (#9151)", async () => { + const env = orbEnv({ ORB_GITHUB_APP_PRIVATE_KEY: await pkcs8Pem() }); + vi.stubGlobal("fetch", async () => + Response.json([{ id: 900, account: { login: "acme", type: "Organization", id: 20 }, repository_selection: "all", suspended_at: "2026-06-01T00:00:00Z" }]), + ); + const installs = await listOrbAppInstallations(env); + expect(installs).toEqual([{ id: 900, accountLogin: "acme", accountType: "Organization", accountId: 20, repositorySelection: "all", suspendedAt: "2026-06-01T00:00:00Z" }]); }); it("throws on a non-ok response", async () => { @@ -78,4 +87,35 @@ describe("backfillOrbInstallations", () => { { installation_id: 6, account_login: "bob", account_id: 21, registered: 0 }, // new → default opt-out ]); }); + + it("#9151: a suspended install SURVIVES a backfill (suspended_at is written through, not hardcoded NULL)", async () => { + const env = orbEnv({ ORB_GITHUB_APP_PRIVATE_KEY: await pkcs8Pem() }); + // Simulate the `installation.suspend` webhook having already recorded the suspension. + await (env.DB as unknown as TestD1Database) + .prepare("INSERT INTO orb_github_installations (installation_id, registered, suspended_at) VALUES (7, 1, '2026-06-01T00:00:00Z')") + .run(); + vi.stubGlobal("fetch", async () => + Response.json([{ id: 7, account: { login: "acme", type: "Organization", id: 20 }, repository_selection: "all", suspended_at: "2026-06-01T00:00:00Z" }]), + ); + expect(await backfillOrbInstallations(env)).toEqual({ backfilled: 1 }); + const row = await (env.DB as unknown as TestD1Database) + .prepare("SELECT suspended_at FROM orb_github_installations WHERE installation_id = 7") + .first<{ suspended_at: string | null }>(); + expect(row?.suspended_at).toBe("2026-06-01T00:00:00Z"); // NOT erased by the backfill + }); + + it("#9151: an active (non-suspended) install still clears suspended_at through the backfill", async () => { + const env = orbEnv({ ORB_GITHUB_APP_PRIVATE_KEY: await pkcs8Pem() }); + await (env.DB as unknown as TestD1Database) + .prepare("INSERT INTO orb_github_installations (installation_id, registered, suspended_at) VALUES (8, 1, '2026-06-01T00:00:00Z')") + .run(); // stale suspension recorded — GitHub now reports it active again (e.g. missed unsuspend webhook) + vi.stubGlobal("fetch", async () => + Response.json([{ id: 8, account: { login: "acme", type: "Organization", id: 20 }, repository_selection: "all" }]), // no suspended_at → active + ); + expect(await backfillOrbInstallations(env)).toEqual({ backfilled: 1 }); + const row = await (env.DB as unknown as TestD1Database) + .prepare("SELECT suspended_at FROM orb_github_installations WHERE installation_id = 8") + .first<{ suspended_at: string | null }>(); + expect(row?.suspended_at).toBeNull(); + }); }); From b987557f3d1a03da28642926f9c1d85377f1d012 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:42:34 -0700 Subject: [PATCH 2/5] fix(github): guard brokered token identity and read the granular collaborator role (#9152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mintInstallationToken now throws when a brokered token's installationId is non-zero and differs from the requested install, instead of caching and returning it under the caller's id — closing a cross-tenant token-serving path a stale caller-side installationId (e.g. after an App uninstall/reinstall) could reach. The permissions write is no longer gated on an installationId match that a legacy zero-default reply could never satisfy. getRepositoryCollaboratorPermission now prefers the collaborator-permission endpoint's role_name over its coarse permission field, since permission only ever resolves to admin/write/read/none — collapsing Maintain into "write" and Triage into "read" and making the "maintain" tier unreachable at both isPerTenantAdmin and resolveRealRepoPermissionAssociation. --- src/github/app.ts | 36 ++++++++++++++++-- test/unit/github-app.test.ts | 72 +++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index b15a7e6c00..dc0498ed76 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -242,12 +242,33 @@ async function mintInstallationToken( if (isOrbBrokerMode(env)) { try { const brokered = await fetchBrokeredInstallationToken(env, fetch, { forceRefresh }); - await writeCachedToken(installationId, { + // #9152: the broker's installationId is either 0 (fetchBrokeredInstallationToken's default for an older/ + // minimal broker reply that never echoed the field back) or must equal the install THIS call requested. + // Previously the cache write and the returned token were UNGUARDED — only the permissions write below + // checked this — so a stale caller-side installationId (e.g. `repositories.installation_id` left behind + // after the maintainer uninstalled/reinstalled the App, which GitHub assigns a NEW id) could cache and + // return a token the broker minted for a DIFFERENT install under THIS install's cache key. In a hosted/ + // multi-tenant broker that is real cross-tenant token exposure, not just a stale-cache correctness bug. + // Throw instead of silently serving another install's token; the catch below still applies its existing + // stale-cache grace / rethrow behavior for this like any other broker-call failure. + if (brokered.installationId !== 0 && brokered.installationId !== installationId) { + throw new Error( + `Orb broker minted a token for installation ${brokered.installationId}, not the requested ${installationId}.`, + ); + } + // Cache under the broker's own claimed id when it provided one (guaranteed equal to installationId by the + // guard above) so the cache key always reflects what was ACTUALLY minted, not just what was asked for. + const mintedInstallationId = brokered.installationId || installationId; + await writeCachedToken(mintedInstallationId, { token: brokered.token, expiresAtMs: brokered.expiresAtMs, }); - if (brokered.installationId === installationId && Object.keys(brokered.permissions).length > 0) { - await updateInstallationPermissions(env, installationId, brokered.permissions).catch((error) => { + // No longer gated on `brokered.installationId === installationId`: that condition was ALWAYS false for a + // legacy broker reply that omits installationId (defaults to 0), so permissions from such a reply were + // silently never persisted. The mismatch case above already throws, so every reply reaching here is + // either explicitly confirmed for this install or carries no installationId claim to check. + if (Object.keys(brokered.permissions).length > 0) { + await updateInstallationPermissions(env, mintedInstallationId, brokered.permissions).catch((error) => { console.warn( JSON.stringify({ level: "warn", @@ -480,8 +501,15 @@ export async function getRepositoryCollaboratorPermission( } const payload = (await response.json()) as { permission?: GitHubRepositoryCollaboratorPermission; + role_name?: string; }; - return payload.permission ?? null; + // #9152: `permission` is GitHub's coarse legacy field — only ever admin/write/read/none, so Maintain collapses + // into "write" and Triage into "read" — making `permission === "maintain"` dead at both call sites + // (isPerTenantAdmin, resolveRealRepoPermissionAssociation). `role_name` carries the actual granular tier + // (including "maintain"/"triage", or a custom org role) and is always present on this endpoint's real response; + // prefer it, falling back to `permission` only for a response that omits it (defensive — keeps the pre-#9152 + // behavior for any caller/fixture that only ever set `permission`). + return payload.role_name ?? payload.permission ?? null; } /** Account-age throttle (#2561, anti-abuse): `GET /users/{login}` for `created_at`, so a repo can flag a diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 1bbf2e8f3b..ce0472e50e 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -543,7 +543,7 @@ describe("GitHub check runs", () => { brokerCalls += 1; return Response.json({ token: "brokered-token", - installationId: 999, + installationId: 888, // must match the requested install (#9152) — see the dedicated mismatch test below expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), permissions: { contents: "write" }, }); @@ -679,6 +679,61 @@ describe("GitHub check runs", () => { await expect(createInstallationToken(env, 1003)).rejects.toThrow(); // re-mint fails + cached expired → rethrow }); + it("#9152: throws rather than caching/returning a token the broker minted for a DIFFERENT installation", async () => { + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }); + await upsertInstallation(env, { + installation: { + id: 1004, + account: { login: "owner", id: 1, type: "User" }, + target_type: "User", + repository_selection: "selected", + permissions: { contents: "read" }, + events: [], + }, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/v1/orb/token")) { + return Response.json({ + token: "wrong-tenant-token", + installationId: 2001, // a stale caller-side id (1004) requested a token; the broker minted for 2001 instead + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + permissions: { contents: "write" }, + }); + } + return new Response("nf", { status: 404 }); + }); + await expect(createInstallationToken(env, 1004)).rejects.toThrow(/installation 2001.*requested 1004/); + // The requested install's permissions are untouched — the mismatched reply was never persisted under it. + expect((await getInstallation(env, 1004))?.permissions).toEqual({ contents: "read" }); + // Nothing was ever recorded for the OTHER (actually-minted-for) installation either. + expect(await getInstallation(env, 2001)).toBeNull(); + }); + + it("#9152: persists broker-returned permissions even when the reply omits installationId (legacy default 0)", async () => { + const env = createTestEnv({ ORB_ENROLLMENT_SECRET: "orbsec_test" }); + await upsertInstallation(env, { + installation: { + id: 1005, + account: { login: "owner", id: 1, type: "User" }, + target_type: "User", + repository_selection: "selected", + permissions: { contents: "read" }, + events: [], + }, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/v1/orb/token")) { + // installationId omitted entirely — fetchBrokeredInstallationToken defaults it to 0, which must be + // treated as "no claim to check" (compatible with any requested install), not a mismatch. + return Response.json({ token: "brokered-token", expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), permissions: { contents: "write" } }); + } + return new Response("nf", { status: 404 }); + }); + + expect(await createInstallationToken(env, 1005)).toBe("brokered-token"); + expect((await getInstallation(env, 1005))?.permissions).toEqual({ contents: "write" }); + }); + it("fetches repository collaborator permissions with installation credentials", async () => { const privateKey = await generatePrivateKeyPem(); const calls: string[] = []; @@ -709,6 +764,21 @@ describe("GitHub check runs", () => { ).toBe(true); }); + it("#9152: prefers role_name over the coarse permission field, surfacing the granular Maintain/Triage tiers", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + // GitHub's real response: `permission` collapses Maintain into "write" — `role_name` carries the real tier. + if (url.includes("/collaborators/a-maintainer/permission")) return Response.json({ permission: "write", role_name: "maintain" }); + if (url.includes("/collaborators/a-triager/permission")) return Response.json({ permission: "read", role_name: "triage" }); + return new Response("not found", { status: 404 }); + }); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }); + await expect(getRepositoryCollaboratorPermission(env, 123, "JSONbored/gittensory", "a-maintainer")).resolves.toBe("maintain"); + await expect(getRepositoryCollaboratorPermission(env, 123, "JSONbored/gittensory", "a-triager")).resolves.toBe("triage"); + }); + it("handles missing repository collaborator permission responses", async () => { const privateKey = await generatePrivateKeyPem(); From 82f4ca2b7d5d6ad27059276f6a9735863379dd6b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:42:43 -0700 Subject: [PATCH 3/5] fix(selfhost): validate numeric env knobs at boot instead of letting a bad value NaN through (#9157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preflightEnv now hard-fails boot when CRON_INTERVAL_MS, PORT, or GITHUB_CACHE_TTL_SECONDS is set to a non-plain-integer or out-of-range value, via a new positiveInteger check. Previously a bare Number(process.env.X ?? default) silently turned a unit-suffixed or malformed value into NaN, which setTimeout/setInterval coerce to a 1ms delay (spinning the cron at ~1000 ticks/second) or which a `> 0` gate always fails (silently disabling the GitHub response cache). server.ts's three call sites now also read through parsePositiveIntEnv (queue-common.ts) as runtime defense-in-depth, floored/clamped with a warning instead of coercing to NaN. CRON_INTERVAL_MS=0 is explicitly NOT treated as "disable the cron" — there is no supported way to run this entrypoint without its maintenance cron. --- src/selfhost/cron-alignment.ts | 10 +++ src/selfhost/preflight.ts | 43 +++++++++++++ src/server.ts | 22 +++++-- test/unit/selfhost-preflight.test.ts | 92 ++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/selfhost/cron-alignment.ts b/src/selfhost/cron-alignment.ts index a774766a42..d09d4dcfd6 100644 --- a/src/selfhost/cron-alignment.ts +++ b/src/selfhost/cron-alignment.ts @@ -1,3 +1,13 @@ +// #9157: the floor below which CRON_INTERVAL_MS is rejected rather than honored. A malformed value (a unit +// suffix like "2m"/"120s", a numeric separator like "120_000", or outright garbage) parses to NaN, and Node +// coerces BOTH `setTimeout(fn, NaN)` and `setInterval(fn, NaN)` to a 1ms delay — turning a single operator typo +// into ~1000 scheduled ticks/second instead of one every two minutes, each running the full worker.scheduled() +// fan-out. 0 takes the identical path (`x % 0` is NaN) and is NOT treated as "disable the cron" — there is no +// supported way to run this entrypoint without its maintenance cron, so 0 is just another invalid value, not a +// meaningful opt-out. Shared by preflight.ts (boot-time hard failure) and server.ts (runtime defense-in-depth +// clamp via parsePositiveIntEnv) so the two enforce the exact same floor. +export const CRON_INTERVAL_MIN_MS = 10_000; // 10s — comfortably above any accidental near-zero value, well under the 120s default + /** Milliseconds from `nowMs` until the next wall-clock boundary of `intervalMs`, so a self-host `setTimeout` * can phase-align its first tick to the same instants Cloudflare's own cron trigger would fire on (e.g. the * every-2-minutes trigger fires exactly at :00, :02, :04, … UTC). Computed against epoch -- itself minute-aligned -- diff --git a/src/selfhost/preflight.ts b/src/selfhost/preflight.ts index f82e21a375..a2d664f7d7 100644 --- a/src/selfhost/preflight.ts +++ b/src/selfhost/preflight.ts @@ -1,4 +1,5 @@ import { createPrivateKey } from "node:crypto"; +import { CRON_INTERVAL_MIN_MS } from "./cron-alignment"; export type SelfHostPreflightProblem = { var: string; @@ -129,6 +130,39 @@ function criticalSecretProblem(name: string, value: string): string | null { return null; } +// #9157: a bare `Number(process.env.X ?? default)` at a call site turns a wrong-unit/wrong-type operator +// mistake ("2m", "120s", "120_000", a fraction, a negative number) into NaN — and NaN silently becomes a +// runaway (setTimeout/setInterval coerce NaN to 1ms) or a silent feature disable (a `> 0` gate on NaN is +// always false), with no boot-time signal either way. Unlike parsePositiveIntEnv (queue-common.ts), which is +// the RUNTIME defense-in-depth for the same knobs — it silently falls back to a safe default with a warn log +// so a running process degrades gracefully — this runs at BOOT and turns the identical bad value into a +// fatal preflight error: an operator who fat-fingers a unit suffix finds out immediately, from a clear error, +// rather than from a buried warn line or (worse) a live production runaway. Absence is always fine — presence +// is each var's own default's concern, mirrored in the call site's own fallback — this only judges a value +// the operator actually set. +function positiveInteger( + problems: SelfHostPreflightProblem[], + env: SelfHostPreflightEnv, + name: string, + min: number, + max: number, +): void { + const raw = nonBlank(env[name]); + if (!raw) return; + if (!/^\d+$/.test(raw)) { + addProblem( + problems, + name, + `Set ${name} to a plain positive integer — no unit suffix (e.g. "2m"/"120s"), no numeric separators ("120_000"), and no decimal point.`, + ); + return; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + addProblem(problems, name, `Set ${name} to an integer between ${min} and ${max}.`); + } +} + function checkCriticalSecrets( problems: SelfHostPreflightProblem[], env: SelfHostPreflightEnv, @@ -223,6 +257,15 @@ export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult checkCriticalSecrets(problems, env); + // #9157: the numeric env knobs previously read with a bare `Number(process.env.X ?? default)` at their call + // sites (src/server.ts) — a malformed value NaN's through to a runaway 1ms cron/setTimeout or a silently + // disabled response cache with no boot-time signal. 0 is a valid, meaningful value for + // GITHUB_CACHE_TTL_SECONDS ("disable the cache", server.ts's own documented convention) but NOT for + // CRON_INTERVAL_MS (see CRON_INTERVAL_MIN_MS's own doc comment) or PORT (no TCP port is 0). + positiveInteger(problems, env, "CRON_INTERVAL_MS", CRON_INTERVAL_MIN_MS, 24 * 60 * 60_000); + positiveInteger(problems, env, "PORT", 1, 65_535); + positiveInteger(problems, env, "GITHUB_CACHE_TTL_SECONDS", 0, 86_400); + return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems }; } diff --git a/src/server.ts b/src/server.ts index 582cfdcc75..462b17691a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -63,12 +63,12 @@ import { import { clockSkewSampleAgeSeconds, clockSkewSecondsSample } from "./selfhost/clock-skew"; import { d1DatabaseSizeBytesSample, d1SignalSnapshotsRowsPerKeySample, d1TableRowCountSamples, isD1SizeProbeEnabled, runD1SizeProbe } from "./selfhost/d1-size-probe"; import { gauge, gaugeVector, incr, observe, renderMetrics, setSelfHostedMetricsMode, setSelfHostedRawRepoLabels } from "./selfhost/metrics"; -import { delayToNextWallClockBoundaryMs } from "./selfhost/cron-alignment"; +import { CRON_INTERVAL_MIN_MS, delayToNextWallClockBoundaryMs } from "./selfhost/cron-alignment"; import { runSelfHostMigrations } from "./selfhost/migrate"; import { createPgAdapter, tuneGithubRateLimitObservationsAutovacuum, widenGithubIdColumnsToBigint } from "./selfhost/pg-adapter"; import { createPgQueue } from "./selfhost/pg-queue"; import { createPgVectorize, initPgVectorize } from "./selfhost/pg-vectorize"; -import { resolvePostgresPoolMax } from "./selfhost/queue-common"; +import { parsePositiveIntEnv, resolvePostgresPoolMax } from "./selfhost/queue-common"; import type { DurableQueue } from "./selfhost/backend-contracts"; import { createSqliteQueue } from "./selfhost/sqlite-queue"; import { createSqliteVectorize } from "./selfhost/vectorize"; @@ -753,7 +753,12 @@ async function main(): Promise { // Enable/disable gate for the GitHub GET-response cache (dedups the ~24 reads per review); NOT a per-entry // TTL — each cached class (branch-protection/metadata/commit/GraphQL) resolves its own TTL env var, so the // value here only matters as >0 (enabled) vs 0 (disabled) (#2505). - const ghCacheTtl = Math.max(0, Number(process.env.GITHUB_CACHE_TTL_SECONDS ?? "20")); + // #9157: parsePositiveIntEnv rejects NaN/out-of-range instead of a bare Number(...) silently producing NaN, + // which `Math.max(0, NaN)` would pass straight through — `ghCacheTtl > 0` below is then always false, + // silently disabling the GitHub response cache with no signal. Defense-in-depth: assertSelfHostPreflight + // (called earlier in main()) already hard-fails boot on a malformed value; this is the runtime floor for + // any caller that reaches this code without going through that gate (e.g. a future direct import). + const ghCacheTtl = parsePositiveIntEnv("GITHUB_CACHE_TTL_SECONDS", { min: 0, max: 86_400, fallback: 20 }); if (ghCacheTtl > 0) { const { createRedisResponseCache } = await import("./selfhost/redis-response-cache"); setGitHubResponseCache(createRedisResponseCache(redisClient)); @@ -995,7 +1000,10 @@ async function main(): Promise { passThroughOnException: () => undefined, } as unknown as ExecutionContext; - const port = Number(process.env.PORT ?? 8787); + // #9157: see the GITHUB_CACHE_TTL_SECONDS comment above — a malformed PORT previously NaN'd through to + // @hono/node-server's serve(), which silently binds a random port instead of the intended one, breaking the + // compose healthcheck with no boot-time error. + const port = parsePositiveIntEnv("PORT", { min: 1, max: 65_535, fallback: 8787 }); const server = serve( { fetch: async (request: Request, httpBindings: HttpBindings | Http2Bindings) => { @@ -1219,7 +1227,11 @@ async function main(): Promise { // intervalMs` lands on the same boundaries Cloudflare's cron would for any intervalMs that evenly divides // an hour (the default 120_000 included) — with a one-shot setTimeout, then hand off to setInterval from // that aligned moment so every subsequent tick keeps landing on those boundaries. - const intervalMs = Number(process.env.CRON_INTERVAL_MS ?? 120_000); + // #9157: see the GITHUB_CACHE_TTL_SECONDS comment above — a malformed CRON_INTERVAL_MS previously NaN'd + // through to both the one-shot setTimeout and the follow-on setInterval below, which Node coerces a NaN + // delay to 1ms, spinning the scheduler at ~1000 ticks/second instead of once every two minutes. Floored at + // CRON_INTERVAL_MIN_MS (not 0 — see that constant's own doc comment on why 0 is not a supported "disable"). + const intervalMs = parsePositiveIntEnv("CRON_INTERVAL_MS", { min: CRON_INTERVAL_MIN_MS, fallback: 120_000 }); /* v8 ignore start -- self-host entrypoint timers start a live server; monitor semantics are covered in selfhost tests. */ const runCronTick = (): void => { const controller = { diff --git a/test/unit/selfhost-preflight.test.ts b/test/unit/selfhost-preflight.test.ts index 1256ce75f6..6396ba118c 100644 --- a/test/unit/selfhost-preflight.test.ts +++ b/test/unit/selfhost-preflight.test.ts @@ -334,6 +334,98 @@ describe("self-host environment preflight (#2080)", () => { ); }); + describe("numeric env knobs (#9157: a malformed value must fail boot, not silently NaN into a runaway/disabled feature)", () => { + const baseEnv = { + REDIS_URL: "redis://redis:6379", + GITHUB_APP_ID: "123", + GITHUB_APP_PRIVATE_KEY: privateKey, + }; + + it("accepts every knob when unset — presence is never required, only format when set", () => { + expect(preflightEnv(baseEnv)).toEqual({ ok: true, problems: [] }); + }); + + it("accepts valid values for CRON_INTERVAL_MS, PORT, and GITHUB_CACHE_TTL_SECONDS", () => { + expect( + preflightEnv({ ...baseEnv, CRON_INTERVAL_MS: "120000", PORT: "8787", GITHUB_CACHE_TTL_SECONDS: "20" }), + ).toEqual({ ok: true, problems: [] }); + }); + + it("accepts GITHUB_CACHE_TTL_SECONDS=0 (the documented 'disable the cache' value)", () => { + expect(preflightEnv({ ...baseEnv, GITHUB_CACHE_TTL_SECONDS: "0" })).toEqual({ ok: true, problems: [] }); + }); + + it("rejects a unit-suffixed or separator-formatted value instead of silently NaN-ing", () => { + for (const CRON_INTERVAL_MS of ["2m", "120s", "120_000", "12.5", "-5", "abc"]) { + const result = preflightEnv({ ...baseEnv, CRON_INTERVAL_MS }); + expect(result).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "CRON_INTERVAL_MS" })], + }); + } + }); + + it("rejects CRON_INTERVAL_MS=0 — NOT a supported 'disable the cron' value, unlike GITHUB_CACHE_TTL_SECONDS", () => { + expect(preflightEnv({ ...baseEnv, CRON_INTERVAL_MS: "0" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "CRON_INTERVAL_MS" })], + }); + }); + + it("rejects CRON_INTERVAL_MS below the floor and above the ceiling", () => { + expect(preflightEnv({ ...baseEnv, CRON_INTERVAL_MS: "9999" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "CRON_INTERVAL_MS" })], + }); + expect(preflightEnv({ ...baseEnv, CRON_INTERVAL_MS: String(24 * 60 * 60_000 + 1) })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "CRON_INTERVAL_MS" })], + }); + }); + + it("rejects PORT of 0 or above 65535", () => { + expect(preflightEnv({ ...baseEnv, PORT: "0" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "PORT" })], + }); + expect(preflightEnv({ ...baseEnv, PORT: "65536" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "PORT" })], + }); + }); + + it("rejects a negative GITHUB_CACHE_TTL_SECONDS and one above its ceiling", () => { + expect(preflightEnv({ ...baseEnv, GITHUB_CACHE_TTL_SECONDS: "-1" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "GITHUB_CACHE_TTL_SECONDS" })], + }); + expect(preflightEnv({ ...baseEnv, GITHUB_CACHE_TTL_SECONDS: "86401" })).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "GITHUB_CACHE_TTL_SECONDS" })], + }); + }); + + it("rejects a digit string so large it overflows to a non-finite number despite matching the digit-only regex", () => { + const result = preflightEnv({ ...baseEnv, PORT: "9".repeat(400) }); + expect(result).toEqual({ + ok: false, + problems: [expect.objectContaining({ var: "PORT" })], + }); + }); + + it("collects a problem for every affected numeric knob at once, not just the first", () => { + const result = preflightEnv({ ...baseEnv, CRON_INTERVAL_MS: "2m", PORT: "not-a-port", GITHUB_CACHE_TTL_SECONDS: "-1" }); + expect(result).toEqual({ + ok: false, + problems: [ + expect.objectContaining({ var: "CRON_INTERVAL_MS" }), + expect.objectContaining({ var: "PORT" }), + expect.objectContaining({ var: "GITHUB_CACHE_TTL_SECONDS" }), + ], + }); + }); + }); + it("asserts the preflight result for the boot path", () => { expect(() => assertSelfHostPreflight({ From d26d9aeb54f0a5bec16bbac8d6b0c7546e291096 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:42:51 -0700 Subject: [PATCH 4/5] fix(selfhost): detect an already-applied migration edited in place via a content hash (#9164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-host migration ledger (_selfhost_migrations) now records each applied file's content_sha256 alongside its name. On every boot, runSelfHostMigrations recomputes the current on-disk hash of every already-applied file and compares it to the recorded one: a mismatch fails boot loudly (logged, then thrown) instead of the prior behavior, where the ledger's filename-only key made an in-place edit after apply invisible forever. A pre-#9164 row with no stored hash is backfilled to its current content as the baseline, since the original apply-time content can't be recovered. No down-migration path is added — this repo's migrations remain forward-only. --- src/selfhost/migrate.ts | 79 ++++++++++++++++++++++++++++-- test/unit/selfhost-migrate.test.ts | 59 ++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/selfhost/migrate.ts b/src/selfhost/migrate.ts index cc5ccc9693..c977f476de 100644 --- a/src/selfhost/migrate.ts +++ b/src/selfhost/migrate.ts @@ -2,10 +2,16 @@ // files Cloudflare applies via `wrangler d1 migrations apply` — they're plain SQLite DDL, so they run as-is // through the D1 adapter's exec(). Tracked in a `_selfhost_migrations` table so a restart re-applies only the // new ones (idempotent), mirroring wrangler's migration ledger. +import { createHash } from "node:crypto"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { errorMessage } from "../utils/json"; +/** SHA-256 of a migration file's raw content, hex-encoded — the ledger's content-identity check (#9164). */ +function contentSha256(sql: string): string { + return createHash("sha256").update(sql, "utf8").digest("hex"); +} + function splitSqlStatements(sql: string): string[] { const statements: string[] = []; let start = 0; @@ -96,12 +102,74 @@ function sqlQuote(value: string): string { * only works per-statement. So a file that fails atomically is retried through the original tolerant path. * The two orders matter: atomic first means a genuine mid-file crash is the case that gets protected, and the * tolerant fallback only runs for a database that was already inconsistent before this boot began. + * + * #9164 — the ledger also records each applied file's `content_sha256`, checked on every boot against the + * file's CURRENT on-disk hash. Before this, the ledger recorded only a filename: a migration edited in place + * after it was applied (rather than shipping a new numbered file) was invisible forever — `applied.has(file)` + * still matched, so the edit was silently skipped, and the running DB quietly diverged from the repo's + * declared schema with no signal anywhere (not at boot, not in `/health`, not in `preflight.ts`). A mismatch + * now fails boot loudly (thrown, after a structured console.error) rather than being silently skipped — same + * "fail loudly, don't limp along on a maybe-wrong schema" posture as `assertSelfHostPreflight`. + * + * The four grandfathered duplicate-NUMBER pairs (0015/0017/0074/0156, see migration-collisions.ts) need no + * special handling here: the ledger — and this drift check — key on the FILENAME, not the leading number, so + * each grandfathered file already has its own independent ledger row and hash exactly like any other + * migration. Their collision is a distinct, already-solved concern (duplicate NUMBER assignment, caught by + * scripts/check-migrations.ts pre-merge); content identity per file is unaffected by it. + * + * No down-migration path is added here. Forward-only migrations remain this repo's model — as with every + * existing migrations/*.sql file, reverting a change means writing a new migration that undoes it, not + * running this one "backwards". */ export async function runSelfHostMigrations(db: D1Database, dir: string): Promise { - await db.exec("CREATE TABLE IF NOT EXISTS _selfhost_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); - const existing = await db.prepare("SELECT name FROM _selfhost_migrations").all<{ name: string }>(); - const applied = new Set(existing.results.map((r) => r.name)); + await db.exec("CREATE TABLE IF NOT EXISTS _selfhost_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL, content_sha256 TEXT)"); + // A pre-#9164 ledger predates the content_sha256 column; ADD COLUMN is idempotent the same way every other + // "might already be there" DDL in this file is tolerated (duplicate column / already exists), so a fresh + // table (which already has the column from CREATE TABLE above) and an upgrading one both end up identical. + try { + await db.exec("ALTER TABLE _selfhost_migrations ADD COLUMN content_sha256 TEXT"); + } catch (error) { + if (!/duplicate column|already exists/i.test(errorMessage(error))) throw error; + } + + const existing = await db.prepare("SELECT name, content_sha256 FROM _selfhost_migrations").all<{ name: string; content_sha256: string | null }>(); + const appliedHashes = new Map(existing.results.map((r) => [r.name, r.content_sha256])); const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort(); + + // Content-drift check (#9164): for every file already recorded as applied, recompute its CURRENT on-disk hash + // and compare it to the hash recorded at apply time. A row with no stored hash predates this column — + // backfilled below to the file's CURRENT content as its baseline (there is no way to recover the hash of + // whatever actually ran before this column existed; the current content is the best available starting + // point). A mismatch means the file's body changed AFTER it was applied — the running DB was never updated + // to match, so the schema has silently drifted with no other signal. This is the opposite case from the + // per-statement drift TOLERANCE below (that tolerance is for a database catching itself up to a migration it + // never fully applied); here the migration WAS fully applied, and it is the file's declared intent that + // changed out from under it, so it must fail loudly rather than be silently skipped. + const hashBackfills: Array<{ file: string; hash: string }> = []; + const drifted: string[] = []; + for (const [name, storedHash] of appliedHashes) { + if (!files.includes(name)) continue; // file removed from the repo since applying — a different concern (renumbering/collisions), not content drift + const currentHash = contentSha256(readFileSync(join(dir, name), "utf8")); + if (storedHash === null) hashBackfills.push({ file: name, hash: currentHash }); + else if (storedHash !== currentHash) drifted.push(name); + } + if (drifted.length > 0) { + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_migration_content_drift", + files: drifted, + }), + ); + throw new Error( + `Applied migration(s) edited after being applied — on-disk content no longer matches the hash recorded at apply time: ${drifted.join(", ")}. Add a NEW migration to make the change instead of editing an already-applied file, or restore its original content.`, + ); + } + for (const { file, hash } of hashBackfills) { + await db.prepare("UPDATE _selfhost_migrations SET content_sha256 = ? WHERE name = ?").bind(hash, file).run(); + } + + const applied = new Set(appliedHashes.keys()); // Real Cloudflare D1 has no transactional multi-statement surface; only the two self-host adapters implement // this. Absent it, behavior is exactly the pre-#9027 path. const execTransaction = (db as unknown as { execTransaction?: (sql: string) => Promise }).execTransaction; @@ -109,8 +177,9 @@ export async function runSelfHostMigrations(db: D1Database, dir: string): Promis for (const file of files) { if (applied.has(file)) continue; const sql = readFileSync(join(dir, file), "utf8"); + const hash = contentSha256(sql); const statements = splitSqlStatements(sql); - const ledger = `INSERT INTO _selfhost_migrations (name, applied_at) VALUES (${sqlQuote(file)}, ${sqlQuote(new Date().toISOString())});`; + const ledger = `INSERT INTO _selfhost_migrations (name, applied_at, content_sha256) VALUES (${sqlQuote(file)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(hash)});`; if (execTransaction) { try { // splitSqlStatements keeps each statement's own trailing `;` but returns a final unterminated tail @@ -146,7 +215,7 @@ export async function runSelfHostMigrations(db: D1Database, dir: string): Promis throw error; } } - await db.prepare("INSERT INTO _selfhost_migrations (name, applied_at) VALUES (?, ?)").bind(file, new Date().toISOString()).run(); + await db.prepare("INSERT INTO _selfhost_migrations (name, applied_at, content_sha256) VALUES (?, ?, ?)").bind(file, new Date().toISOString(), hash).run(); count += 1; } return count; diff --git a/test/unit/selfhost-migrate.test.ts b/test/unit/selfhost-migrate.test.ts index 9910713b14..d25868b6bb 100644 --- a/test/unit/selfhost-migrate.test.ts +++ b/test/unit/selfhost-migrate.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,6 +8,8 @@ import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter import { runSelfHostMigrations } from "../../src/selfhost/migrate"; import { normalizePostgresValue } from "../../scripts/migrate-selfhost-sqlite-to-postgres"; +const sha256 = (sql: string) => createHash("sha256").update(sql, "utf8").digest("hex"); + describe("runSelfHostMigrations (#980)", () => { it("applies un-applied migrations in order, idempotently", async () => { const dir = mkdtempSync(join(tmpdir(), "gtmig-")); @@ -100,6 +103,62 @@ INSERT INTO notes (body) VALUES ('triggered');`, }); }); + describe("content_sha256 ledger (#9164: an applied migration edited in place must be detected)", () => { + it("records each applied migration's content hash", async () => { + const dir = mkdtempSync(join(tmpdir(), "gtmig-")); + const sql = "CREATE TABLE a (id INTEGER);"; + writeFileSync(join(dir, "0001_a.sql"), sql); + const db = createD1Adapter(nodeSqliteDriver(new DatabaseSync(":memory:") as never)); + + expect(await runSelfHostMigrations(db, dir)).toBe(1); + const row = await db.prepare("SELECT content_sha256 FROM _selfhost_migrations WHERE name = ?").bind("0001_a.sql").first<{ content_sha256: string }>(); + expect(row?.content_sha256).toBe(sha256(sql)); + }); + + it("throws and logs loudly when an already-applied migration's on-disk content no longer matches its recorded hash", async () => { + const dir = mkdtempSync(join(tmpdir(), "gtmig-")); + writeFileSync(join(dir, "0001_a.sql"), "CREATE TABLE a (id INTEGER);"); + const db = createD1Adapter(nodeSqliteDriver(new DatabaseSync(":memory:") as never)); + expect(await runSelfHostMigrations(db, dir)).toBe(1); // applied + hash recorded + + // Edited in place AFTER being applied — the exact hazard #9164 exists to catch. + writeFileSync(join(dir, "0001_a.sql"), "CREATE TABLE a (id INTEGER, extra INTEGER);"); + await expect(runSelfHostMigrations(db, dir)).rejects.toThrow(/0001_a\.sql/); + + // The ledger itself is untouched by the failed re-run — still records the ORIGINAL hash, not the new one. + const row = await db.prepare("SELECT content_sha256 FROM _selfhost_migrations WHERE name = ?").bind("0001_a.sql").first<{ content_sha256: string }>(); + expect(row?.content_sha256).toBe(sha256("CREATE TABLE a (id INTEGER);")); + }); + + it("backfills content_sha256 for a pre-#9164 ledger row that predates the column, using its CURRENT content as the baseline", async () => { + const dir = mkdtempSync(join(tmpdir(), "gtmig-")); + const sql = "CREATE TABLE a (id INTEGER);"; + writeFileSync(join(dir, "0001_a.sql"), sql); + const db = createD1Adapter(nodeSqliteDriver(new DatabaseSync(":memory:") as never)); + + // Simulate a ledger written before #9164 — no content_sha256 column at all — with the migration already + // recorded applied (this repo's actual pre-#9164 shape). + await db.exec("CREATE TABLE _selfhost_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"); + await db.exec(`INSERT INTO _selfhost_migrations (name, applied_at) VALUES ('0001_a.sql', '2020-01-01T00:00:00.000Z')`); + + expect(await runSelfHostMigrations(db, dir)).toBe(0); // already applied — not re-run, just backfilled + const row = await db.prepare("SELECT content_sha256 FROM _selfhost_migrations WHERE name = ?").bind("0001_a.sql").first<{ content_sha256: string }>(); + expect(row?.content_sha256).toBe(sha256(sql)); + + // Once backfilled, an unrelated later boot with the SAME content is a no-op — no false drift from the backfill itself. + expect(await runSelfHostMigrations(db, dir)).toBe(0); + }); + + it("does not flag drift for a ledger row whose file was later removed from the repo (a distinct, unrelated concern)", async () => { + const dir = mkdtempSync(join(tmpdir(), "gtmig-")); + writeFileSync(join(dir, "0001_a.sql"), "CREATE TABLE a (id INTEGER);"); + const db = createD1Adapter(nodeSqliteDriver(new DatabaseSync(":memory:") as never)); + expect(await runSelfHostMigrations(db, dir)).toBe(1); + + const dir2 = mkdtempSync(join(tmpdir(), "gtmig-")); // 0001_a.sql no longer present on disk + await expect(runSelfHostMigrations(db, dir2)).resolves.toBe(0); + }); + }); }); describe("SQLite-to-Postgres migrator helpers", () => { From d4cffcab710c6e08aa64d215cd650c238e15e18b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:49:38 -0700 Subject: [PATCH 5/5] chore(selfhost): regenerate env-reference for the new preflight validations npm run selfhost:env-reference was not re-run after CRON_INTERVAL_MS/PORT/ GITHUB_CACHE_TTL_SECONDS validation moved into preflight.ts (#9157). --- apps/loopover-ui/src/lib/selfhost-env-reference.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index 9544d33eac..cae60d54d3 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -183,7 +183,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "CRON_INTERVAL_MS", - firstReference: "src/server.ts", + firstReference: "src/selfhost/preflight.ts", }, { name: "DATABASE_PATH", @@ -231,7 +231,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "GITHUB_CACHE_TTL_SECONDS", - firstReference: "src/server.ts", + firstReference: "src/selfhost/preflight.ts", }, { name: "GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS", @@ -483,7 +483,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "PORT", - firstReference: "src/server.ts", + firstReference: "src/selfhost/preflight.ts", }, { name: "POSTHOG_API_KEY", @@ -690,7 +690,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `CONFIG_DIR_EMPTY_ACKNOWLEDGED` | `src/server.ts` |", "| `CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT` | `src/queue/processors.ts` |", "| `CONTRIBUTOR_EVIDENCE_BATCH_SIZE` | `src/queue/processors.ts` |", - "| `CRON_INTERVAL_MS` | `src/server.ts` |", + "| `CRON_INTERVAL_MS` | `src/selfhost/preflight.ts` |", "| `DATABASE_PATH` | `src/server.ts` |", "| `DATABASE_URL` | `src/selfhost/preflight.ts` |", "| `DISCORD_REPO_WEBHOOKS` | `src/services/notify-discord.ts` |", @@ -702,7 +702,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `GITHUB_APP_ID` | `src/selfhost/orb-collector.ts` |", "| `GITHUB_APP_PRIVATE_KEY` | `src/selfhost/orb-collector.ts` |", "| `GITHUB_APP_SLUG` | `src/selfhost/pg-queue.ts` |", - "| `GITHUB_CACHE_TTL_SECONDS` | `src/server.ts` |", + "| `GITHUB_CACHE_TTL_SECONDS` | `src/selfhost/preflight.ts` |", "| `GITHUB_INSTALLATION_CONCURRENCY_DEFER_MS` | `src/selfhost/installation-concurrency-admission.ts` |", "| `GITHUB_INSTALLATION_CONCURRENCY_ENABLED` | `src/selfhost/installation-concurrency-admission.ts` |", "| `GITHUB_INSTALLATION_CONCURRENCY_LIMIT` | `src/selfhost/installation-concurrency-admission.ts` |", @@ -765,7 +765,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `PAGERDUTY_ROUTING_KEY` | `src/services/notify-pagerduty.ts` |", "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts` |", "| `PGVECTOR_ENABLED` | `src/server.ts` |", - "| `PORT` | `src/server.ts` |", + "| `PORT` | `src/selfhost/preflight.ts` |", "| `POSTHOG_API_KEY` | `src/selfhost/otel.ts` |", "| `POSTHOG_DISABLED` | `src/selfhost/posthog.ts` |", "| `POSTHOG_ENVIRONMENT` | `src/selfhost/otel.ts` |",