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

system-config.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.

system-config.tsBlame289 lines · 2 contributors
509c376Claude1/**
2 * DB-backed runtime config for `/admin/integrations`.
3 *
4 * Operators used to SSH into the box, edit `/etc/gluecron.env`, and
5 * `systemctl restart gluecron` to rotate keys like `ANTHROPIC_API_KEY`,
6 * `RESEND_API_KEY`, `GITHUB_TOKEN`. This module replaces that workflow:
7 *
8 * - `getConfigValue(key, fallbackEnv)` — DB first, env fallback,
9 * empty string if neither is set.
10 * - `setConfigValue(key, value, userId)` — upsert + write `process.env`
11 * so existing synchronous callers (e.g. `config.anthropicApiKey`)
12 * pick up the new value immediately, with no restart.
13 * - `loadConfigIntoEnv()` — boot hook (called from `src/index.ts`)
14 * that copies every saved row into `process.env` before other
15 * modules read it.
16 * - `maskSecret(s)` — `re_••••••...XYZ`-style display helper.
17 *
18 * A small in-memory LRU (60s TTL) avoids hitting the DB on every read.
19 * Cache is invalidated on any `setConfigValue` so admins see their
20 * changes immediately in subsequent renders.
21 */
22
23import { eq } from "drizzle-orm";
24import { db } from "../db";
25import { systemConfig } from "../db/schema";
26
27const CACHE_TTL_MS = 60_000;
28
29type CacheEntry = { value: string; expiresAt: number };
30const cache = new Map<string, CacheEntry>();
31
32/** Mask "re_abc123xyz789" → "re_••••••89". Used for safe display in the UI. */
33export function maskSecret(s: string | null | undefined): string {
34 const v = (s ?? "").trim();
35 if (!v) return "";
36 // Keep the prefix (up to first underscore, max 4 chars) and last 2 chars.
37 const underscore = v.indexOf("_");
38 const prefixLen =
39 underscore > 0 && underscore <= 4 ? underscore + 1 : Math.min(2, v.length);
40 const prefix = v.slice(0, prefixLen);
41 const tail = v.length > prefixLen + 4 ? v.slice(-2) : "";
42 return `${prefix}••••••${tail}`;
43}
44
45/**
46 * Heuristic: is this value the mask string we showed on the form, rather
47 * than a freshly-typed secret? Used by the POST handler so a re-submit of
48 * the rendered form doesn't overwrite the real secret with the mask.
49 */
50export function isMaskedValue(s: string | null | undefined): boolean {
51 if (!s) return false;
52 return s.includes("••••••");
53}
54
55/**
56 * Read a config value. DB wins; falls back to `process.env[fallbackEnv]`;
57 * empty string if neither is set. Cached for 60s.
58 */
59export async function getConfigValue(
60 key: string,
61 fallbackEnv: string
62): Promise<string> {
63 const cached = cache.get(key);
64 if (cached && cached.expiresAt > Date.now()) {
65 return cached.value || process.env[fallbackEnv] || "";
66 }
67
68 let dbValue = "";
69 try {
70 const [row] = await db
71 .select({ value: systemConfig.value })
72 .from(systemConfig)
73 .where(eq(systemConfig.key, key))
74 .limit(1);
75 dbValue = (row?.value ?? "").trim();
76 } catch (err) {
77 console.warn(
78 "[system-config] getConfigValue read failed:",
79 err instanceof Error ? err.message : err
80 );
81 }
82
83 cache.set(key, { value: dbValue, expiresAt: Date.now() + CACHE_TTL_MS });
84 return dbValue || process.env[fallbackEnv] || "";
85}
86
87/**
88 * Upsert a config value. Also writes `process.env[key]` so existing
89 * synchronous `config.X` getters pick it up immediately — no restart.
90 * Throws on DB failure so the route handler can surface an error banner.
91 */
92export async function setConfigValue(
93 key: string,
94 value: string,
95 userId: string | null
96): Promise<void> {
97 const trimmed = (value ?? "").trim();
98 await db
99 .insert(systemConfig)
100 .values({ key, value: trimmed, updatedByUserId: userId || null })
101 .onConflictDoUpdate({
102 target: systemConfig.key,
103 set: {
104 value: trimmed,
105 updatedByUserId: userId || null,
106 updatedAt: new Date(),
107 },
108 });
109 cache.set(key, { value: trimmed, expiresAt: Date.now() + CACHE_TTL_MS });
110 // Make the new value visible to sync getters across the process.
111 if (trimmed) process.env[key] = trimmed;
112 else delete process.env[key];
113}
114
115/**
116 * Boot hook: copy every saved row into `process.env` before other modules
117 * read it. Called once from `src/index.ts` near startup. Fire-and-forget —
118 * if the DB is unreachable at boot, existing env vars stay as the fallback.
119 */
120export async function loadConfigIntoEnv(): Promise<number> {
121 try {
122 const rows = await db
123 .select({ key: systemConfig.key, value: systemConfig.value })
124 .from(systemConfig);
125 let n = 0;
126 for (const r of rows) {
127 const v = (r.value ?? "").trim();
128 if (!v) continue;
129 // DB wins over env at boot. Existing process.env values are
130 // overridden — that's the whole point: the admin saved a newer
131 // value in the UI, and they want it to take effect.
132 process.env[r.key] = v;
133 cache.set(r.key, { value: v, expiresAt: Date.now() + CACHE_TTL_MS });
134 n++;
135 }
136 return n;
137 } catch (err) {
138 console.warn(
139 "[system-config] loadConfigIntoEnv skipped:",
140 err instanceof Error ? err.message : err
141 );
142 return 0;
143 }
144}
145
146/** Test-only — flush the in-memory cache. */
147export function __resetCache(): void {
148 cache.clear();
149}
150
151/**
152 * Canonical integration list rendered by /admin/integrations. Keeping it
153 * here (not in the route) so other modules (e.g. /admin/health) can read
154 * which keys are user-managed without importing JSX.
155 */
156export interface IntegrationField {
157 key: string;
158 envFallback: string;
159 label: string;
160 helper: string;
161 helperLink?: { href: string; text: string };
162 isSecret: boolean;
06a502cClaude163 group:
164 | "platform"
165 | "ai"
166 | "email"
167 | "scm"
168 | "security"
169 | "observability"
170 | "webhook";
509c376Claude171}
172
173export const INTEGRATION_FIELDS: IntegrationField[] = [
06a502cClaude174 {
175 key: "APP_BASE_URL",
176 envFallback: "APP_BASE_URL",
177 label: "Public base URL",
178 helper:
179 "The HTTPS URL users hit (e.g. https://gluecron.com). Used to build OAuth redirect URIs — wrong/missing → Google/GitHub sign-in fails with redirect_uri_mismatch.",
180 isSecret: false,
181 group: "platform",
182 },
183 {
184 key: "SELF_HOST_REPO",
185 envFallback: "SELF_HOST_REPO",
186 label: "Self-host repo (owner/name)",
187 helper:
188 "Which repo the /admin/ops + /admin/self-host tools operate on. Defaults to ccantynz-alt/Gluecron.com.",
189 isSecret: false,
190 group: "platform",
191 },
509c376Claude192 {
193 key: "ANTHROPIC_API_KEY",
194 envFallback: "ANTHROPIC_API_KEY",
195 label: "Anthropic API key",
196 helper:
197 "Powers AI PR review, incident responder, commit messages, and auto-merge approval.",
198 helperLink: { href: "https://console.anthropic.com/", text: "console.anthropic.com" },
199 isSecret: true,
200 group: "ai",
201 },
202 {
203 key: "RESEND_API_KEY",
204 envFallback: "RESEND_API_KEY",
205 label: "Resend API key",
206 helper: "Verification emails, password reset, magic link sign-in.",
207 helperLink: { href: "https://resend.com/api-keys", text: "resend.com/api-keys" },
208 isSecret: true,
209 group: "email",
210 },
211 {
212 key: "EMAIL_FROM",
213 envFallback: "EMAIL_FROM",
214 label: "Email sender address",
215 helper: 'Format: `Gluecron <no-reply@your-domain>`.',
216 isSecret: false,
217 group: "email",
218 },
219 {
220 key: "GITHUB_TOKEN",
221 envFallback: "GITHUB_TOKEN",
222 label: "GitHub personal access token",
223 helper: "Used by the auto-merge sweep and GitHub-side API calls.",
224 helperLink: { href: "https://github.com/settings/tokens", text: "github.com/settings/tokens" },
225 isSecret: true,
226 group: "scm",
227 },
228 {
229 key: "GATETEST_URL",
230 envFallback: "GATETEST_URL",
231 label: "GateTest endpoint URL",
232 helper: "Push-time security scanner — leave blank to disable scans.",
233 isSecret: false,
234 group: "security",
235 },
236 {
237 key: "GATETEST_API_KEY",
238 envFallback: "GATETEST_API_KEY",
239 label: "GateTest API key",
240 helper: "Auth token sent with each scan request.",
241 isSecret: true,
242 group: "security",
243 },
244 {
245 key: "DEPLOY_EVENT_TOKEN",
246 envFallback: "DEPLOY_EVENT_TOKEN",
247 label: "Deploy events token",
248 helper:
249 "Shared secret for the live deploy timeline and AI incident responder.",
250 isSecret: true,
251 group: "observability",
252 },
253 {
9ecf5a4Claude254 key: "VAPRON_DEPLOY_URL",
255 envFallback: "VAPRON_DEPLOY_URL",
256 label: "Vapron webhook URL",
257 helper:
5e67f6bccanty labs258 "Optional: notify Vapron (formerly Crontech) on every push to the canonical repo. Default: https://vapron.ai/api/hooks/gluecron/push",
509c376Claude259 isSecret: false,
260 group: "webhook",
261 },
262 {
9ecf5a4Claude263 key: "VAPRON_HMAC_SECRET",
264 envFallback: "VAPRON_HMAC_SECRET",
265 label: "Vapron HMAC secret",
266 helper:
267 "Signs outbound Vapron webhook payloads (X-Gluecron-Signature). Must match the secret configured on Vapron's side.",
268 isSecret: true,
269 group: "webhook",
270 },
e61e6cdccanty labs271 {
272 key: "VAPRON_API_KEY",
273 envFallback: "VAPRON_API_KEY",
274 label: "Vapron API key",
275 helper:
276 "Tenant API key from Vapron onboarding. Sent as `Authorization: Bearer …` on every outbound call to Vapron.",
277 isSecret: true,
278 group: "webhook",
279 },
9ecf5a4Claude280 {
281 key: "VAPRON_EVENT_TOKEN",
282 envFallback: "VAPRON_EVENT_TOKEN",
283 label: "Vapron inbound event token",
284 helper:
285 "Bearer token Vapron presents on deploy.succeeded/deploy.failed callbacks to /api/events/deploy.",
509c376Claude286 isSecret: true,
287 group: "webhook",
288 },
289];