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.
| 509c376 | 1 | /** |
| 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 | ||
| 23 | import { eq } from "drizzle-orm"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { systemConfig } from "../db/schema"; | |
| 26 | ||
| 27 | const CACHE_TTL_MS = 60_000; | |
| 28 | ||
| 29 | type CacheEntry = { value: string; expiresAt: number }; | |
| 30 | const cache = new Map<string, CacheEntry>(); | |
| 31 | ||
| 32 | /** Mask "re_abc123xyz789" → "re_••••••89". Used for safe display in the UI. */ | |
| 33 | export 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 | */ | |
| 50 | export 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 | */ | |
| 59 | export 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 | */ | |
| 92 | export 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 | */ | |
| 120 | export 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. */ | |
| 147 | export 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 | */ | |
| 156 | export interface IntegrationField { | |
| 157 | key: string; | |
| 158 | envFallback: string; | |
| 159 | label: string; | |
| 160 | helper: string; | |
| 161 | helperLink?: { href: string; text: string }; | |
| 162 | isSecret: boolean; | |
| 06a502c | 163 | group: |
| 164 | | "platform" | |
| 165 | | "ai" | |
| 166 | | "email" | |
| 167 | | "scm" | |
| 168 | | "security" | |
| 169 | | "observability" | |
| 170 | | "webhook"; | |
| 509c376 | 171 | } |
| 172 | ||
| 173 | export const INTEGRATION_FIELDS: IntegrationField[] = [ | |
| 06a502c | 174 | { |
| 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 | }, | |
| 509c376 | 192 | { |
| 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 | { | |
| 9ecf5a4 | 254 | key: "VAPRON_DEPLOY_URL", |
| 255 | envFallback: "VAPRON_DEPLOY_URL", | |
| 256 | label: "Vapron webhook URL", | |
| 257 | helper: | |
| 258 | "Optional: notify Vapron (formerly Crontech) on every push to the canonical repo. Default: https://vapron.ai/api/webhooks/gluecron-push", | |
| 509c376 | 259 | isSecret: false, |
| 260 | group: "webhook", | |
| 261 | }, | |
| 262 | { | |
| 9ecf5a4 | 263 | 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 | }, | |
| 271 | { | |
| 272 | key: "VAPRON_EVENT_TOKEN", | |
| 273 | envFallback: "VAPRON_EVENT_TOKEN", | |
| 274 | label: "Vapron inbound event token", | |
| 275 | helper: | |
| 276 | "Bearer token Vapron presents on deploy.succeeded/deploy.failed callbacks to /api/events/deploy.", | |
| 509c376 | 277 | isSecret: true, |
| 278 | group: "webhook", | |
| 279 | }, | |
| 280 | ]; |