Commitbc519aeunknown_key
feat: repair-flywheel cache live, SSRF guard, env-health panel, Haiku routing
feat: repair-flywheel cache live, SSRF guard, env-health panel, Haiku routing Closes the BUILD_BIBLE §7 P0 and P1 gaps and ships two platform improvements, all owner-approved this session: - Repair flywheel (P0): ci-autofix now consults the cache of previously successful repairs before calling Sonnet. Cache hits replay the stored patch with no AI call (and work without an API key); outcomes are settled back via updateOutcome through a comment marker, so the flywheel finally learns. Migration 0105 adds the replayable patch column; flywheel failures always fall open to the AI path. - SSRF hardening (P1): new ssrf-guard lib blocks private/internal addresses (incl. decimal/hex/octal IP encodings and IPv6 forms) on webhook deliveries and repo mirrors. SSRF_ALLOW_PRIVATE=1 escape hatch for self-hosted setups. - /admin/env-health: site-admin panel showing every env-gated feature (AI, email, semantic search, Redis, previews, error tracking, Google OAuth, ...) as Configured/Missing so features can no longer be silently disabled by a missing env var. - Conservative model routing: modelForTask() routes only short, human-overridden tasks (commit messages, issue/PR triage, label suggestions) to Haiku; everything that writes or judges code stays on Sonnet, unknown tasks default to Sonnet, and AI_FORCE_SONNET=1 is an instant kill-switch. Full suite: 2865 pass / 0 fail (+87 new tests), tsc clean. https://claude.ai/code/session_01RHwR7NK4HawRujV4dJmtab
19 files changed+2443−81bc519ae5ea08e21fbe4e37441b7e7fda04933839
19 changed files+2444−81
Modified.env.example+8−0View fileUnifiedSplit
@@ -129,3 +129,11 @@ GOOGLE_OAUTH_CLIENT_SECRET=
129129GOOGLE_OAUTH_AUTO_CREATE=
130130# Optional: comma-separated email domains allowed to sign in with Google.
131131GOOGLE_OAUTH_ALLOWED_DOMAINS=
132
133# ── 2026-06-12 build block (flywheel + SSRF + model routing) ──────────────
134# Kill-switch: set to 1 to force every AI task onto Sonnet, ignoring the
135# Haiku allowlist in src/lib/ai-client.ts modelForTask(). Read per-call.
136AI_FORCE_SONNET=
137# Escape hatch for self-hosted/dev setups whose webhooks or mirrors
138# legitimately target private addresses. Default: private IPs blocked.
139SSRF_ALLOW_PRIVATE=
ModifiedBUILD_BIBLE.md+7−0View fileUnifiedSplit
@@ -865,3 +865,10 @@ What was verified, not changed:
865865- New policy lives in `src/lib/login-lockout.ts` (`evaluateLockout`, pure, tested in `src/__tests__/login-lockout.test.ts`): locked iff ≥10 failures in the trailing hour AND the newest failure is <15 min old; blocked attempts are NOT recorded; successful login deletes the failure history. The route's lockout query now also **fails open** if `login_attempts` is unreachable — a broken lockout ledger must never take down login for everyone (the 0087 failure class).
866866- **Google sign-in env bootstrap:** `getGoogleOauthConfig()` in `src/lib/sso.ts` now falls back to `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` env vars (optional `GOOGLE_OAUTH_AUTO_CREATE=0`, `GOOGLE_OAUTH_ALLOWED_DOMAINS=`) when no credentialed `/admin/google-oauth` DB row exists. Removes the bootstrap deadlock where Google login could only be enabled by an admin who could already log in. Pure helper `googleOauthConfigFromEnv` tested in `src/__tests__/google-oauth-env.test.ts`. `.env.example` documents the vars. A DB row with credentials still wins.
867867- Locked-file note: `src/routes/auth.tsx` (§4) was modified **correctively only** — same limits, same audit actions; the change makes the already-shipped 15-minute lockout behave as documented.
868
869**2026-06-12 session (cont.) — §7 P0+P1 closed, env-health panel, Haiku routing:**
870- **Repair flywheel wired (was §7's P0).** `src/lib/ci-autofix.ts` now runs Tier 0 (flywheel cache) before the Sonnet call: `findCachedRepair()` serves a previously-successful patch for the same failure signature with no AI call (works even without `ANTHROPIC_API_KEY`); `updateOutcome()` settles success/failure via a `<!-- gluecron:ci-autofix:flywheel:<id> -->` comment marker parsed back in `applyAutofix`. `drizzle/0105_repair_flywheel_patch.sql` adds the replayable full-patch column (additive; pre-0105 rows fall through to AI). Gate: settled success rate ≥0.5; flywheel errors always fail open to the AI path. Tests: `src/__tests__/repair-flywheel-wiring.test.ts` (41).
871- **SSRF hardening shipped (was §7's P1).** New `src/lib/ssrf-guard.ts` (private IPv4/IPv6 ranges, localhost/.local/.internal, decimal/hex/octal IP encodings, optional best-effort DNS check) wired into `src/lib/webhook-delivery.ts` (blocked deliveries go straight to `dead` with reason) and `src/lib/mirrors.ts` (config-time + sync-time checks). `SSRF_ALLOW_PRIVATE=1` escape hatch; test env default-allows unless `SSRF_ENFORCE_IN_TEST=1`. Tests: `src/__tests__/ssrf-guard.test.ts` (25).
872- **Env/feature health panel.** `GET /admin/env-health` (site-admin) renders every env-gated feature grouped critical/recommended/optional with Configured/Missing pills — kills the "feature silently off" class of mystery. `src/lib/env-health.ts` (pure `collectEnvHealth`, secret values never exposed) + `src/routes/admin-env-health.tsx` + tests (12).
873- **Conservative AI model routing.** `modelForTask(task)` in `src/lib/ai-client.ts`: explicit Haiku allowlist (commit-message, issue-triage, pr-triage, label-suggest — short, human-overridden outputs only); everything code-judging/code-writing stays Sonnet, unknown tasks default Sonnet. `AI_FORCE_SONNET=1` kill-switch read per-call. Call sites switched in `ai-commit-message.ts` + `ai-generators.ts`. Owner-approved 2026-06-12 ("as long as Haiku won't cause us any harm"). Future Haiku candidates (standup, smart-digest, nl-search ranking, onboarding copy) need owner sign-off. Tests: `src/__tests__/model-routing.test.ts` (14).
874- Deploy note: migration 0105 must apply (self-deploy runs db:migrate; safe to no-op).
Addeddrizzle/0105_repair_flywheel_patch.sql+14−0View fileUnifiedSplit
@@ -0,0 +1,14 @@
1-- Repair Flywheel — full-patch storage for Tier-0 cache replay
2--
3-- Migration 0039 stores only a 400-char patch_summary — enough for the
4-- /admin/repair-flywheel dashboard, but not enough to REPLAY a repair on a
5-- cache hit. This adds the full unified-diff patch (capped at write-site,
6-- ~64KB) so ci-autofix can serve a previously-successful fix without an
7-- AI call (BUILD_BIBLE §7 finding 1: close the flywheel loop).
8--
9-- src/db/schema.ts is locked (§4.1), so this column is intentionally NOT on
10-- the drizzle table object — src/lib/repair-flywheel.ts reads/writes it via
11-- raw SQL fragments. Strictly additive; nullable so existing rows are fine
12-- (they simply can't be replayed and fall through to the AI tier).
13
14ALTER TABLE "repair_flywheel" ADD COLUMN IF NOT EXISTS "patch" TEXT;
Addedsrc/__tests__/env-health.test.ts+167−0View fileUnifiedSplit
@@ -0,0 +1,167 @@
1/**
2 * Environment / feature health tests (src/lib/env-health.ts).
3 *
4 * Pure-function checks against synthetic env objects — no DB, no real
5 * process.env mutation. Also smoke-tests the /admin/env-health route's
6 * auth gate against the standalone Hono module (the route is mounted in
7 * app.tsx, but testing the module directly keeps this independent of
8 * mount order).
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 collectEnvHealth,
14 groupBySeverity,
15 SEVERITY_ORDER,
16 type EnvHealthItem,
17} from "../lib/env-health";
18import envHealthRoutes from "../routes/admin-env-health";
19
20/** A fully-wired synthetic env — every feature should report configured. */
21const FULL_ENV: NodeJS.ProcessEnv = {
22 ANTHROPIC_API_KEY: "sk-ant-secret-aaa",
23 EMAIL_PROVIDER: "resend",
24 RESEND_API_KEY: "re_secret_bbb",
25 APP_BASE_URL: "https://gluecron.com",
26 VOYAGE_API_KEY: "pa-secret-ccc",
27 GATETEST_URL: "https://gatetest.ai/api/events/push",
28 GATETEST_API_KEY: "gt_secret_ddd",
29 GLUECRON_WEBHOOK_SECRET: "whsec_secret_eee",
30 PREVIEW_DOMAIN: "https://previews.gluecron.com",
31 SENTRY_DSN: "https://abc@o1.ingest.sentry.io/1",
32 ERROR_WEBHOOK_URL: "https://hooks.example.com/errors",
33 SSH_HOST_KEY: "-----BEGIN OPENSSH PRIVATE KEY-----secret-fff",
34 REDIS_URL: "redis://:redis-secret-ggg@localhost:6379",
35 GOOGLE_OAUTH_CLIENT_ID: "abc.apps.googleusercontent.com",
36 GOOGLE_OAUTH_CLIENT_SECRET: "google-secret-hhh",
37 AI_AUTO_ISSUES: "1",
38 DEPENDENCY_SCAN_ENABLED: "1",
39};
40
41describe("collectEnvHealth — configured detection", () => {
42 it("reports every feature missing on an empty env", () => {
43 const items = collectEnvHealth({});
44 expect(items.length).toBeGreaterThanOrEqual(12);
45 expect(items.every((i) => i.configured === false)).toBe(true);
46 });
47
48 it("reports every feature configured on a fully-wired env", () => {
49 const items = collectEnvHealth(FULL_ENV);
50 expect(items.every((i) => i.configured === true)).toBe(true);
51 });
52
53 it("email needs EMAIL_PROVIDER=resend, not just the key", () => {
54 const find = (env: NodeJS.ProcessEnv) =>
55 collectEnvHealth(env).find((i) => i.envVars.includes("RESEND_API_KEY"))!;
56 // Key alone: still the dev "log" provider — mail goes to stderr.
57 expect(find({ RESEND_API_KEY: "re_x" }).configured).toBe(false);
58 // Provider alone: nothing to authenticate with.
59 expect(find({ EMAIL_PROVIDER: "resend" }).configured).toBe(false);
60 expect(
61 find({ EMAIL_PROVIDER: "resend", RESEND_API_KEY: "re_x" }).configured
62 ).toBe(true);
63 });
64
65 it("APP_BASE_URL pointing at localhost does not count", () => {
66 const find = (env: NodeJS.ProcessEnv) =>
67 collectEnvHealth(env).find((i) => i.envVars.includes("APP_BASE_URL"))!;
68 expect(find({}).configured).toBe(false);
69 expect(find({ APP_BASE_URL: "http://localhost:3000" }).configured).toBe(
70 false
71 );
72 expect(find({ APP_BASE_URL: "https://gluecron.com" }).configured).toBe(
73 true
74 );
75 });
76
77 it("either-or pairs: REDIS_URL/VALKEY_URL and SENTRY_DSN/ERROR_WEBHOOK_URL", () => {
78 const sse = (env: NodeJS.ProcessEnv) =>
79 collectEnvHealth(env).find((i) => i.envVars.includes("REDIS_URL"))!;
80 expect(sse({ REDIS_URL: "redis://x" }).configured).toBe(true);
81 expect(sse({ VALKEY_URL: "redis://y" }).configured).toBe(true);
82 expect(sse({}).configured).toBe(false);
83
84 const errs = (env: NodeJS.ProcessEnv) =>
85 collectEnvHealth(env).find((i) => i.envVars.includes("SENTRY_DSN"))!;
86 expect(errs({ SENTRY_DSN: "https://x" }).configured).toBe(true);
87 expect(errs({ ERROR_WEBHOOK_URL: "https://y" }).configured).toBe(true);
88 expect(errs({}).configured).toBe(false);
89 });
90
91 it("opt-in flags require the literal \"1\"", () => {
92 const auto = (env: NodeJS.ProcessEnv) =>
93 collectEnvHealth(env).find((i) => i.envVars.includes("AI_AUTO_ISSUES"))!;
94 expect(auto({ AI_AUTO_ISSUES: "1" }).configured).toBe(true);
95 expect(auto({ AI_AUTO_ISSUES: "true" }).configured).toBe(false);
96 expect(auto({ AI_AUTO_ISSUES: "0" }).configured).toBe(false);
97 });
98
99 it("whitespace-only values count as unset", () => {
100 const items = collectEnvHealth({ ANTHROPIC_API_KEY: " " });
101 const ai = items.find((i) => i.envVars.includes("ANTHROPIC_API_KEY"))!;
102 expect(ai.configured).toBe(false);
103 });
104});
105
106describe("collectEnvHealth — shape + severity", () => {
107 it("every item has the full shape and a valid severity", () => {
108 for (const item of collectEnvHealth(FULL_ENV)) {
109 expect(typeof item.feature).toBe("string");
110 expect(item.feature.length).toBeGreaterThan(0);
111 expect(Array.isArray(item.envVars)).toBe(true);
112 expect(item.envVars.length).toBeGreaterThan(0);
113 expect(typeof item.configured).toBe("boolean");
114 expect(typeof item.impact).toBe("string");
115 expect(item.impact.length).toBeGreaterThan(0);
116 expect(SEVERITY_ORDER).toContain(item.severity);
117 }
118 });
119
120 it("groupBySeverity returns critical → recommended → optional, no empties", () => {
121 const groups = groupBySeverity(collectEnvHealth({}));
122 expect(groups.map((g) => g.severity)).toEqual([
123 "critical",
124 "recommended",
125 "optional",
126 ]);
127 for (const g of groups) {
128 expect(g.items.length).toBeGreaterThan(0);
129 expect(g.items.every((i) => i.severity === g.severity)).toBe(true);
130 }
131 });
132
133 it("groupBySeverity drops empty buckets", () => {
134 const only: EnvHealthItem[] = [
135 {
136 feature: "x",
137 envVars: ["X"],
138 configured: false,
139 impact: "y",
140 severity: "optional",
141 },
142 ];
143 const groups = groupBySeverity(only);
144 expect(groups.length).toBe(1);
145 expect(groups[0]!.severity).toBe("optional");
146 });
147});
148
149describe("collectEnvHealth — never leaks values", () => {
150 it("no secret value from the env appears anywhere in the output", () => {
151 const serialized = JSON.stringify(collectEnvHealth(FULL_ENV));
152 // Short non-secret knobs ("1", "resend") legitimately appear in the
153 // impact prose — only assert on values long enough to be secrets/URLs.
154 for (const value of Object.values(FULL_ENV)) {
155 if (!value || value.length < 8) continue;
156 expect(serialized).not.toContain(value);
157 }
158 });
159});
160
161describe("/admin/env-health — auth gate", () => {
162 it("GET without auth → 302 /login", async () => {
163 const res = await envHealthRoutes.request("/admin/env-health");
164 expect(res.status).toBe(302);
165 expect(res.headers.get("location") || "").toContain("/login");
166 });
167});
Addedsrc/__tests__/model-routing.test.ts+118−0View fileUnifiedSplit
@@ -0,0 +1,118 @@
1/**
2 * Tests for the task → model router in src/lib/ai-client.ts.
3 *
4 * Routing policy under test:
5 * - Only the light, human-reviewed tasks (commit-message, issue-triage,
6 * pr-triage, label-suggest) may run on Haiku.
7 * - Everything that writes or judges code — and any unknown task —
8 * defaults to Sonnet.
9 * - AI_FORCE_SONNET=1 is an instant kill-switch that forces Sonnet for
10 * every task, read from the environment at call time.
11 */
12
13import { describe, it, expect, afterEach } from "bun:test";
14import {
15 modelForTask,
16 MODEL_SONNET,
17 MODEL_HAIKU,
18 type AiTask,
19} from "../lib/ai-client";
20
21const ORIGINAL_FORCE_SONNET = process.env.AI_FORCE_SONNET;
22
23afterEach(() => {
24 // Restore whatever the environment had before the suite ran.
25 if (ORIGINAL_FORCE_SONNET === undefined) {
26 delete process.env.AI_FORCE_SONNET;
27 } else {
28 process.env.AI_FORCE_SONNET = ORIGINAL_FORCE_SONNET;
29 }
30});
31
32// ──────────────────────────── Haiku allowlist ────────────────────────────
33
34describe("modelForTask — Haiku allowlist", () => {
35 const haikuTasks: AiTask[] = [
36 "commit-message",
37 "issue-triage",
38 "pr-triage",
39 "label-suggest",
40 ];
41
42 for (const task of haikuTasks) {
43 it(`routes ${task} to Haiku`, () => {
44 delete process.env.AI_FORCE_SONNET;
45 expect(modelForTask(task)).toBe(MODEL_HAIKU);
46 });
47 }
48});
49
50// ──────────────────────────── Sonnet defaults ────────────────────────────
51
52describe("modelForTask — code-critical and doc tasks stay on Sonnet", () => {
53 const sonnetTasks: AiTask[] = [
54 "code-review",
55 "code-completion",
56 "spec-to-pr",
57 "ci-heal",
58 "pr-summary",
59 "changelog",
60 ];
61
62 for (const task of sonnetTasks) {
63 it(`routes ${task} to Sonnet`, () => {
64 delete process.env.AI_FORCE_SONNET;
65 expect(modelForTask(task)).toBe(MODEL_SONNET);
66 });
67 }
68
69 it("defaults unknown tasks to Sonnet (never silently downgrades)", () => {
70 delete process.env.AI_FORCE_SONNET;
71 // Simulate a typo'd / future task name slipping past the type system.
72 expect(modelForTask("some-future-task" as AiTask)).toBe(MODEL_SONNET);
73 expect(modelForTask("" as AiTask)).toBe(MODEL_SONNET);
74 });
75});
76
77// ──────────────────────────── kill-switch ────────────────────────────
78
79describe("modelForTask — AI_FORCE_SONNET kill-switch", () => {
80 const allTasks: AiTask[] = [
81 "commit-message",
82 "issue-triage",
83 "pr-triage",
84 "label-suggest",
85 "code-review",
86 "code-completion",
87 "spec-to-pr",
88 "ci-heal",
89 "pr-summary",
90 "changelog",
91 ];
92
93 it("forces Sonnet for every task when AI_FORCE_SONNET=1", () => {
94 process.env.AI_FORCE_SONNET = "1";
95 for (const task of allTasks) {
96 expect(modelForTask(task)).toBe(MODEL_SONNET);
97 }
98 });
99
100 it("is read at call time — flipping the env var takes effect immediately", () => {
101 delete process.env.AI_FORCE_SONNET;
102 expect(modelForTask("commit-message")).toBe(MODEL_HAIKU);
103
104 process.env.AI_FORCE_SONNET = "1";
105 expect(modelForTask("commit-message")).toBe(MODEL_SONNET);
106
107 delete process.env.AI_FORCE_SONNET;
108 expect(modelForTask("commit-message")).toBe(MODEL_HAIKU);
109 });
110
111 it("only the exact value \"1\" activates the kill-switch", () => {
112 process.env.AI_FORCE_SONNET = "0";
113 expect(modelForTask("commit-message")).toBe(MODEL_HAIKU);
114
115 process.env.AI_FORCE_SONNET = "";
116 expect(modelForTask("commit-message")).toBe(MODEL_HAIKU);
117 });
118});
Addedsrc/__tests__/repair-flywheel-wiring.test.ts+343−0View fileUnifiedSplit
@@ -0,0 +1,343 @@
1/**
2 * Repair-flywheel ↔ ci-autofix wiring tests (BUILD_BIBLE §7 finding 1).
3 *
4 * Drives `resolveAutofix` / `recordAutofixOutcome` through their
5 * dependency-injection seam (`CiAutofixDeps`) so no DB, git, or Anthropic
6 * call is touched — same pattern as the auto-merge fast-lane suite.
7 *
8 * Covers the closed-loop contract:
9 * - Tier-0 cache hit serves the cached patch WITHOUT an AI call and
10 * records a 'cached'-tier pending row with cache lineage + the patch
11 * - cache miss / low success rate / missing patch fall through to AI
12 * - outcomes settle via updateOutcome on apply success AND failure
13 * - flywheel errors (lookup, record, settle) never break the autofix
14 * path — everything fails open to the AI tier
15 * - isAiAvailable() gating: no key + cache miss → no fix, AI never called;
16 * no key + cache hit → cached fix still served (graceful degradation)
17 */
18
19import { describe, it, expect } from "bun:test";
20import { readFileSync } from "node:fs";
21import { join } from "node:path";
22
23import {
24 resolveAutofix,
25 recordAutofixOutcome,
26 extractFlywheelEntryId,
27 CACHE_MIN_SUCCESS_RATE,
28 FLYWHEEL_MARKER_PREFIX,
29 type AutofixPlan,
30 type CiAutofixDeps,
31 type ClaudeAutofixResponse,
32} from "../lib/ci-autofix";
33import type { CachedRepair, RecordRepairInput } from "../lib/repair-flywheel";
34
35// ---------------------------------------------------------------------------
36// Fixtures
37// ---------------------------------------------------------------------------
38
39const CACHED_PATCH = `--- a/src/foo.ts
40+++ b/src/foo.ts
41@@ -1,3 +1,3 @@
42-const x: string = 42;
43+const x = 42;
44`;
45
46const AI_FIX: ClaudeAutofixResponse = {
47 patch: "--- a/src/bar.ts\n+++ b/src/bar.ts\n@@ -1 +1 @@\n-old\n+new\n",
48 explanation: "Fixes the type error in bar.ts.",
49 confidence: "high",
50 affectedFiles: ["src/bar.ts"],
51};
52
53const FAILURE_TEXT =
54 "error TS2322: Type 'number' is not assignable to type 'string' at src/foo.ts:1:7";
55
56const PATTERN_ID = "11111111-2222-3333-4444-555555555555";
57const NEW_ENTRY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
58
59function makeCached(overrides: Partial<CachedRepair> = {}): CachedRepair {
60 return {
61 id: PATTERN_ID,
62 patchSummary: "Drop the bogus string annotation on x.",
63 patch: CACHED_PATCH,
64 filesChanged: ["src/foo.ts"],
65 commitSha: null,
66 hitCount: 3,
67 successRate: 1,
68 classification: null,
69 appliedCount: 4,
70 ...overrides,
71 };
72}
73
74interface Harness {
75 deps: CiAutofixDeps;
76 aiCalls: number;
77 recorded: RecordRepairInput[];
78 outcomes: Array<{ id: string; outcome: string }>;
79 generateAiFix: () => Promise<ClaudeAutofixResponse | null>;
80}
81
82function makeHarness(opts: {
83 cached?: CachedRepair | null;
84 aiFix?: ClaudeAutofixResponse | null;
85 aiAvailable?: boolean;
86 // Throw-injection hooks (default false everywhere)
87 lookupThrows?: boolean;
88 recordThrows?: boolean;
89 updateOutcomeThrows?: boolean;
90} = {}): Harness {
91 const recorded: RecordRepairInput[] = [];
92 const outcomes: Array<{ id: string; outcome: string }> = [];
93
94 const harness: Harness = {
95 aiCalls: 0,
96 recorded,
97 outcomes,
98 generateAiFix: async () => {
99 harness.aiCalls += 1;
100 return opts.aiFix === undefined ? AI_FIX : opts.aiFix;
101 },
102 deps: {
103 findCachedRepair: async () => {
104 if (opts.lookupThrows) throw new Error("flywheel db down");
105 return opts.cached ?? null;
106 },
107 recordRepair: async (input) => {
108 if (opts.recordThrows) throw new Error("insert failed");
109 recorded.push(input);
110 return NEW_ENTRY_ID;
111 },
112 updateOutcome: async (id, outcome) => {
113 if (opts.updateOutcomeThrows) throw new Error("update failed");
114 outcomes.push({ id, outcome });
115 },
116 aiAvailable: () => opts.aiAvailable ?? true,
117 },
118 };
119 return harness;
120}
121
122function run(h: Harness): Promise<AutofixPlan | null> {
123 return resolveAutofix({
124 repositoryId: "repo-1",
125 failureText: FAILURE_TEXT,
126 generateAiFix: h.generateAiFix,
127 deps: h.deps,
128 });
129}
130
131// ---------------------------------------------------------------------------
132// Tier 0: cache hit
133// ---------------------------------------------------------------------------
134
135describe("resolveAutofix — cache hit (Tier 0)", () => {
136 it("serves the cached patch without calling the AI", async () => {
137 const h = makeHarness({ cached: makeCached() });
138 const plan = await run(h);
139
140 expect(plan).not.toBeNull();
141 expect(plan!.source).toBe("cache");
142 expect(plan!.fix.patch).toBe(CACHED_PATCH);
143 expect(plan!.cachedPatternId).toBe(PATTERN_ID);
144 expect(h.aiCalls).toBe(0);
145 });
146
147 it("records a 'cached'-tier pending row with cache lineage + the patch", async () => {
148 const h = makeHarness({ cached: makeCached() });
149 const plan = await run(h);
150
151 expect(h.recorded.length).toBe(1);
152 expect(h.recorded[0]!.tier).toBe("cached");
153 expect(h.recorded[0]!.parentPatternId).toBe(PATTERN_ID);
154 expect(h.recorded[0]!.repositoryId).toBe("repo-1");
155 expect(h.recorded[0]!.failureText).toBe(FAILURE_TEXT);
156 // Carried forward so the new row is itself replayable once it settles
157 expect(h.recorded[0]!.patch).toBe(CACHED_PATCH);
158 // outcome defaults to 'pending' inside recordRepair — must not be forced
159 expect(h.recorded[0]!.outcome).toBeUndefined();
160 expect(plan!.flywheelEntryId).toBe(NEW_ENTRY_ID);
161 });
162
163 it("maps success rate to confidence (>=0.9 high, else medium)", async () => {
164 const high = await run(makeHarness({ cached: makeCached({ successRate: 0.95 }) }));
165 expect(high!.fix.confidence).toBe("high");
166
167 const med = await run(makeHarness({ cached: makeCached({ successRate: 0.6 }) }));
168 expect(med!.fix.confidence).toBe("medium");
169 });
170
171 it("works even when the AI is unavailable (graceful degradation)", async () => {
172 const h = makeHarness({ cached: makeCached(), aiAvailable: false });
173 const plan = await run(h);
174
175 expect(plan).not.toBeNull();
176 expect(plan!.source).toBe("cache");
177 expect(h.aiCalls).toBe(0);
178 });
179});
180
181// ---------------------------------------------------------------------------
182// Cache miss / unusable hit → fall through to the AI tier
183// ---------------------------------------------------------------------------
184
185describe("resolveAutofix — fall-through to AI (Tier 2)", () => {
186 it("cache miss falls through to the AI and records an 'ai-sonnet' row", async () => {
187 const h = makeHarness({ cached: null });
188 const plan = await run(h);
189
190 expect(h.aiCalls).toBe(1);
191 expect(plan!.source).toBe("ai");
192 expect(plan!.fix).toEqual(AI_FIX);
193 expect(plan!.cachedPatternId).toBeNull();
194 expect(h.recorded.length).toBe(1);
195 expect(h.recorded[0]!.tier).toBe("ai-sonnet");
196 // The AI patch is stored so the entry is replayable once it succeeds
197 expect(h.recorded[0]!.patch).toBe(AI_FIX.patch);
198 expect(plan!.flywheelEntryId).toBe(NEW_ENTRY_ID);
199 });
200
201 it("hit below CACHE_MIN_SUCCESS_RATE is not replayed", async () => {
202 const h = makeHarness({
203 cached: makeCached({ successRate: CACHE_MIN_SUCCESS_RATE - 0.01 }),
204 });
205 const plan = await run(h);
206
207 expect(h.aiCalls).toBe(1);
208 expect(plan!.source).toBe("ai");
209 });
210
211 it("hit with no stored patch (pre-0105 row) is not replayed", async () => {
212 const h = makeHarness({ cached: makeCached({ patch: null }) });
213 const plan = await run(h);
214
215 expect(h.aiCalls).toBe(1);
216 expect(plan!.source).toBe("ai");
217 });
218
219 it("returns null when the AI declines (no patch)", async () => {
220 const h = makeHarness({ cached: null, aiFix: null });
221 expect(await run(h)).toBeNull();
222 expect(h.recorded.length).toBe(0);
223 });
224
225 it("returns null on a low-confidence AI fix without recording it", async () => {
226 const h = makeHarness({
227 cached: null,
228 aiFix: { ...AI_FIX, confidence: "low" },
229 });
230 expect(await run(h)).toBeNull();
231 expect(h.recorded.length).toBe(0);
232 });
233
234 it("returns null (and never calls the AI) when no key + cache miss", async () => {
235 const h = makeHarness({ cached: null, aiAvailable: false });
236 expect(await run(h)).toBeNull();
237 expect(h.aiCalls).toBe(0);
238 });
239});
240
241// ---------------------------------------------------------------------------
242// Fail-open: flywheel errors never break the autofix path
243// ---------------------------------------------------------------------------
244
245describe("resolveAutofix — flywheel errors fail open", () => {
246 it("a throwing findCachedRepair degrades to the AI tier", async () => {
247 const h = makeHarness({ lookupThrows: true });
248 const plan = await run(h);
249
250 expect(plan!.source).toBe("ai");
251 expect(h.aiCalls).toBe(1);
252 });
253
254 it("a throwing recordRepair still serves the cached fix (entry id null)", async () => {
255 const h = makeHarness({ cached: makeCached(), recordThrows: true });
256 const plan = await run(h);
257
258 expect(plan!.source).toBe("cache");
259 expect(plan!.fix.patch).toBe(CACHED_PATCH);
260 expect(plan!.flywheelEntryId).toBeNull();
261 expect(h.aiCalls).toBe(0);
262 });
263
264 it("a throwing recordRepair still serves the AI fix (entry id null)", async () => {
265 const h = makeHarness({ cached: null, recordThrows: true });
266 const plan = await run(h);
267
268 expect(plan!.source).toBe("ai");
269 expect(plan!.fix).toEqual(AI_FIX);
270 expect(plan!.flywheelEntryId).toBeNull();
271 });
272});
273
274// ---------------------------------------------------------------------------
275// Outcome settling (recordAutofixOutcome ← applyAutofix)
276// ---------------------------------------------------------------------------
277
278describe("recordAutofixOutcome", () => {
279 const bodyWithMarker = `<!-- gluecron:ci-autofix:v1 -->\n${FLYWHEEL_MARKER_PREFIX}${NEW_ENTRY_ID} -->\n\n## 🔧 AI Auto-Fix`;
280
281 it("settles 'success' for the entry referenced by the comment", async () => {
282 const h = makeHarness();
283 await recordAutofixOutcome(bodyWithMarker, "success", h.deps);
284 expect(h.outcomes).toEqual([{ id: NEW_ENTRY_ID, outcome: "success" }]);
285 });
286
287 it("settles 'failed' when the apply blows up", async () => {
288 const h = makeHarness();
289 await recordAutofixOutcome(bodyWithMarker, "failed", h.deps);
290 expect(h.outcomes).toEqual([{ id: NEW_ENTRY_ID, outcome: "failed" }]);
291 });
292
293 it("is a no-op when the comment carries no flywheel marker", async () => {
294 const h = makeHarness();
295 await recordAutofixOutcome("<!-- gluecron:ci-autofix:v1 -->", "success", h.deps);
296 expect(h.outcomes.length).toBe(0);
297 });
298
299 it("never throws even when updateOutcome throws", async () => {
300 const h = makeHarness({ updateOutcomeThrows: true });
301 await expect(
302 recordAutofixOutcome(bodyWithMarker, "success", h.deps)
303 ).resolves.toBeUndefined();
304 });
305});
306
307describe("extractFlywheelEntryId", () => {
308 it("round-trips an id through the comment marker", () => {
309 const body = `hello\n${FLYWHEEL_MARKER_PREFIX}${PATTERN_ID} -->\nworld`;
310 expect(extractFlywheelEntryId(body)).toBe(PATTERN_ID);
311 });
312
313 it("returns null when absent", () => {
314 expect(extractFlywheelEntryId("no markers here")).toBeNull();
315 });
316});
317
318// ---------------------------------------------------------------------------
319// Wiring regression guards — the seam must actually be plumbed into the
320// live paths (same readFileSync technique as the fast-lane suite).
321// ---------------------------------------------------------------------------
322
323describe("ci-autofix source wiring", () => {
324 const src = readFileSync(
325 join(import.meta.dir, "../lib/ci-autofix.ts"),
326 "utf8"
327 );
328
329 it("_runAutofix routes through resolveAutofix (cache before AI)", () => {
330 expect(src).toContain("const plan = await resolveAutofix({");
331 });
332
333 it("applyAutofix settles both success and failed outcomes", () => {
334 expect(src).toContain('recordAutofixOutcome(comment.body, "success"');
335 expect(src).toContain('recordAutofixOutcome(comment.body, "failed"');
336 });
337
338 it("imports the flywheel cache + outcome functions", () => {
339 expect(src).toContain("findCachedRepair");
340 expect(src).toContain("updateOutcome");
341 expect(src).toContain('from "./repair-flywheel"');
342 });
343});
Addedsrc/__tests__/ssrf-guard.test.ts+402−0View fileUnifiedSplit
@@ -0,0 +1,402 @@
1/**
2 * SSRF guard tests — P1 security hardening (BUILD_BIBLE §7).
3 *
4 * Pins down `src/lib/ssrf-guard.ts`:
5 * - isPrivateAddress(): every reserved IPv4 range (incl. the
6 * decimal/hex/octal single-integer encodings), IPv6 private forms,
7 * and the localhost/.local/.internal hostname conventions
8 * - assertPublicUrl(): scheme allow-list, private-host rejection, and
9 * the SSRF_ALLOW_PRIVATE / test-env escape hatches
10 * - resolvesToPrivate(): injectable resolver, best-effort on failure
11 * - mirrors wiring: validateUpstreamUrl() rejects private upstreams
12 *
13 * The guard default-allows private addresses when NODE_ENV=test, so the
14 * env-dependent suites set SSRF_ENFORCE_IN_TEST=1 up front and restore
15 * the original env afterwards. isPrivateAddress() itself is pure and
16 * needs no env handling.
17 */
18
19import { afterAll, beforeAll, describe, expect, it } from "bun:test";
20import {
21 assertPublicUrl,
22 isPrivateAddress,
23 parseIpv4,
24 resolvesToPrivate,
25 ssrfPrivateAllowed,
26} from "../lib/ssrf-guard";
27import { validateUpstreamUrl } from "../lib/mirrors";
28
29// ---------------------------------------------------------------------------
30// Env bookkeeping — enforce blocking for this file, restore on exit.
31// ---------------------------------------------------------------------------
32
33const ORIGINAL_ENV = {
34 enforce: process.env.SSRF_ENFORCE_IN_TEST,
35 allow: process.env.SSRF_ALLOW_PRIVATE,
36};
37
38beforeAll(() => {
39 process.env.SSRF_ENFORCE_IN_TEST = "1";
40 delete process.env.SSRF_ALLOW_PRIVATE;
41});
42
43afterAll(() => {
44 if (ORIGINAL_ENV.enforce === undefined) {
45 delete process.env.SSRF_ENFORCE_IN_TEST;
46 } else {
47 process.env.SSRF_ENFORCE_IN_TEST = ORIGINAL_ENV.enforce;
48 }
49 if (ORIGINAL_ENV.allow === undefined) {
50 delete process.env.SSRF_ALLOW_PRIVATE;
51 } else {
52 process.env.SSRF_ALLOW_PRIVATE = ORIGINAL_ENV.allow;
53 }
54});
55
56// ---------------------------------------------------------------------------
57// isPrivateAddress — hostname conventions
58// ---------------------------------------------------------------------------
59
60describe("ssrf-guard — isPrivateAddress hostnames", () => {
61 it("blocks localhost and *.localhost", () => {
62 expect(isPrivateAddress("localhost")).toBe(true);
63 expect(isPrivateAddress("LOCALHOST")).toBe(true);
64 expect(isPrivateAddress("foo.localhost")).toBe(true);
65 expect(isPrivateAddress("a.b.localhost")).toBe(true);
66 expect(isPrivateAddress("localhost.")).toBe(true); // trailing-dot FQDN
67 });
68
69 it("blocks .local and .internal TLDs", () => {
70 expect(isPrivateAddress("printer.local")).toBe(true);
71 expect(isPrivateAddress("db.prod.internal")).toBe(true);
72 expect(isPrivateAddress("metadata.google.internal")).toBe(true);
73 });
74
75 it("does not block lookalike hostnames", () => {
76 expect(isPrivateAddress("localhost.example.com")).toBe(false);
77 expect(isPrivateAddress("notlocalhost")).toBe(false);
78 expect(isPrivateAddress("internal.example.com")).toBe(false);
79 expect(isPrivateAddress("locality.example.org")).toBe(false);
80 });
81
82 it("blocks the empty host", () => {
83 expect(isPrivateAddress("")).toBe(true);
84 });
85});
86
87// ---------------------------------------------------------------------------
88// isPrivateAddress — IPv4 ranges
89// ---------------------------------------------------------------------------
90
91describe("ssrf-guard — isPrivateAddress IPv4 ranges", () => {
92 it("blocks 127.0.0.0/8 loopback", () => {
93 expect(isPrivateAddress("127.0.0.1")).toBe(true);
94 expect(isPrivateAddress("127.255.255.255")).toBe(true);
95 expect(isPrivateAddress("127.1.2.3")).toBe(true);
96 });
97
98 it("blocks 10.0.0.0/8", () => {
99 expect(isPrivateAddress("10.0.0.1")).toBe(true);
100 expect(isPrivateAddress("10.255.255.255")).toBe(true);
101 });
102
103 it("blocks 172.16.0.0/12 (and only that slice of 172/8)", () => {
104 expect(isPrivateAddress("172.16.0.1")).toBe(true);
105 expect(isPrivateAddress("172.31.255.255")).toBe(true);
106 expect(isPrivateAddress("172.15.0.1")).toBe(false);
107 expect(isPrivateAddress("172.32.0.1")).toBe(false);
108 });
109
110 it("blocks 192.168.0.0/16", () => {
111 expect(isPrivateAddress("192.168.0.1")).toBe(true);
112 expect(isPrivateAddress("192.168.255.255")).toBe(true);
113 expect(isPrivateAddress("192.169.0.1")).toBe(false);
114 });
115
116 it("blocks 169.254.0.0/16 link-local (cloud metadata)", () => {
117 expect(isPrivateAddress("169.254.169.254")).toBe(true);
118 expect(isPrivateAddress("169.254.0.1")).toBe(true);
119 expect(isPrivateAddress("169.253.0.1")).toBe(false);
120 });
121
122 it("blocks 100.64.0.0/10 CGNAT (and only that slice of 100/8)", () => {
123 expect(isPrivateAddress("100.64.0.1")).toBe(true);
124 expect(isPrivateAddress("100.127.255.255")).toBe(true);
125 expect(isPrivateAddress("100.63.255.255")).toBe(false);
126 expect(isPrivateAddress("100.128.0.1")).toBe(false);
127 });
128
129 it("blocks 0.0.0.0/8 and the broadcast address", () => {
130 expect(isPrivateAddress("0.0.0.0")).toBe(true);
131 expect(isPrivateAddress("0.1.2.3")).toBe(true);
132 expect(isPrivateAddress("255.255.255.255")).toBe(true);
133 });
134
135 it("allows public IPv4", () => {
136 expect(isPrivateAddress("8.8.8.8")).toBe(false);
137 expect(isPrivateAddress("1.1.1.1")).toBe(false);
138 expect(isPrivateAddress("93.184.216.34")).toBe(false);
139 expect(isPrivateAddress("255.255.255.254")).toBe(false);
140 });
141});
142
143// ---------------------------------------------------------------------------
144// isPrivateAddress — integer/hex/octal IPv4 encodings
145// ---------------------------------------------------------------------------
146
147describe("ssrf-guard — IPv4 integer encodings", () => {
148 it("parses single-integer forms (parseIpv4)", () => {
149 expect(parseIpv4("2130706433")).toBe(0x7f000001); // 127.0.0.1
150 expect(parseIpv4("0x7f000001")).toBe(0x7f000001);
151 expect(parseIpv4("017700000001")).toBe(0x7f000001); // octal
152 expect(parseIpv4("example.com")).toBeNull();
153 expect(parseIpv4("4294967296")).toBeNull(); // > 0xffffffff
154 });
155
156 it("blocks decimal-integer loopback (http://2130706433/)", () => {
157 expect(isPrivateAddress("2130706433")).toBe(true);
158 });
159
160 it("blocks hex-integer loopback (0x7f000001)", () => {
161 expect(isPrivateAddress("0x7f000001")).toBe(true);
162 });
163
164 it("blocks octal-integer loopback (017700000001)", () => {
165 expect(isPrivateAddress("017700000001")).toBe(true);
166 });
167
168 it("blocks mixed-radix dotted forms", () => {
169 expect(isPrivateAddress("0177.0.0.1")).toBe(true); // octal octet
170 expect(isPrivateAddress("0xa.0.0.1")).toBe(true); // hex octet → 10.0.0.1
171 expect(isPrivateAddress("0x7f.1")).toBe(true); // 2-part → 127.0.0.1
172 expect(isPrivateAddress("127.1")).toBe(true); // inet_aton short form
173 expect(isPrivateAddress("169.254.43518")).toBe(true); // 3-part link-local
174 });
175
176 it("allows public integer encodings", () => {
177 expect(isPrivateAddress("134744072")).toBe(false); // 8.8.8.8
178 expect(isPrivateAddress("0x08080808")).toBe(false);
179 });
180});
181
182// ---------------------------------------------------------------------------
183// isPrivateAddress — IPv6 forms
184// ---------------------------------------------------------------------------
185
186describe("ssrf-guard — isPrivateAddress IPv6", () => {
187 it("blocks ::1 loopback and :: unspecified", () => {
188 expect(isPrivateAddress("::1")).toBe(true);
189 expect(isPrivateAddress("::")).toBe(true);
190 expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
191 });
192
193 it("handles the URL bracket form", () => {
194 expect(isPrivateAddress("[::1]")).toBe(true);
195 expect(isPrivateAddress("[fe80::1]")).toBe(true);
196 expect(isPrivateAddress("[2606:4700::1111]")).toBe(false);
197 });
198
199 it("blocks fc00::/7 unique-local", () => {
200 expect(isPrivateAddress("fc00::1")).toBe(true);
201 expect(isPrivateAddress("fd12:3456:789a::1")).toBe(true);
202 expect(isPrivateAddress("fdff:ffff::1")).toBe(true);
203 expect(isPrivateAddress("fbff::1")).toBe(false); // just below fc00::/7
204 expect(isPrivateAddress("fe00::1")).toBe(false); // just above
205 });
206
207 it("blocks fe80::/10 link-local (incl. zone ids)", () => {
208 expect(isPrivateAddress("fe80::1")).toBe(true);
209 expect(isPrivateAddress("febf::1")).toBe(true); // top of /10
210 expect(isPrivateAddress("fe80::1%eth0")).toBe(true);
211 expect(isPrivateAddress("fec0::1")).toBe(false); // above the /10
212 });
213
214 it("blocks IPv4-mapped private addresses", () => {
215 expect(isPrivateAddress("::ffff:10.0.0.1")).toBe(true);
216 expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
217 expect(isPrivateAddress("::ffff:192.168.1.1")).toBe(true);
218 expect(isPrivateAddress("::ffff:169.254.169.254")).toBe(true);
219 expect(isPrivateAddress("::ffff:7f00:1")).toBe(true); // hex-word mapped 127.0.0.1
220 });
221
222 it("allows IPv4-mapped public addresses and public IPv6", () => {
223 expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
224 expect(isPrivateAddress("2606:4700::1111")).toBe(false);
225 expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
226 });
227});
228
229// ---------------------------------------------------------------------------
230// assertPublicUrl
231// ---------------------------------------------------------------------------
232
233describe("ssrf-guard — assertPublicUrl", () => {
234 it("accepts public http/https URLs", () => {
235 const r1 = assertPublicUrl("https://example.com/webhook");
236 expect(r1.ok).toBe(true);
237 if (r1.ok) expect(r1.url.hostname).toBe("example.com");
238 expect(assertPublicUrl("http://api.example.org:8080/x").ok).toBe(true);
239 expect(assertPublicUrl("https://user:pw@example.com/x").ok).toBe(true); // creds allowed
240 });
241
242 it("rejects non-http(s) schemes by default", () => {
243 for (const raw of [
244 "ftp://example.com/x",
245 "file:///etc/passwd",
246 "gopher://example.com/x",
247 "git://example.com/x.git",
248 ]) {
249 const r = assertPublicUrl(raw);
250 expect(r.ok).toBe(false);
251 if (!r.ok) expect(r.reason).toContain("scheme");
252 }
253 });
254
255 it("accepts widened schemes via opts.schemes", () => {
256 const r = assertPublicUrl("git://kernel.org/linux.git", {
257 schemes: ["http:", "https:", "git:"],
258 });
259 expect(r.ok).toBe(true);
260 });
261
262 it("rejects unparseable URLs", () => {
263 const r = assertPublicUrl("not a url");
264 expect(r.ok).toBe(false);
265 if (!r.ok) expect(r.reason).toContain("invalid");
266 });
267
268 it("rejects private hosts in every encoding", () => {
269 for (const raw of [
270 "http://localhost:3000/hook",
271 "http://127.0.0.1/hook",
272 "http://10.1.2.3/hook",
273 "http://172.16.0.1/hook",
274 "http://192.168.1.1/hook",
275 "http://169.254.169.254/latest/meta-data/",
276 "http://100.64.0.1/hook",
277 "http://0.0.0.0/hook",
278 "http://2130706433/hook", // decimal 127.0.0.1 (URL normalizes it)
279 "http://0x7f000001/hook", // hex 127.0.0.1
280 "http://[::1]/hook",
281 "http://[fd00::1]/hook",
282 "http://[fe80::1]/hook",
283 "http://[::ffff:10.0.0.1]/hook",
284 "http://internal-db.local/hook",
285 "http://svc.cluster.internal/hook",
286 ]) {
287 const r = assertPublicUrl(raw);
288 expect(r.ok).toBe(false);
289 if (!r.ok) expect(r.reason).toContain("private");
290 }
291 });
292
293 it("opts.allowPrivate=true bypasses the private-host check", () => {
294 expect(
295 assertPublicUrl("http://127.0.0.1/hook", { allowPrivate: true }).ok
296 ).toBe(true);
297 // ...but not the scheme check.
298 expect(
299 assertPublicUrl("file:///etc/passwd", { allowPrivate: true }).ok
300 ).toBe(false);
301 });
302});
303
304// ---------------------------------------------------------------------------
305// Escape hatches — env read at call time
306// ---------------------------------------------------------------------------
307
308describe("ssrf-guard — env escape hatches", () => {
309 it("SSRF_ALLOW_PRIVATE=1 allows private hosts (read at call time)", () => {
310 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(false);
311 process.env.SSRF_ALLOW_PRIVATE = "1";
312 try {
313 expect(ssrfPrivateAllowed()).toBe(true);
314 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(true);
315 expect(assertPublicUrl("http://192.168.1.1/hook").ok).toBe(true);
316 } finally {
317 delete process.env.SSRF_ALLOW_PRIVATE;
318 }
319 expect(assertPublicUrl("http://127.0.0.1/hook").ok).toBe(false);
320 });
321
322 it("test env default-allows unless SSRF_ENFORCE_IN_TEST=1", () => {
323 // This file sets SSRF_ENFORCE_IN_TEST=1 in beforeAll; lifting it while
324 // NODE_ENV=test (bun test sets it) must default to allow, so existing
325 // suites that POST to localhost keep passing untouched.
326 delete process.env.SSRF_ENFORCE_IN_TEST;
327 try {
328 expect(ssrfPrivateAllowed()).toBe(true);
329 expect(assertPublicUrl("http://localhost:1234/hook").ok).toBe(true);
330 } finally {
331 process.env.SSRF_ENFORCE_IN_TEST = "1";
332 }
333 expect(ssrfPrivateAllowed()).toBe(false);
334 expect(assertPublicUrl("http://localhost:1234/hook").ok).toBe(false);
335 });
336});
337
338// ---------------------------------------------------------------------------
339// resolvesToPrivate — injectable DNS layer
340// ---------------------------------------------------------------------------
341
342describe("ssrf-guard — resolvesToPrivate", () => {
343 it("short-circuits on private literals without resolving", async () => {
344 let called = false;
345 const resolver = async () => {
346 called = true;
347 return [{ address: "8.8.8.8" }];
348 };
349 expect(await resolvesToPrivate("127.0.0.1", resolver)).toBe(true);
350 expect(called).toBe(false);
351 });
352
353 it("flags hostnames that resolve to private addresses", async () => {
354 const resolver = async () => [{ address: "10.0.0.5" }];
355 expect(await resolvesToPrivate("evil.example.com", resolver)).toBe(true);
356 });
357
358 it("flags when any of several records is private", async () => {
359 const resolver = async () => [
360 { address: "93.184.216.34" },
361 { address: "169.254.169.254" },
362 ];
363 expect(await resolvesToPrivate("dual.example.com", resolver)).toBe(true);
364 });
365
366 it("passes hostnames that resolve publicly", async () => {
367 const resolver = async () => [{ address: "93.184.216.34" }];
368 expect(await resolvesToPrivate("example.com", resolver)).toBe(false);
369 });
370
371 it("is best-effort: resolution failure does not block", async () => {
372 const resolver = async () => {
373 throw new Error("ENOTFOUND");
374 };
375 expect(await resolvesToPrivate("nx.example.com", resolver)).toBe(false);
376 });
377});
378
379// ---------------------------------------------------------------------------
380// Wiring — mirrors.validateUpstreamUrl uses the guard
381// ---------------------------------------------------------------------------
382
383describe("ssrf-guard — mirrors wiring", () => {
384 it("validateUpstreamUrl rejects private upstreams when enforced", () => {
385 for (const raw of [
386 "http://127.0.0.1/x.git",
387 "http://localhost:3000/x.git",
388 "https://169.254.169.254/x.git",
389 "git://10.0.0.1/x.git",
390 "http://2130706433/x.git",
391 ]) {
392 const r = validateUpstreamUrl(raw);
393 expect(r.ok).toBe(false);
394 expect(r.error).toContain("SSRF");
395 }
396 });
397
398 it("validateUpstreamUrl still accepts public upstreams", () => {
399 expect(validateUpstreamUrl("https://github.com/foo/bar.git").ok).toBe(true);
400 expect(validateUpstreamUrl("git://kernel.org/linux.git").ok).toBe(true);
401 });
402});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
@@ -109,6 +109,7 @@ import adminOpsRoutes from "./routes/admin-ops";
109109import adminSelfHostRoutes from "./routes/admin-self-host";
110110import adminDiagnoseRoutes from "./routes/admin-diagnose";
111111import adminIntegrationsRoutes from "./routes/admin-integrations";
112import adminEnvHealthRoutes from "./routes/admin-env-health";
112113import adminAdvancementRoutes from "./routes/admin-advancement";
113114import adminSecurityRoutes from "./routes/admin-security";
114115import settingsSessionsRoutes from "./routes/settings-sessions";
@@ -733,6 +734,7 @@ app.route("/", adminStripeRoutes);
733734// SOC 2 security dashboard + readiness checklist (/admin/security, /admin/soc2)
734735app.route("/", adminSecurityRoutes);
735736app.route("/", adminIntegrationsRoutes);
737app.route("/", adminEnvHealthRoutes);
736738app.route("/", adminAdvancementRoutes);
737739app.route("/", adminDeploysRoutes);
738740app.route("/", adminDeploysPageRoutes);
Modifiedsrc/lib/ai-client.ts+52−1View fileUnifiedSplit
@@ -24,9 +24,60 @@ export function isAiAvailable(): boolean {
2424
2525/** Primary model for all AI features — code understanding, review, generation */
2626export const MODEL_SONNET = "claude-sonnet-4-6";
27/** Legacy constant — kept for backwards compatibility, do not use in new code */
27/** Light-task model — never reference directly; route through modelForTask() */
2828export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
2929
30/**
31 * Task → model routing.
32 *
33 * Every AI feature names its task here and asks `modelForTask()` which model
34 * to run. Routing policy is deliberately conservative:
35 *
36 * - Haiku is allowed ONLY for short, low-stakes outputs that a human
37 * always reviews and can trivially override (commit-message drafts,
38 * issue/PR triage suggestions, label suggestions). Worst-case failure
39 * is a bad suggestion someone ignores.
40 * - EVERYTHING else — anything that writes code, judges code, or produces
41 * user-facing documents — stays on Sonnet. Unknown tasks default to
42 * Sonnet too, so a typo can never silently downgrade quality.
43 *
44 * Kill-switch: set AI_FORCE_SONNET=1 to route every task to Sonnet. The env
45 * var is read at call time, so flipping it takes effect on the next request
46 * without a restart.
47 */
48export type AiTask =
49 // Haiku-eligible (short, human-reviewed, low-stakes suggestions)
50 | "commit-message"
51 | "issue-triage"
52 | "pr-triage"
53 | "label-suggest"
54 // Sonnet-only (writes or judges code, or produces user-facing docs)
55 | "code-review"
56 | "code-completion"
57 | "spec-to-pr"
58 | "ci-heal"
59 | "pr-summary"
60 | "changelog";
61
62/** Tasks allowed to run on Haiku. Additions require explicit owner sign-off. */
63const HAIKU_ALLOWLIST: ReadonlySet<AiTask> = new Set<AiTask>([
64 "commit-message",
65 "issue-triage",
66 "pr-triage",
67 "label-suggest",
68]);
69
70/**
71 * Resolve the model for a task. Allowlisted light tasks get Haiku; everything
72 * else (including unknown task strings) gets Sonnet. `AI_FORCE_SONNET=1`
73 * forces Sonnet for all tasks.
74 */
75export function modelForTask(task: AiTask): string {
76 // Read at call time so the kill-switch works without a restart.
77 if (process.env.AI_FORCE_SONNET === "1") return MODEL_SONNET;
78 return HAIKU_ALLOWLIST.has(task) ? MODEL_HAIKU : MODEL_SONNET;
79}
80
3081/**
3182 * Extract text content from an Anthropic message response.
3283 */
Modifiedsrc/lib/ai-commit-message.ts+6−4View fileUnifiedSplit
@@ -20,8 +20,7 @@
2020
2121import {
2222 getAnthropic,
23 MODEL_HAIKU,
24 MODEL_SONNET,
23 modelForTask,
2524 extractText,
2625 parseJsonResponse,
2726 isAiAvailable,
@@ -273,8 +272,11 @@ export async function generateCommitMessage(
273272
274273 try {
275274 const client = getAnthropic();
275 // Commit messages are one-line drafts a human always reviews before
276 // committing — safe for Haiku (subject to the AI_FORCE_SONNET kill-switch).
277 const model = modelForTask("commit-message");
276278 const message = await client.messages.create({
277 model: MODEL_SONNET,
279 model,
278280 max_tokens: 512,
279281 messages: [
280282 {
@@ -287,7 +289,7 @@ export async function generateCommitMessage(
287289 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
288290 const usage = extractUsage(message);
289291 await recordAiCost({
290 model: MODEL_SONNET,
292 model,
291293 inputTokens: usage.input,
292294 outputTokens: usage.output,
293295 category: "other",
Modifiedsrc/lib/ai-generators.ts+13−7View fileUnifiedSplit
@@ -5,8 +5,7 @@
55
66import {
77 getAnthropic,
8 MODEL_HAIKU,
9 MODEL_SONNET,
8 modelForTask,
109 extractText,
1110 parseJsonResponse,
1211 isAiAvailable,
@@ -18,7 +17,8 @@ export async function generateCommitMessage(diff: string): Promise<string> {
1817 }
1918 const client = getAnthropic();
2019 const message = await client.messages.create({
21 model: MODEL_SONNET,
20 // Human-reviewed one-line draft → Haiku-eligible.
21 model: modelForTask("commit-message"),
2222 max_tokens: 512,
2323 messages: [
2424 {
@@ -42,7 +42,8 @@ export async function generatePrSummary(
4242 if (!isAiAvailable() || !diff.trim()) return "";
4343 const client = getAnthropic();
4444 const message = await client.messages.create({
45 model: MODEL_SONNET,
45 // User-facing PR description → stays on Sonnet (routed for the kill-switch).
46 model: modelForTask("pr-summary"),
4647 max_tokens: 1024,
4748 messages: [
4849 {
@@ -82,7 +83,8 @@ export async function generateChangelog(
8283 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
8384 .join("\n");
8485 const message = await client.messages.create({
85 model: MODEL_SONNET,
86 // User-facing release notes → stays on Sonnet (routed for the kill-switch).
87 model: modelForTask("changelog"),
8688 max_tokens: 2048,
8789 messages: [
8890 {
@@ -124,7 +126,9 @@ export async function triageIssue(
124126 if (!isAiAvailable()) return fallback;
125127 const client = getAnthropic();
126128 const message = await client.messages.create({
127 model: MODEL_SONNET,
129 // Label/priority/duplicate suggestions, validated against allowlists and
130 // trivially human-overridden → Haiku-eligible.
131 model: modelForTask("issue-triage"),
128132 max_tokens: 512,
129133 messages: [
130134 {
@@ -200,7 +204,9 @@ export async function triagePullRequest(
200204 try {
201205 const client = getAnthropic();
202206 const message = await client.messages.create({
203 model: MODEL_SONNET,
207 // Label/reviewer/priority suggestions, validated against allowlists and
208 // trivially human-overridden → Haiku-eligible.
209 model: modelForTask("pr-triage"),
204210 max_tokens: 512,
205211 messages: [
206212 {
Modifiedsrc/lib/auto-repair.ts+9−0View fileUnifiedSplit
@@ -408,6 +408,15 @@ ${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
408408/**
409409 * Given a GateTest failure summary, ask Claude to produce a patch set
410410 * that should make the failing check pass.
411 *
412 * NOTE (2026-06-12, BUILD_BIBLE §7 finding 1): the LIVE gate/CI repair path
413 * is `triggerCiAutofix()` in src/lib/ci-autofix.ts, which consults the
414 * repair-flywheel Tier-0 cache (`findCachedRepair`) before any AI call and
415 * settles outcomes via `updateOutcome()`. This function currently has no
416 * route/hook callers but is retained per the do-not-undo rule (§4.4 locked):
417 * it remains the worktree-backed Tier-1→2 orchestrator should a direct
418 * commit-back repair path be wired again. Do not delete; do not dedupe with
419 * src/lib/autorepair.ts (both are load-bearing per the Bible).
411420 */
412421export async function repairGateFailure(
413422 owner: string,
Modifiedsrc/lib/ci-autofix.ts+313−60View fileUnifiedSplit
@@ -1,16 +1,25 @@
11/**
2 * CI Auto-Fix — when a gate run or workflow run fails on a PR, Claude reads
3 * the error logs, the failing test file, and the PR diff, then posts a
4 * ready-to-apply patch as a comment on the PR.
2 * CI Auto-Fix — when a gate run or workflow run fails on a PR, the repair
3 * flywheel cache is consulted first (Tier 0); on a miss Claude reads the
4 * error logs, the failing test file, and the PR diff. Either way a
5 * ready-to-apply patch is posted as a comment on the PR.
56 *
67 * Entry points:
78 * triggerCiAutofix(gateRunId) — fire-and-forget; call after a gate_run
89 * row is written with status="failed".
910 * applyAutofix(prCommentId, userId) — apply the patch from a comment onto
10 * a new branch and return the branch name.
11 * a new branch and return the branch name. Settles the flywheel entry
12 * (success/failed) so the cache's confidence accumulates.
1113 *
1214 * Route wiring (src/routes/pulls.tsx or src/routes/api.ts):
1315 * POST /api/pr-comments/:commentId/apply-autofix → applyAutofix
16 *
17 * Flywheel wiring (BUILD_BIBLE §7 finding 1): every failure is fingerprinted
18 * via repair-flywheel.ts; a previously-successful patch with the same
19 * signature and a good success rate is served WITHOUT an AI call. All served
20 * fixes are recorded as 'pending' flywheel rows and settled on apply.
21 * Flywheel/DB errors never break this path — a broken cache degrades to the
22 * old always-call-AI behaviour.
1423 */
1524
1625import { and, eq } from "drizzle-orm";
@@ -18,6 +27,13 @@ import { mkdtemp, rm, writeFile } from "fs/promises";
1827import { join } from "path";
1928import { tmpdir } from "os";
2029import { db } from "../db";
30import {
31 findCachedRepair,
32 recordRepair,
33 updateOutcome,
34 type CachedRepair,
35 type RecordRepairInput,
36} from "./repair-flywheel";
2137import {
2238 gateRuns,
2339 pullRequests,
@@ -50,13 +66,35 @@ export interface AutofixResult {
5066 affectedFiles: string[];
5167}
5268
53interface ClaudeAutofixResponse {
69export interface ClaudeAutofixResponse {
5470 patch: string;
5571 explanation: string;
5672 confidence: "high" | "medium" | "low";
5773 affectedFiles: string[];
5874}
5975
76/**
77 * DI seam for the repair-flywheel wiring. Production callers omit this and
78 * get the real implementations; tests inject fakes so no DB is touched.
79 */
80export interface CiAutofixDeps {
81 findCachedRepair?: typeof findCachedRepair;
82 recordRepair?: typeof recordRepair;
83 updateOutcome?: typeof updateOutcome;
84 aiAvailable?: () => boolean;
85}
86
87/** The fix triggerCiAutofix decided to post, plus its flywheel bookkeeping. */
88export interface AutofixPlan {
89 /** 'cache' = Tier-0 flywheel replay (no AI call); 'ai' = fresh Sonnet patch. */
90 source: "cache" | "ai";
91 fix: ClaudeAutofixResponse;
92 /** Flywheel row recorded as 'pending' for this attempt; null if recording failed. */
93 flywheelEntryId: string | null;
94 /** On a cache hit: the parent pattern that was replayed. */
95 cachedPatternId: string | null;
96}
97
6098// ---------------------------------------------------------------------------
6199// Constants
62100// ---------------------------------------------------------------------------
@@ -64,6 +102,19 @@ interface ClaudeAutofixResponse {
64102/** Idempotency marker embedded in every autofix comment. */
65103export const CI_AUTOFIX_MARKER = "<!-- gluecron:ci-autofix:v1 -->";
66104
105/**
106 * Marker carrying the pending flywheel entry id, embedded in the autofix
107 * comment so applyAutofix can settle the outcome (success/failed) later.
108 */
109export const FLYWHEEL_MARKER_PREFIX = "<!-- gluecron:ci-autofix:flywheel:";
110
111/**
112 * Minimum settled success rate before a cached pattern is replayed instead
113 * of calling the AI. Below this the cache is considered unreliable for the
114 * signature and we fall through to a fresh Sonnet patch.
115 */
116export const CACHE_MIN_SUCCESS_RATE = 0.5;
117
67118/** Max bytes of PR diff sent to Claude. */
68119const MAX_DIFF_BYTES = 80 * 1024;
69120
@@ -144,6 +195,167 @@ function parseErrorLog(errorLog: string): {
144195 };
145196}
146197
198// ---------------------------------------------------------------------------
199// Repair flywheel wiring (Tier 0)
200// ---------------------------------------------------------------------------
201
202/** Matches the id inside a FLYWHEEL_MARKER_PREFIX comment marker. */
203const FLYWHEEL_MARKER_RE = /<!-- gluecron:ci-autofix:flywheel:([0-9a-fA-F-]{8,64}) -->/;
204
205/** Parse the flywheel entry id out of an autofix comment body, if present. */
206export function extractFlywheelEntryId(commentBody: string): string | null {
207 const m = commentBody.match(FLYWHEEL_MARKER_RE);
208 return m ? m[1] : null;
209}
210
211/**
212 * Record a 'pending' flywheel row for a fix we're about to post. Never
213 * throws — a flywheel write failure must not stop the fix from shipping,
214 * it only means this attempt won't contribute to the cache's learning.
215 */
216async function safeRecordRepair(
217 input: RecordRepairInput,
218 deps: CiAutofixDeps
219): Promise<string | null> {
220 const record = deps.recordRepair ?? recordRepair;
221 try {
222 return await record(input);
223 } catch (err) {
224 console.warn(
225 "[ci-autofix] flywheel record failed (fix still served):",
226 err instanceof Error ? err.message : err
227 );
228 return null;
229 }
230}
231
232/**
233 * Settle the flywheel entry referenced by an autofix comment (if any) so
234 * the pattern's success rate accumulates. Never throws — flywheel
235 * bookkeeping must not break the apply path.
236 */
237export async function recordAutofixOutcome(
238 commentBody: string,
239 outcome: "success" | "failed",
240 deps: CiAutofixDeps = {}
241): Promise<void> {
242 const entryId = extractFlywheelEntryId(commentBody);
243 if (!entryId) return;
244 const settle = deps.updateOutcome ?? updateOutcome;
245 try {
246 await settle(entryId, outcome);
247 } catch (err) {
248 console.warn(
249 "[ci-autofix] flywheel outcome update failed:",
250 err instanceof Error ? err.message : err
251 );
252 }
253}
254
255/**
256 * Tier-0-then-AI resolution for a CI failure (BUILD_BIBLE §7 finding 1).
257 *
258 * 1. Tier 0 — consult the repair flywheel for a previously-successful fix
259 * with the same failure signature. On a usable hit (success rate ≥
260 * CACHE_MIN_SUCCESS_RATE and a stored patch) the cached patch is
261 * served directly: no Anthropic call at all.
262 * 2. Tier 2 — fall through to a fresh Claude Sonnet patch via the
263 * `generateAiFix` thunk (guarded by isAiAvailable(); the thunk also
264 * keeps the expensive git context-gathering off the cache-hit path).
265 *
266 * Every served fix is recorded as a 'pending' flywheel row; applyAutofix
267 * settles it via recordAutofixOutcome. Flywheel/DB failures NEVER break
268 * this path — a broken cache degrades to the old always-call-AI behaviour.
269 */
270export async function resolveAutofix(args: {
271 repositoryId: string;
272 failureText: string;
273 generateAiFix: () => Promise<ClaudeAutofixResponse | null>;
274 deps?: CiAutofixDeps;
275}): Promise<AutofixPlan | null> {
276 const deps = args.deps ?? {};
277 const lookup = deps.findCachedRepair ?? findCachedRepair;
278 const aiOk = deps.aiAvailable ?? isAiAvailable;
279
280 // ── Tier 0: flywheel cache. Fail open — any error counts as a miss.
281 let cached: CachedRepair | null = null;
282 if (args.failureText.trim()) {
283 try {
284 cached = await lookup(args.repositoryId, args.failureText);
285 } catch (err) {
286 console.warn(
287 "[ci-autofix] flywheel lookup failed (falling through to AI):",
288 err instanceof Error ? err.message : err
289 );
290 }
291 }
292
293 // A usable hit needs a stored full patch (pre-0105 rows have none) AND a
294 // good settled success rate; anything else falls through to the AI tier.
295 if (
296 cached &&
297 cached.patch?.trim() &&
298 cached.successRate >= CACHE_MIN_SUCCESS_RATE
299 ) {
300 const fix: ClaudeAutofixResponse = {
301 patch: cached.patch,
302 explanation:
303 cached.patchSummary ||
304 "Replayed a previously-successful repair for this failure signature.",
305 // A pattern that keeps working is high confidence; anything that has
306 // failed at least occasionally is medium.
307 confidence: cached.successRate >= 0.9 ? "high" : "medium",
308 affectedFiles: cached.filesChanged,
309 };
310 const entryId = await safeRecordRepair(
311 {
312 repositoryId: args.repositoryId,
313 failureText: args.failureText,
314 classification: cached.classification,
315 tier: "cached",
316 patchSummary: fix.explanation,
317 // Carry the patch onto the new row so it is itself replayable once
318 // it settles (findCachedRepair prefers the most recent success).
319 patch: cached.patch,
320 filesChanged: fix.affectedFiles,
321 commitSha: null,
322 parentPatternId: cached.id,
323 },
324 deps
325 );
326 // Audit the saved AI call — this line is the flywheel's whole point.
327 console.log(
328 `[ci-autofix] flywheel cache HIT — pattern ${cached.id} (success rate ${(cached.successRate * 100).toFixed(0)}%, ${cached.hitCount} prior hits) served without an AI call`
329 );
330 return { source: "cache", fix, flywheelEntryId: entryId, cachedPatternId: cached.id };
331 }
332
333 // ── Tier 2: fresh AI patch. All AI features degrade gracefully when no
334 // API key is configured — cached fixes above still work without one.
335 if (!aiOk()) return null;
336
337 const fix = await args.generateAiFix();
338 if (!fix || !fix.patch || !fix.explanation) return null;
339 if (fix.confidence === "low") return null;
340
341 const entryId = await safeRecordRepair(
342 {
343 repositoryId: args.repositoryId,
344 failureText: args.failureText,
345 classification: null,
346 tier: "ai-sonnet",
347 patchSummary: fix.explanation,
348 // Stored so the entry is replayable by the Tier-0 cache once it
349 // settles to 'success'.
350 patch: fix.patch,
351 filesChanged: fix.affectedFiles,
352 commitSha: null,
353 },
354 deps
355 );
356 return { source: "ai", fix, flywheelEntryId: entryId, cachedPatternId: null };
357}
358
147359// ---------------------------------------------------------------------------
148360// Main entry point
149361// ---------------------------------------------------------------------------
@@ -151,12 +363,17 @@ function parseErrorLog(errorLog: string): {
151363/**
152364 * Fire-and-forget entry point called after a gate_run row is set to 'failed'.
153365 * Never throws — all errors are swallowed after logging.
366 *
367 * Note: no isAiAvailable() gate here — the Tier-0 flywheel cache needs no
368 * API key, so cached fixes still flow when AI is unconfigured. The actual
369 * Anthropic call is guarded inside resolveAutofix (graceful degradation).
154370 */
155export async function triggerCiAutofix(gateRunId: string): Promise<void> {
156 if (!isAiAvailable()) return;
157
371export async function triggerCiAutofix(
372 gateRunId: string,
373 deps: CiAutofixDeps = {}
374): Promise<void> {
158375 try {
159 await _runAutofix(gateRunId);
376 await _runAutofix(gateRunId, deps);
160377 } catch (err) {
161378 console.error(
162379 "[ci-autofix] crashed:",
@@ -165,7 +382,10 @@ export async function triggerCiAutofix(gateRunId: string): Promise<void> {
165382 }
166383}
167384
168async function _runAutofix(gateRunId: string): Promise<void> {
385async function _runAutofix(
386 gateRunId: string,
387 deps: CiAutofixDeps = {}
388): Promise<void> {
169389 // 1. Load the gate run
170390 const [gateRun] = await db
171391 .select()
@@ -229,37 +449,47 @@ async function _runAutofix(gateRunId: string): Promise<void> {
229449
230450 const repoDir = getRepoPath(repoRow.ownerUsername, repoRow.name);
231451
232 // 5. Get the PR diff (max 80KB)
233 const diffResult = await spawnGit(
234 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
235 repoDir
236 );
237 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
238
239 if (!prDiff.trim()) return; // nothing to work with
240
241 // 6. Parse errorLog to extract test files + error summary
452 // 5. Failure text — drives both the flywheel signature and the AI prompt.
242453 const errorLog = gateRun.summary || gateRun.details || "";
243 const { testFiles, errorSummary } = parseErrorLog(
244 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog)
245 );
246
247 // 7. Read failing test files via git show HEAD:path
248 let testFileContent = "";
249 for (const filePath of testFiles) {
250 const showResult = await spawnGit(
251 ["show", `${pr.headBranch}:${filePath}`],
252 repoDir
253 );
254 if (showResult.exitCode === 0 && showResult.stdout) {
255 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
256 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
257 }
258 }
454 const failureText =
455 typeof errorLog === "string" ? errorLog : JSON.stringify(errorLog);
456
457 // 6. Tier 0 (flywheel cache) first, then the AI fallback. The expensive
458 // context gathering (PR diff + failing test files + Sonnet call) lives
459 // inside the thunk so a cache hit never touches git or the API.
460 const plan = await resolveAutofix({
461 repositoryId: repoRow.id,
462 failureText,
463 deps,
464 generateAiFix: async () => {
465 // 6a. Get the PR diff (max 80KB)
466 const diffResult = await spawnGit(
467 ["diff", `${pr.baseBranch}...${pr.headBranch}`],
468 repoDir
469 );
470 const prDiff = truncate(diffResult.stdout, MAX_DIFF_BYTES);
471
472 if (!prDiff.trim()) return null; // nothing to work with
473
474 // 6b. Parse errorLog to extract test files + error summary
475 const { testFiles, errorSummary } = parseErrorLog(failureText);
476
477 // 6c. Read failing test files via git show HEAD:path
478 let testFileContent = "";
479 for (const filePath of testFiles) {
480 const showResult = await spawnGit(
481 ["show", `${pr.headBranch}:${filePath}`],
482 repoDir
483 );
484 if (showResult.exitCode === 0 && showResult.stdout) {
485 const content = truncate(showResult.stdout, MAX_FILE_BYTES);
486 testFileContent += `\n\n--- ${filePath} ---\n${content}`;
487 }
488 }
259489
260 // 8. Call Claude Sonnet 4.6
261 const client = getAnthropic();
262 const prompt = `You are a senior engineer fixing a CI failure.
490 // 6d. Call Claude Sonnet 4.6
491 const client = getAnthropic();
492 const prompt = `You are a senior engineer fixing a CI failure.
263493
264494PR diff (what changed):
265495${prDiff}
@@ -276,26 +506,22 @@ Produce a minimal unified diff patch that fixes the CI failure. The patch must:
276506
277507Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|low", "affectedFiles": ["..."]}`;
278508
279 const message = await client.messages.create({
280 model: MODEL_SONNET,
281 max_tokens: 4096,
282 messages: [{ role: "user", content: prompt }],
283 });
509 const message = await client.messages.create({
510 model: MODEL_SONNET,
511 max_tokens: 4096,
512 messages: [{ role: "user", content: prompt }],
513 });
284514
285 const rawText = extractText(message);
286 const parsed = parseJsonResponse<ClaudeAutofixResponse>(rawText);
515 const rawText = extractText(message);
516 return parseJsonResponse<ClaudeAutofixResponse>(rawText);
517 },
518 });
287519
288 if (!parsed || !parsed.patch || !parsed.explanation) return;
520 // No usable fix (cache miss + AI declined/low-confidence/unavailable).
521 if (!plan) return;
289522
290 // 10. If confidence === 'low' → skip
291 if (parsed.confidence === "low") return;
292
293 // 11. Build and post the comment
294 const commentBody = buildAutofixComment(
295 parsed,
296 idempotencyMarker,
297 gateRunId
298 );
523 // 7. Build and post the comment
524 const commentBody = buildAutofixComment(plan, idempotencyMarker, gateRunId);
299525
300526 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
301527 if (!botAuthorId) return;
@@ -309,10 +535,11 @@ Return JSON: {"patch": "...", "explanation": "...", "confidence": "high|medium|l
309535}
310536
311537function buildAutofixComment(
312 result: ClaudeAutofixResponse,
538 plan: AutofixPlan,
313539 idempotencyMarker: string,
314540 gateRunId: string
315541): string {
542 const result = plan.fix;
316543 const confidenceBadge =
317544 result.confidence === "high"
318545 ? "🟢 High confidence"
@@ -320,12 +547,23 @@ function buildAutofixComment(
320547 ? "🟡 Medium confidence"
321548 : "🔴 Low confidence";
322549
550 // Flywheel bookkeeping marker — applyAutofix parses this to settle the
551 // pending entry's outcome. Absent when the flywheel write failed.
552 const flywheelMarker = plan.flywheelEntryId
553 ? `\n${FLYWHEEL_MARKER_PREFIX}${plan.flywheelEntryId} -->`
554 : "";
555
556 const sourceNote =
557 plan.source === "cache"
558 ? `\n\n♻️ **Served from the repair cache** — this failure signature was fixed successfully before; no AI call was made.`
559 : "";
560
323561 return `${CI_AUTOFIX_MARKER}
324${idempotencyMarker}
562${idempotencyMarker}${flywheelMarker}
325563
326564## 🔧 AI Auto-Fix
327565
328${result.explanation}
566${result.explanation}${sourceNote}
329567
330568**Confidence:** ${confidenceBadge}
331569
@@ -355,10 +593,15 @@ Copy the patch above or click **Apply Fix** to commit it automatically.
355593/**
356594 * Applies the patch from a PR comment onto a new branch.
357595 * Returns the new branch name so the caller can redirect to compare view.
596 *
597 * Also settles the comment's pending flywheel entry: a clean apply+commit
598 * records 'success' (the pattern becomes replayable by the Tier-0 cache),
599 * an apply failure records 'failed' so unreliable patterns lose confidence.
358600 */
359601export async function applyAutofix(
360602 prCommentId: string,
361 userId: string
603 userId: string,
604 deps: CiAutofixDeps = {}
362605): Promise<{ branchName: string }> {
363606 // 1. Load the comment
364607 const [comment] = await db
@@ -483,6 +726,16 @@ export async function applyAutofix(
483726 ["push", repoDir, `HEAD:refs/heads/${branchName}`],
484727 tmpDir
485728 );
729
730 // 8. The repair landed — settle the flywheel entry so this pattern's
731 // success rate climbs and future identical failures hit the Tier-0 cache.
732 await recordAutofixOutcome(comment.body, "success", deps);
733 } catch (err) {
734 // The patch failed to apply/commit/push — settle as 'failed' so the
735 // flywheel learns this pattern is unreliable. Best-effort: the original
736 // error is always rethrown for the route to surface.
737 await recordAutofixOutcome(comment.body, "failed", deps);
738 throw err;
486739 } finally {
487740 // Cleanup worktree
488741 await spawnGit(["worktree", "remove", "--force", tmpDir], repoDir).catch(
Addedsrc/lib/env-health.ts+217−0View fileUnifiedSplit
@@ -0,0 +1,217 @@
1/**
2 * Environment / feature health — makes silently-disabled features visible.
3 *
4 * A dozen major features quietly turn off when their env vars are unset
5 * (no ANTHROPIC_API_KEY → every AI feature is a no-op, no RESEND_API_KEY →
6 * verification emails go to stderr, …). This module turns that implicit
7 * state into an explicit, renderable checklist for /admin/env-health.
8 *
9 * collectEnvHealth(env?) — pure + synchronous, env-only checks.
10 * collectEnvHealthWithDb() — augments the pure result with DB-backed
11 * toggles (currently: Google OAuth rows
12 * saved via /admin/google-oauth).
13 *
14 * SECURITY: items only ever carry set/unset booleans — never the values.
15 * Keep it that way; this page is rendered into HTML for site admins and
16 * the JSON shape may end up in logs or screenshots.
17 */
18
19import { getGoogleOauthConfig } from "./sso";
20
21export type EnvHealthSeverity = "critical" | "recommended" | "optional";
22
23export interface EnvHealthItem {
24 /** Human-facing feature name, e.g. "AI features (review, incidents, …)". */
25 feature: string;
26 /** Env vars that control the feature — names only, never values. */
27 envVars: string[];
28 /** True when the feature is live in the current environment. */
29 configured: boolean;
30 /** One-liner: what silently turns off when this is missing. */
31 impact: string;
32 severity: EnvHealthSeverity;
33}
34
35/** Render order for the grouped table. */
36export const SEVERITY_ORDER: EnvHealthSeverity[] = [
37 "critical",
38 "recommended",
39 "optional",
40];
41
42/** Truthy = non-empty after trim. Never returns the value itself. */
43function isSet(env: NodeJS.ProcessEnv, name: string): boolean {
44 return (env[name] || "").trim().length > 0;
45}
46
47/**
48 * Pure, synchronous snapshot of every env-gated feature. Pass a synthetic
49 * env object in tests; defaults to `process.env` at call time (matching
50 * the getter-based pattern in `src/lib/config.ts` — values are read at
51 * access time, never cached at import).
52 */
53export function collectEnvHealth(
54 env: NodeJS.ProcessEnv = process.env
55): EnvHealthItem[] {
56 const appBaseUrl = (env.APP_BASE_URL || "").trim();
57
58 return [
59 // ─── Critical — core product surface degrades without these ───
60 {
61 feature: "AI features (PR review, incidents, commit messages, …)",
62 envVars: ["ANTHROPIC_API_KEY"],
63 configured: isSet(env, "ANTHROPIC_API_KEY"),
64 impact:
65 "Every AI surface silently no-ops: AI PR review, incident responder, commit messages, changelogs, test generation.",
66 severity: "critical",
67 },
68 {
69 feature: "Email delivery (verification, password reset)",
70 envVars: ["EMAIL_PROVIDER", "RESEND_API_KEY"],
71 // The provider must be "resend" AND the key present — the default
72 // "log" provider only writes outbound mail to stderr (dev mode).
73 configured:
74 (env.EMAIL_PROVIDER || "").trim().toLowerCase() === "resend" &&
75 isSet(env, "RESEND_API_KEY"),
76 impact:
77 "Outbound email goes to stderr instead of users — email verification, password resets, and digests never arrive.",
78 severity: "critical",
79 },
80 {
81 feature: "Canonical base URL (APP_BASE_URL)",
82 envVars: ["APP_BASE_URL"],
83 // Set AND not pointing at localhost — the default breaks OAuth
84 // redirects and every link in outbound email.
85 configured: appBaseUrl.length > 0 && !appBaseUrl.includes("localhost"),
86 impact:
87 "Links in emails/webhooks point at http://localhost:3000 and OAuth fails with redirect_uri_mismatch.",
88 severity: "critical",
89 },
90
91 // ─── Recommended — feature works but in a degraded mode ───
92 {
93 feature: "Semantic code search (real embeddings)",
94 envVars: ["VOYAGE_API_KEY"],
95 configured: isSet(env, "VOYAGE_API_KEY"),
96 impact:
97 "Code search falls back to the hash-based local embedder instead of voyage-code-3 — noticeably worse relevance.",
98 severity: "recommended",
99 },
100 {
101 feature: "GateTest push-time security scans",
102 envVars: ["GATETEST_URL", "GATETEST_API_KEY"],
103 // GATETEST_URL has a baked-in default in config.ts, so the API key
104 // is the real on/off switch.
105 configured: isSet(env, "GATETEST_API_KEY"),
106 impact:
107 "Pushes are not scanned by GateTest; gate enforcement at push time is off.",
108 severity: "recommended",
109 },
110 {
111 feature: "Signed deploy webhook (Crontech)",
112 envVars: ["GLUECRON_WEBHOOK_SECRET"],
113 configured: isSet(env, "GLUECRON_WEBHOOK_SECRET"),
114 impact:
115 "Outbound deploy webhook fires without an HMAC signature header — the receiver rejects it with 401 (treated as a failed deploy).",
116 severity: "recommended",
117 },
118 {
119 feature: "PR preview builds",
120 envVars: ["PREVIEW_DOMAIN"],
121 configured: isSet(env, "PREVIEW_DOMAIN"),
122 impact:
123 "PR previews are URL-only — the preview-builder never runs and no static files are served.",
124 severity: "recommended",
125 },
126 {
127 feature: "Error tracking",
128 envVars: ["SENTRY_DSN", "ERROR_WEBHOOK_URL"],
129 // Either sink counts — both are fire-and-forget exporters.
130 configured: isSet(env, "SENTRY_DSN") || isSet(env, "ERROR_WEBHOOK_URL"),
131 impact:
132 "Unhandled errors are only visible in server logs — nothing is exported to Sentry or a webhook.",
133 severity: "recommended",
134 },
135 {
136 feature: "Stable SSH host key",
137 envVars: ["SSH_HOST_KEY"],
138 configured: isSet(env, "SSH_HOST_KEY"),
139 impact:
140 "An ephemeral host key is generated on every restart — git-over-SSH clients see 'host key changed' warnings.",
141 severity: "recommended",
142 },
143
144 // ─── Optional — opt-ins and scale-out knobs ───
145 {
146 feature: "Multi-instance SSE fan-out",
147 envVars: ["REDIS_URL", "VALKEY_URL"],
148 configured: isSet(env, "REDIS_URL") || isSet(env, "VALKEY_URL"),
149 impact:
150 "SSE events are delivered in-process only — live updates miss users connected to other instances behind a load balancer.",
151 severity: "optional",
152 },
153 {
154 feature: "Google login (env bootstrap)",
155 envVars: ["GOOGLE_OAUTH_CLIENT_ID", "GOOGLE_OAUTH_CLIENT_SECRET"],
156 configured:
157 isSet(env, "GOOGLE_OAUTH_CLIENT_ID") &&
158 isSet(env, "GOOGLE_OAUTH_CLIENT_SECRET"),
159 impact:
160 "'Sign in with Google' is unavailable — unless credentials were saved at /admin/google-oauth, which also satisfies this check.",
161 severity: "optional",
162 },
163 {
164 feature: "AI auto-issue opener",
165 envVars: ["AI_AUTO_ISSUES"],
166 configured: (env.AI_AUTO_ISSUES || "").trim() === "1",
167 impact:
168 "Pushes are not scanned for TODOs / hardcoded secrets / SQL-injection patterns; no issues are auto-opened. Opt-in: set to \"1\".",
169 severity: "optional",
170 },
171 {
172 feature: "Dependency CVE scanner",
173 envVars: ["DEPENDENCY_SCAN_ENABLED"],
174 configured: (env.DEPENDENCY_SCAN_ENABLED || "").trim() === "1",
175 impact:
176 "Manifest changes (package.json, requirements.txt, …) are not scanned for known CVEs on push. Opt-in: set to \"1\".",
177 severity: "optional",
178 },
179 ];
180}
181
182/**
183 * Async variant that augments the pure env snapshot with DB-backed toggles.
184 * v1 only checks Google OAuth: a row saved via /admin/google-oauth enables
185 * Google login even when the env pair is unset. DB failures degrade to the
186 * env-only result — this must never take the admin page down.
187 */
188export async function collectEnvHealthWithDb(
189 env: NodeJS.ProcessEnv = process.env
190): Promise<EnvHealthItem[]> {
191 const items = collectEnvHealth(env);
192
193 try {
194 // getGoogleOauthConfig() already merges DB row + env fallback.
195 const google = await getGoogleOauthConfig();
196 if (google?.clientId && google?.clientSecret) {
197 const item = items.find((i) =>
198 i.envVars.includes("GOOGLE_OAUTH_CLIENT_ID")
199 );
200 if (item) item.configured = true;
201 }
202 } catch {
203 // DB unreachable — keep the env-only answer.
204 }
205
206 return items;
207}
208
209/** Group items by severity in render order (critical → recommended → optional). */
210export function groupBySeverity(
211 items: EnvHealthItem[]
212): Array<{ severity: EnvHealthSeverity; items: EnvHealthItem[] }> {
213 return SEVERITY_ORDER.map((severity) => ({
214 severity,
215 items: items.filter((i) => i.severity === severity),
216 })).filter((g) => g.items.length > 0);
217}
Modifiedsrc/lib/mirrors.ts+21−0View fileUnifiedSplit
@@ -20,8 +20,12 @@ import {
2020 users,
2121} from "../db/schema";
2222import { getRepoPath } from "../git/repository";
23import { assertPublicUrl } from "./ssrf-guard";
2324
2425const MIRROR_REMOTE_NAME = "gluecron-mirror";
26
27/** Mirror upstreams may use git:// in addition to http(s). */
28const MIRROR_SCHEMES = ["http:", "https:", "git:"];
2529const FETCH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
2630
2731export interface ValidationResult {
@@ -53,6 +57,14 @@ export function validateUpstreamUrl(url: string): ValidationResult {
5357 if (!allowed.test(trimmed)) {
5458 return { ok: false, error: "URL must start with https://, http://, or git://" };
5559 }
60 // SSRF guard (BUILD_BIBLE §7): refuse upstreams that point at private/
61 // internal address space (localhost, RFC1918, link-local metadata, etc).
62 // SSRF_ALLOW_PRIVATE=1 opts out for self-hosted setups mirroring
63 // internal git servers.
64 const guard = assertPublicUrl(trimmed, { schemes: MIRROR_SCHEMES });
65 if (!guard.ok) {
66 return { ok: false, error: `blocked: ${guard.reason} (SSRF protection)` };
67 }
5668 return { ok: true };
5769}
5870
@@ -201,6 +213,15 @@ export async function runMirrorSync(
201213 let stderr = "";
202214
203215 try {
216 // SSRF guard — re-check at use time: the stored URL may predate the
217 // guard or have been saved while SSRF_ALLOW_PRIVATE was set. Throwing
218 // here lands in the catch below, which records the run as failed and
219 // never throws out.
220 const guard = assertPublicUrl(url, { schemes: MIRROR_SCHEMES });
221 if (!guard.ok) {
222 throw new Error(`blocked: ${guard.reason} (SSRF protection)`);
223 }
224
204225 // Ensure the remote exists and points at the current URL.
205226 await runGit(["git", "remote", "remove", MIRROR_REMOTE_NAME], repoPath);
206227 const addRes = await runGit(
Modifiedsrc/lib/repair-flywheel.ts+49−9View fileUnifiedSplit
@@ -32,7 +32,7 @@
3232 */
3333
3434import { createHash } from "crypto";
35import { and, desc, eq, sql, isNotNull, or } from "drizzle-orm";
35import { and, desc, eq, ne, sql, isNotNull, or } from "drizzle-orm";
3636import { db } from "../db";
3737import { repairFlywheel } from "../db/schema";
3838
@@ -42,6 +42,9 @@ export type RepairOutcome = "pending" | "success" | "failed" | "reverted";
4242export interface CachedRepair {
4343 id: string;
4444 patchSummary: string;
45 /** Full unified-diff patch (migration 0105). Null on pre-0105 rows — those
46 * entries can't be replayed and callers must fall through to the AI tier. */
47 patch: string | null;
4548 filesChanged: string[];
4649 commitSha: string | null;
4750 hitCount: number;
@@ -50,6 +53,14 @@ export interface CachedRepair {
5053 appliedCount: number;
5154}
5255
56/** Cap on the stored full patch — anything bigger isn't worth replaying. */
57const MAX_PATCH_CHARS = 64 * 1024;
58
59// The full-patch column postdates the locked schema.ts (migration 0105), so
60// it isn't on the drizzle table object — read it with a raw fragment. If the
61// migration hasn't run yet the query throws; callers treat that as a miss.
62const patchColumn = sql<string | null>`${repairFlywheel}."patch"`;
63
5364// ─────────────────────────────────────────────────────────────────────────
5465// Fingerprinting — turn any failure text into a stable signature.
5566// ─────────────────────────────────────────────────────────────────────────
@@ -117,7 +128,7 @@ export async function findCachedRepair(
117128
118129 // First try: same repo, same signature, prefer the most recent success
119130 const sameRepo = await db
120 .select()
131 .select({ row: repairFlywheel, patch: patchColumn })
121132 .from(repairFlywheel)
122133 .where(
123134 and(
@@ -130,12 +141,12 @@ export async function findCachedRepair(
130141 .limit(1);
131142
132143 if (sameRepo.length > 0) {
133 return await hydrate(sameRepo[0]!);
144 return await hydrate(sameRepo[0]!.row, sameRepo[0]!.patch);
134145 }
135146
136147 // Fallback: any public pattern with this signature
137148 const cross = await db
138 .select()
149 .select({ row: repairFlywheel, patch: patchColumn })
139150 .from(repairFlywheel)
140151 .where(
141152 and(
@@ -148,7 +159,7 @@ export async function findCachedRepair(
148159 .limit(1);
149160
150161 if (cross.length > 0) {
151 return await hydrate(cross[0]!);
162 return await hydrate(cross[0]!.row, cross[0]!.patch);
152163 }
153164
154165 return null;
@@ -156,10 +167,13 @@ export async function findCachedRepair(
156167
157168async function hydrate(
158169 row: typeof repairFlywheel.$inferSelect,
170 patch: string | null,
159171): Promise<CachedRepair> {
160172 // Confidence: count successes vs failures across all entries that share
161173 // this signature (or its cache lineage). Quick computation, sub-ms in
162 // typical use; we'll cache later if this becomes hot.
174 // typical use; we'll cache later if this becomes hot. Pending rows are
175 // excluded — in-flight repairs haven't settled, and counting them as
176 // non-successes would let queued attempts depress a good pattern's score.
163177 const stats = await db
164178 .select({
165179 total: sql<number>`count(*)::int`,
@@ -167,9 +181,12 @@ async function hydrate(
167181 })
168182 .from(repairFlywheel)
169183 .where(
170 or(
171 eq(repairFlywheel.failureSignature, row.failureSignature),
172 eq(repairFlywheel.parentPatternId, row.id),
184 and(
185 or(
186 eq(repairFlywheel.failureSignature, row.failureSignature),
187 eq(repairFlywheel.parentPatternId, row.id),
188 ),
189 ne(repairFlywheel.outcome, "pending"),
173190 ),
174191 );
175192
@@ -180,6 +197,7 @@ async function hydrate(
180197 return {
181198 id: row.id,
182199 patchSummary: row.patchSummary,
200 patch,
183201 filesChanged: (row.filesChanged as string[]) ?? [],
184202 commitSha: row.commitSha,
185203 hitCount: row.cacheHitCount,
@@ -199,6 +217,9 @@ export interface RecordRepairInput {
199217 classification: string | null;
200218 tier: RepairTier;
201219 patchSummary: string;
220 /** Full unified-diff patch so Tier-0 cache hits can replay it. Optional —
221 * mechanical repairs have no diff to store. Capped at ~64KB. */
222 patch?: string | null;
202223 filesChanged: string[];
203224 commitSha: string | null;
204225 parentPatternId?: string | null;
@@ -234,6 +255,25 @@ export async function recordRepair(input: RecordRepairInput): Promise<string> {
234255 })
235256 .returning({ id: repairFlywheel.id });
236257
258 // The full patch goes in via raw UPDATE: the "patch" column (migration
259 // 0105) postdates the locked schema.ts so it can't ride the drizzle
260 // insert above. Best-effort — if it fails (e.g. migration not yet
261 // applied) the audit row is still intact, the entry just can't be
262 // replayed by the Tier-0 cache.
263 if (input.patch) {
264 const patch = input.patch.slice(0, MAX_PATCH_CHARS);
265 try {
266 await db.execute(
267 sql`update "repair_flywheel" set "patch" = ${patch} where "id" = ${row!.id}`,
268 );
269 } catch (err) {
270 console.warn(
271 "[repair-flywheel] patch write failed (is migration 0105 applied?):",
272 err instanceof Error ? err.message : err,
273 );
274 }
275 }
276
237277 // If this was a cache hit (Tier 0), bump the parent pattern's hit count.
238278 if (input.parentPatternId) {
239279 await db
Addedsrc/lib/ssrf-guard.ts+295−0View fileUnifiedSplit
@@ -0,0 +1,295 @@
1/**
2 * SSRF guard — P1 security hardening (BUILD_BIBLE §7).
3 *
4 * Server-side fetches of user-controlled URLs (webhook deliveries, repo
5 * mirror upstreams) must not be allowed to reach private / internal
6 * address space: cloud metadata endpoints (169.254.169.254), localhost
7 * services, RFC1918 LANs, etc.
8 *
9 * Primary defence is the *synchronous literal check* — `isPrivateAddress()`
10 * recognises hostname conventions (localhost, .local, .internal), every
11 * private/reserved IPv4 range (including decimal/hex/octal single-integer
12 * encodings like `http://2130706433/`), and the private IPv6 forms
13 * (::1, ::, fc00::/7, fe80::/10, IPv4-mapped ::ffff:x.x.x.x).
14 *
15 * `resolvesToPrivate()` adds a best-effort DNS layer: if a public-looking
16 * hostname resolves to a private address it can be blocked too. Resolution
17 * failures never block — the subsequent connect would fail anyway.
18 *
19 * Escape hatches (read at call time, never at module load):
20 * - SSRF_ALLOW_PRIVATE=1 — disable blocking entirely (local dev /
21 * self-hosted setups that legitimately mirror internal git servers).
22 * - test env default-allow — when NODE_ENV/BUN_ENV is "test" (and not
23 * production), blocking is OFF unless SSRF_ENFORCE_IN_TEST=1. Existing
24 * suites spin up Bun.serve() on localhost and must keep passing; the
25 * ssrf-guard suite opts back in via SSRF_ENFORCE_IN_TEST=1.
26 */
27
28// ---------------------------------------------------------------------------
29// IPv4 parsing — inet_aton-compatible (1–4 parts, decimal/hex/octal).
30// ---------------------------------------------------------------------------
31
32/** Parse one IPv4 component: decimal, 0x-hex, or 0-prefixed octal. */
33function parseIpv4Part(s: string): number | null {
34 if (!/^(0x[0-9a-f]+|[0-9]+)$/.test(s)) return null;
35 let v: number;
36 if (s.startsWith("0x")) {
37 v = parseInt(s.slice(2), 16);
38 } else if (s.length > 1 && s.startsWith("0")) {
39 // Leading zero → octal (inet_aton semantics, so 0177.0.0.1 = 127.0.0.1).
40 if (!/^[0-7]+$/.test(s)) return null;
41 v = parseInt(s, 8);
42 } else {
43 v = parseInt(s, 10);
44 }
45 return Number.isFinite(v) ? v : null;
46}
47
48/**
49 * Parse an IPv4 literal into a 32-bit unsigned integer, accepting the
50 * 1/2/3/4-part forms inet_aton accepts (so `2130706433`, `0x7f000001`,
51 * `0177.0.0.1`, and `127.1` all map to 127.0.0.1). Returns null when the
52 * string is not an IPv4 literal (e.g. a normal hostname).
53 */
54export function parseIpv4(host: string): number | null {
55 const parts = host.split(".");
56 if (parts.length < 1 || parts.length > 4) return null;
57 const nums: number[] = [];
58 for (const p of parts) {
59 const v = parseIpv4Part(p);
60 if (v === null) return null;
61 nums.push(v);
62 }
63
64 const n = nums.length;
65 if (n === 1) {
66 if (nums[0]! > 0xffffffff) return null;
67 return nums[0]! >>> 0;
68 }
69 // All but the last part are single octets; the last fills the remainder.
70 for (let i = 0; i < n - 1; i++) {
71 if (nums[i]! > 0xff) return null;
72 }
73 const lastMax = [0, 0, 0xffffff, 0xffff, 0xff][n]!;
74 if (nums[n - 1]! > lastMax) return null;
75
76 let prefix = 0;
77 for (let i = 0; i < n - 1; i++) {
78 prefix = prefix * 256 + nums[i]!;
79 }
80 return (prefix * 2 ** ((5 - n) * 8) + nums[n - 1]!) >>> 0;
81}
82
83/** True when a 32-bit IPv4 address falls in a private/reserved range. */
84function isPrivateIpv4(ip: number): boolean {
85 const a = (ip >>> 24) & 0xff;
86 const b = (ip >>> 16) & 0xff;
87 if (a === 127) return true; // 127.0.0.0/8 loopback
88 if (a === 10) return true; // 10.0.0.0/8
89 if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
90 if (a === 192 && b === 168) return true; // 192.168.0.0/16
91 if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local/metadata
92 if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
93 if (a === 0) return true; // 0.0.0.0/8 "this network"
94 if (ip === 0xffffffff) return true; // 255.255.255.255 broadcast
95 return false;
96}
97
98// ---------------------------------------------------------------------------
99// IPv6 parsing — 8×16-bit words, with :: compression and embedded IPv4.
100// ---------------------------------------------------------------------------
101
102/** Convert colon-separated groups to 16-bit words (embedded IPv4 → 2 words). */
103function groupsToWords(groups: string[]): number[] | null {
104 const words: number[] = [];
105 for (let i = 0; i < groups.length; i++) {
106 const g = groups[i]!;
107 if (g.includes(".")) {
108 // Embedded IPv4 — only valid in the final position.
109 if (i !== groups.length - 1) return null;
110 const v4 = parseIpv4(g);
111 if (v4 === null) return null;
112 words.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
113 } else {
114 if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
115 words.push(parseInt(g, 16));
116 }
117 }
118 return words;
119}
120
121/** Parse an IPv6 literal into 8 words, or null if it isn't one. */
122function parseIpv6(input: string): number[] | null {
123 let h = input;
124 const pct = h.indexOf("%"); // strip zone id (fe80::1%eth0)
125 if (pct !== -1) h = h.slice(0, pct);
126 if (h.length === 0) return null;
127
128 const dbl = h.split("::");
129 if (dbl.length > 2) return null;
130
131 if (dbl.length === 2) {
132 const head = dbl[0] === "" ? [] : dbl[0]!.split(":");
133 const tail = dbl[1] === "" ? [] : dbl[1]!.split(":");
134 const headW = groupsToWords(head);
135 const tailW = groupsToWords(tail);
136 if (!headW || !tailW) return null;
137 const fill = 8 - headW.length - tailW.length;
138 if (fill < 1) return null;
139 return [...headW, ...new Array(fill).fill(0), ...tailW];
140 }
141
142 const words = groupsToWords(h.split(":"));
143 if (!words || words.length !== 8) return null;
144 return words;
145}
146
147/** True when an IPv6 literal is loopback/unspecified/ULA/link-local/mapped-private. */
148function isPrivateIpv6(host: string): boolean {
149 const w = parseIpv6(host);
150 if (!w) return false; // not a parseable literal — connect will fail anyway
151 if (w.every((x) => x === 0)) return true; // :: unspecified
152 if (w.slice(0, 7).every((x) => x === 0) && w[7] === 1) return true; // ::1
153 if ((w[0]! & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
154 if ((w[0]! & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
155 // IPv4-mapped ::ffff:a.b.c.d (and the bare ::a.b.c.d compat form).
156 if (w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0) {
157 if (w[5] === 0xffff || (w[5] === 0 && (w[6] !== 0 || w[7]! > 1))) {
158 const ip = ((w[6]! << 16) | w[7]!) >>> 0;
159 return isPrivateIpv4(ip);
160 }
161 }
162 return false;
163}
164
165// ---------------------------------------------------------------------------
166// Public surface
167// ---------------------------------------------------------------------------
168
169/**
170 * Pure, synchronous check: is `host` a private/internal address or a
171 * hostname that conventionally maps to one? Accepts bare hostnames, IPv4
172 * literals in any inet_aton encoding, and IPv6 literals (with or without
173 * the URL bracket form `[::1]`).
174 */
175export function isPrivateAddress(host: string): boolean {
176 if (!host) return true; // empty host — nothing legitimate to reach
177 let h = host.trim().toLowerCase();
178 if (h.endsWith(".")) h = h.slice(0, -1); // trailing-dot FQDN form
179 if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
180 if (h.length === 0) return true;
181
182 // Hostname conventions.
183 if (h === "localhost" || h.endsWith(".localhost")) return true;
184 if (h.endsWith(".local") || h.endsWith(".internal")) return true;
185
186 // IPv6 literal.
187 if (h.includes(":")) return isPrivateIpv6(h);
188
189 // IPv4 literal (any encoding). Non-literals fall through as public —
190 // the optional DNS layer (resolvesToPrivate) covers resolved names.
191 const v4 = parseIpv4(h);
192 if (v4 !== null) return isPrivateIpv4(v4);
193
194 return false;
195}
196
197/**
198 * Should private addresses be allowed right now? Read at call time, never
199 * cached at module load.
200 *
201 * - `SSRF_ALLOW_PRIVATE=1` → always allow (local dev / self-hosted).
202 * - Test env (NODE_ENV/BUN_ENV "test", and NOT production) → allow unless
203 * `SSRF_ENFORCE_IN_TEST=1`. Belt-and-braces mirrors rate-limit.ts: a
204 * leaked test env var in a production container must not drop the guard.
205 */
206export function ssrfPrivateAllowed(): boolean {
207 if (process.env.SSRF_ALLOW_PRIVATE === "1") return true;
208 const isTestEnv =
209 process.env.NODE_ENV !== "production" &&
210 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
211 return isTestEnv && process.env.SSRF_ENFORCE_IN_TEST !== "1";
212}
213
214export type PublicUrlResult =
215 | { ok: true; url: URL }
216 | { ok: false; reason: string };
217
218export interface PublicUrlOptions {
219 /** Explicit override; when omitted, env policy (ssrfPrivateAllowed) applies. */
220 allowPrivate?: boolean;
221 /** Allowed schemes incl. trailing colon. Default: http/https. */
222 schemes?: string[];
223}
224
225const DEFAULT_SCHEMES = ["http:", "https:"];
226
227/**
228 * Validate a user-supplied URL for server-side fetching. Requires an
229 * http/https scheme (callers may widen via `opts.schemes`, e.g. git: for
230 * mirrors) and rejects private hosts per `isPrivateAddress()`.
231 *
232 * Embedded credentials (https://user:pw@host/) are deliberately allowed —
233 * mirror upstreams legitimately use them; callers strip them from logs.
234 *
235 * Never throws.
236 */
237export function assertPublicUrl(
238 raw: string,
239 opts?: PublicUrlOptions
240): PublicUrlResult {
241 let url: URL;
242 try {
243 url = new URL(raw);
244 } catch {
245 return { ok: false, reason: "invalid URL" };
246 }
247
248 const schemes = opts?.schemes ?? DEFAULT_SCHEMES;
249 if (!schemes.includes(url.protocol)) {
250 return { ok: false, reason: `unsupported scheme ${url.protocol.replace(/:$/, "")}` };
251 }
252
253 const allowPrivate = opts?.allowPrivate ?? ssrfPrivateAllowed();
254 if (allowPrivate) return { ok: true, url };
255
256 if (!url.hostname) {
257 return { ok: false, reason: "missing host" };
258 }
259 if (isPrivateAddress(url.hostname)) {
260 return { ok: false, reason: "private address" };
261 }
262 return { ok: true, url };
263}
264
265// ---------------------------------------------------------------------------
266// Optional async DNS layer — best-effort, injectable for tests.
267// ---------------------------------------------------------------------------
268
269export type DnsResolver = (
270 host: string
271) => Promise<Array<{ address: string }>>;
272
273async function defaultResolver(host: string) {
274 const { lookup } = await import("node:dns/promises");
275 return lookup(host, { all: true });
276}
277
278/**
279 * True when `host` is a private literal OR resolves (A/AAAA) to a private
280 * address. Best-effort by design: if resolution fails or times out we do
281 * NOT block — the literal check above is the primary defence and a
282 * non-resolving host can't be fetched anyway.
283 */
284export async function resolvesToPrivate(
285 host: string,
286 resolver: DnsResolver = defaultResolver
287): Promise<boolean> {
288 if (isPrivateAddress(host)) return true;
289 try {
290 const records = await resolver(host);
291 return records.some((r) => isPrivateAddress(r.address));
292 } catch {
293 return false;
294 }
295}
Modifiedsrc/lib/webhook-delivery.ts+31−0View fileUnifiedSplit
@@ -31,6 +31,7 @@
3131import { and, asc, eq, lte, sql } from "drizzle-orm";
3232import { db } from "../db";
3333import { webhookDeliveries, webhooks } from "../db/schema";
34import { assertPublicUrl } from "./ssrf-guard";
3435
3536// ---------------------------------------------------------------------------
3637// Tunables
@@ -185,6 +186,36 @@ export async function attemptDelivery(
185186 const d = row.delivery;
186187 const attemptNumber = d.attemptCount + 1;
187188
189 // SSRF guard (BUILD_BIBLE §7): refuse to POST to private/internal
190 // addresses. Permanent condition — retrying won't change the URL — so
191 // the row goes straight to 'dead' with a clear reason. Never throws.
192 const guard = assertPublicUrl(row.url);
193 if (!guard.ok) {
194 const blockedAt = new Date();
195 await db
196 .update(webhookDeliveries)
197 .set({
198 status: "dead",
199 attemptCount: attemptNumber,
200 lastAttemptedAt: blockedAt,
201 lastStatusCode: null,
202 lastError: `blocked: ${guard.reason} (SSRF protection)`,
203 nextAttemptAt: null,
204 })
205 .where(eq(webhookDeliveries.id, deliveryId));
206
207 try {
208 await db
209 .update(webhooks)
210 .set({ lastDeliveredAt: blockedAt, lastStatus: 0 })
211 .where(eq(webhooks.id, d.webhookId));
212 } catch {
213 /* swallow */
214 }
215
216 return "dead";
217 }
218
188219 // Perform the POST.
189220 let statusCode: number | null = null;
190221 let errorMessage: string | null = null;
Addedsrc/routes/admin-env-health.tsx+377−0View fileUnifiedSplit
@@ -0,0 +1,377 @@
1/**
2 * /admin/env-health — environment / feature health panel.
3 *
4 * GET /admin/env-health — table of every env-gated feature, grouped by
5 * severity (critical / recommended / optional), with green "Configured"
6 * / red "Missing" pills, the controlling env var names, and a one-line
7 * impact description.
8 *
9 * Makes silently-disabled features visible: today a dozen major features
10 * quietly turn off when their env vars are unset and the operator has no
11 * single place to see what's live. Data comes from
12 * `collectEnvHealthWithDb()` in `src/lib/env-health.ts` — set/unset
13 * booleans only, never the values.
14 *
15 * Gated by `isSiteAdmin` using the same `gate()` pattern as
16 * `src/routes/admin.tsx`. Scoped CSS prefixed `.admin-envh-` to avoid
17 * collisions with the parent admin polish.
18 */
19
20import { Hono } from "hono";
21import { Layout } from "../views/layout";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isSiteAdmin } from "../lib/admin";
25import {
26 collectEnvHealthWithDb,
27 groupBySeverity,
28 type EnvHealthSeverity,
29} from "../lib/env-health";
30
31const envHealth = new Hono<AuthEnv>();
32envHealth.use("*", softAuth);
33
34/* ─────────────────────────────────────────────────────────────────────────
35 * Scoped CSS — every class prefixed `.admin-envh-` so this surface can't
36 * bleed into the wider admin panel. Mirrors the gradient-hairline hero +
37 * table patterns from /admin and /admin/integrations.
38 * ───────────────────────────────────────────────────────────────────── */
39const styles = `
40 .admin-envh-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
41
42 .admin-envh-hero {
43 position: relative;
44 margin-bottom: var(--space-5);
45 padding: var(--space-5) var(--space-6);
46 background: var(--bg-elevated);
47 border: 1px solid var(--border);
48 border-radius: 16px;
49 overflow: hidden;
50 }
51 .admin-envh-hero::before {
52 content: '';
53 position: absolute;
54 top: 0; left: 0; right: 0;
55 height: 2px;
56 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
57 opacity: 0.7;
58 pointer-events: none;
59 }
60 .admin-envh-hero-orb {
61 position: absolute;
62 inset: -20% -10% auto auto;
63 width: 380px; height: 380px;
64 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
65 filter: blur(80px);
66 opacity: 0.7;
67 pointer-events: none;
68 z-index: 0;
69 }
70 .admin-envh-hero-inner { position: relative; z-index: 1; max-width: 720px; }
71 .admin-envh-eyebrow {
72 font-size: 12px;
73 color: var(--text-muted);
74 margin-bottom: var(--space-2);
75 letter-spacing: 0.02em;
76 display: inline-flex;
77 align-items: center;
78 gap: 8px;
79 }
80 .admin-envh-eyebrow .pill {
81 display: inline-flex;
82 align-items: center;
83 justify-content: center;
84 width: 18px; height: 18px;
85 border-radius: 6px;
86 background: rgba(140,109,255,0.14);
87 color: #b69dff;
88 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
89 }
90 .admin-envh-title {
91 font-size: clamp(28px, 4vw, 40px);
92 font-family: var(--font-display);
93 font-weight: 800;
94 letter-spacing: -0.028em;
95 line-height: 1.05;
96 margin: 0 0 var(--space-2);
97 color: var(--text-strong);
98 }
99 .admin-envh-title-grad {
100 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
101 -webkit-background-clip: text;
102 background-clip: text;
103 -webkit-text-fill-color: transparent;
104 color: transparent;
105 }
106 .admin-envh-sub {
107 font-size: 15px;
108 color: var(--text-muted);
109 margin: 0;
110 line-height: 1.5;
111 max-width: 620px;
112 }
113
114 /* ─── Severity section headers ─── */
115 .admin-envh-h3 {
116 display: flex;
117 align-items: baseline;
118 justify-content: space-between;
119 gap: var(--space-3);
120 margin: var(--space-5) 0 var(--space-3);
121 }
122 .admin-envh-h3 h3 {
123 font-family: var(--font-display);
124 font-size: 16px;
125 font-weight: 700;
126 letter-spacing: -0.014em;
127 margin: 0;
128 color: var(--text-strong);
129 }
130 .admin-envh-h3-meta {
131 font-size: 12px;
132 color: var(--text-muted);
133 }
134
135 /* ─── Table (mirrors .admin-ap-table from /admin) ─── */
136 .admin-envh-table {
137 width: 100%;
138 border-collapse: collapse;
139 background: var(--bg-elevated);
140 border: 1px solid var(--border);
141 border-radius: 14px;
142 overflow: hidden;
143 }
144 .admin-envh-table thead th {
145 text-align: left;
146 font-size: 11px;
147 font-weight: 600;
148 letter-spacing: 0.08em;
149 text-transform: uppercase;
150 color: var(--text-muted);
151 padding: 10px 14px;
152 background: rgba(255,255,255,0.015);
153 border-bottom: 1px solid var(--border);
154 }
155 .admin-envh-table tbody td {
156 padding: 10px 14px;
157 border-bottom: 1px solid var(--border-subtle);
158 font-size: 13px;
159 color: var(--text);
160 vertical-align: top;
161 }
162 .admin-envh-table tbody tr:last-child td { border-bottom: none; }
163 .admin-envh-table code {
164 font-family: var(--font-mono);
165 font-size: 12px;
166 color: var(--text-strong);
167 background: var(--bg-tertiary);
168 padding: 1px 5px;
169 border-radius: 4px;
170 white-space: nowrap;
171 }
172 .admin-envh-feature {
173 font-weight: 600;
174 color: var(--text-strong);
175 }
176 .admin-envh-impact { color: var(--text-muted); line-height: 1.45; }
177
178 /* ─── Status pills ─── */
179 .admin-envh-status {
180 display: inline-flex;
181 align-items: center;
182 gap: 4px;
183 padding: 2px 8px;
184 border-radius: 9999px;
185 font-size: 10.5px;
186 font-weight: 600;
187 letter-spacing: 0.04em;
188 text-transform: uppercase;
189 white-space: nowrap;
190 }
191 .admin-envh-status.is-set {
192 background: rgba(52,211,153,0.14);
193 color: #6ee7b7;
194 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
195 }
196 .admin-envh-status.is-missing {
197 background: rgba(248,113,113,0.10);
198 color: #fca5a5;
199 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
200 }
201 .admin-envh-status .dot {
202 width: 6px; height: 6px;
203 border-radius: 9999px;
204 background: currentColor;
205 }
206
207 .admin-envh-foot {
208 margin-top: var(--space-5);
209 padding: var(--space-3) var(--space-4);
210 border: 1px solid var(--border-subtle);
211 background: rgba(255,255,255,0.015);
212 border-radius: 10px;
213 color: var(--text-muted);
214 font-size: 12.5px;
215 }
216 .admin-envh-foot a { color: var(--accent); text-decoration: none; }
217 .admin-envh-foot a:hover { text-decoration: underline; }
218
219 .admin-envh-403 {
220 max-width: 540px;
221 margin: var(--space-12) auto;
222 padding: var(--space-6);
223 text-align: center;
224 background: var(--bg-elevated);
225 border: 1px solid var(--border);
226 border-radius: 16px;
227 }
228 .admin-envh-403 h2 {
229 font-family: var(--font-display);
230 font-size: 22px;
231 margin: 0 0 8px;
232 color: var(--text-strong);
233 }
234 .admin-envh-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
235
236 @media (max-width: 720px) {
237 .admin-envh-wrap { padding: var(--space-4) var(--space-3); }
238 .admin-envh-hero { padding: var(--space-4); }
239 .admin-envh-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
240 }
241`;
242
243/** Human labels for the three severity buckets. */
244const SEVERITY_LABELS: Record<EnvHealthSeverity, { title: string; blurb: string }> = {
245 critical: {
246 title: "Critical",
247 blurb: "Core product surface degrades without these.",
248 },
249 recommended: {
250 title: "Recommended",
251 blurb: "Feature works, but in a degraded mode.",
252 },
253 optional: {
254 title: "Optional",
255 blurb: "Opt-ins and scale-out knobs.",
256 },
257};
258
259async function gate(c: any): Promise<{ user: any } | Response> {
260 const user = c.get("user");
261 if (!user) return c.redirect("/login?next=/admin/env-health");
262 if (!(await isSiteAdmin(user.id))) {
263 return c.html(
264 <Layout title="Forbidden" user={user}>
265 <div class="admin-envh-403">
266 <h2>403 — Not a site admin</h2>
267 <p>You don't have permission to view this page.</p>
268 </div>
269 <style dangerouslySetInnerHTML={{ __html: styles }} />
270 </Layout>,
271 403
272 );
273 }
274 return { user };
275}
276
277envHealth.get("/admin/env-health", async (c) => {
278 const g = await gate(c);
279 if (g instanceof Response) return g;
280 const { user } = g;
281
282 const items = await collectEnvHealthWithDb();
283 const groups = groupBySeverity(items);
284 const configured = items.filter((i) => i.configured).length;
285
286 return c.html(
287 <Layout title="Environment health — admin" user={user}>
288 <div class="admin-envh-wrap">
289 <section class="admin-envh-hero">
290 <div class="admin-envh-hero-orb" aria-hidden="true" />
291 <div class="admin-envh-hero-inner">
292 <div class="admin-envh-eyebrow">
293 <span class="pill" aria-hidden="true">
294 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
295 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
296 </svg>
297 </span>
298 Environment health · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
299 </div>
300 <h2 class="admin-envh-title">
301 <span class="admin-envh-title-grad">What's actually on.</span>
302 </h2>
303 <p class="admin-envh-sub">
304 Every feature that silently turns off when its env vars are
305 unset — in one place. {configured} of {items.length} live.
306 Only set/unset is shown; values never leave the server.
307 </p>
308 </div>
309 </section>
310
311 {groups.map(({ severity, items: rows }) => {
312 const label = SEVERITY_LABELS[severity];
313 const live = rows.filter((r) => r.configured).length;
314 return (
315 <>
316 <div class="admin-envh-h3">
317 <h3>{label.title}</h3>
318 <span class="admin-envh-h3-meta">
319 {label.blurb} · {live}/{rows.length} configured
320 </span>
321 </div>
322 <table class="admin-envh-table">
323 <thead>
324 <tr>
325 <th>Feature</th>
326 <th>Status</th>
327 <th>Env vars</th>
328 <th>When missing</th>
329 </tr>
330 </thead>
331 <tbody>
332 {rows.map((item) => (
333 <tr>
334 <td class="admin-envh-feature">{item.feature}</td>
335 <td>
336 <span
337 class={
338 "admin-envh-status " +
339 (item.configured ? "is-set" : "is-missing")
340 }
341 >
342 <span class="dot" aria-hidden="true" />
343 {item.configured ? "Configured" : "Missing"}
344 </span>
345 </td>
346 <td>
347 {item.envVars.map((v, i) => (
348 <>
349 {i > 0 && " "}
350 <code>{v}</code>
351 </>
352 ))}
353 </td>
354 <td class="admin-envh-impact">{item.impact}</td>
355 </tr>
356 ))}
357 </tbody>
358 </table>
359 </>
360 );
361 })}
362
363 <div class="admin-envh-foot">
364 Most keys can be set without a restart on{" "}
365 <a href="/admin/integrations">/admin/integrations</a> · Google
366 OAuth credentials saved at{" "}
367 <a href="/admin/google-oauth">/admin/google-oauth</a> also satisfy
368 the Google login check · runtime checks live on{" "}
369 <a href="/admin/health">/admin/health</a>.
370 </div>
371 </div>
372 <style dangerouslySetInnerHTML={{ __html: styles }} />
373 </Layout>
374 );
375});
376
377export default envHealth;
0378