Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

env-health.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

env-health.tsBlame217 lines · 1 contributor
bc519aeClaude1/**
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}