Commit509c376unknown_key
feat(admin): /admin/integrations — env vars via UI, no more SSH
feat(admin): /admin/integrations — env vars via UI, no more SSH DB-stored runtime config for the keys an operator used to rotate by SSHing into the box and editing /etc/gluecron.env. Boot hook loads saved rows into process.env BEFORE other modules read it, so existing synchronous config getters transparently pick up DB values with no restart. Masked display + isMaskedValue gate prevent re-submitting the form from overwriting real secrets with the rendered mask. - drizzle/0055_system_config.sql — new table - src/db/schema.ts — additive systemConfig export - src/lib/system-config.ts — getConfigValue / setConfigValue with 60s LRU + maskSecret + INTEGRATION_FIELDS catalogue - src/routes/admin-integrations.tsx — GET (form, grouped sections, status pills) + POST (upsert + per-key audit.admin.integrations.save) - src/index.ts — loadConfigIntoEnv() boot hook - src/app.tsx — mount route - src/routes/admin.tsx — primary "Integrations" tile in action grid - src/__tests__/admin-integrations.test.ts — 14 tests (10 pass, 4 DB-gated skip without DATABASE_URL) https://claude.ai/code/session_01KdpgsaGpmEExJAz3yhFLvx
8 files changed+1188−0509c3766ddc20798070760a256b7355b6f873705
8 changed files+1188−0
Addeddrizzle/0055_system_config.sql+19−0View fileUnifiedSplit
@@ -0,0 +1,19 @@
1-- /admin/integrations — DB-stored platform integration secrets.
2--
3-- Replaces the SSH-into-the-box workflow for runtime-changeable keys
4-- (ANTHROPIC_API_KEY, RESEND_API_KEY, GITHUB_TOKEN, etc.). The boot
5-- hook in src/index.ts loads every row into process.env BEFORE any
6-- module reads it, so existing synchronous `config.X` getters keep
7-- working unchanged.
8--
9-- Strictly additive: nothing else reads or writes this table.
10
11CREATE TABLE IF NOT EXISTS system_config (
12 key text PRIMARY KEY,
13 value text NOT NULL DEFAULT '',
14 updated_at timestamptz NOT NULL DEFAULT now(),
15 updated_by_user_id uuid REFERENCES users(id) ON DELETE SET NULL
16);
17
18CREATE INDEX IF NOT EXISTS idx_system_config_updated_at
19 ON system_config (updated_at DESC);
Addedsrc/__tests__/admin-integrations.test.ts+309−0View fileUnifiedSplit
@@ -0,0 +1,309 @@
1/**
2 * /admin/integrations — DB-stored platform integration secrets.
3 *
4 * Coverage:
5 * 1. GET /admin/integrations without auth → 302 to /login
6 * 2. GET /admin/integrations with non-admin session → 403
7 * 3. getConfigValue() returns DB value when set, env fallback when not,
8 * empty string when neither is set
9 * 4. setConfigValue() upserts + writes an audit row
10 * 5. Masked values aren't written back as the real value (re-submitting
11 * the rendered form doesn't overwrite the existing secret with `••••••`)
12 * 6. maskSecret() formats secrets as `prefix_••••••XY`
13 * 7. INTEGRATION_FIELDS exposes the documented surface
14 *
15 * The DB-backed tests are gated on `DATABASE_URL` so the suite stays green
16 * on machines without Postgres (mirroring connect-claude.test.ts).
17 */
18
19import { describe, it, expect, beforeEach } from "bun:test";
20import app from "../app";
21import {
22 getConfigValue,
23 setConfigValue,
24 maskSecret,
25 isMaskedValue,
26 INTEGRATION_FIELDS,
27 __resetCache,
28} from "../lib/system-config";
29
30const HAS_DB = Boolean(process.env.DATABASE_URL);
31
32// ---------------------------------------------------------------------------
33// 1. Auth gating
34// ---------------------------------------------------------------------------
35
36describe("admin-integrations — auth gating", () => {
37 it("GET /admin/integrations without a session → 302 /login", async () => {
38 const res = await app.request("/admin/integrations");
39 expect(res.status).toBe(302);
40 expect(res.headers.get("location") || "").toContain("/login");
41 });
42
43 it("POST /admin/integrations without a session → 302 /login", async () => {
44 const res = await app.request("/admin/integrations", {
45 method: "POST",
46 body: new URLSearchParams({ ANTHROPIC_API_KEY: "sk-test" }),
47 headers: { "content-type": "application/x-www-form-urlencoded" },
48 });
49 expect(res.status).toBe(302);
50 expect(res.headers.get("location") || "").toContain("/login");
51 });
52});
53
54// ---------------------------------------------------------------------------
55// 2. Non-admin → 403 (DB-backed: needs a real user + session)
56// ---------------------------------------------------------------------------
57
58describe.skipIf(!HAS_DB)("admin-integrations — non-admin gate", () => {
59 it("GET /admin/integrations with a non-admin session → 403", async () => {
60 const { db } = await import("../db");
61 const { users, sessions } = await import("../db/schema");
62 const { randomBytes } = await import("crypto");
63
64 // Make a throwaway user — no site_admins row → non-admin by definition.
65 const username = `integ-nonadmin-${randomBytes(4).toString("hex")}`;
66 const [u] = await db
67 .insert(users)
68 .values({
69 username,
70 email: `${username}@test.local`,
71 passwordHash: "x",
72 })
73 .returning({ id: users.id });
74
75 const token = randomBytes(32).toString("hex");
76 await db.insert(sessions).values({
77 userId: u!.id,
78 token,
79 expiresAt: new Date(Date.now() + 60_000),
80 });
81
82 const res = await app.request("/admin/integrations", {
83 headers: { cookie: `session=${token}` },
84 });
85 expect(res.status).toBe(403);
86 });
87});
88
89// ---------------------------------------------------------------------------
90// 3. getConfigValue — DB / env / empty fallback ladder
91// ---------------------------------------------------------------------------
92
93describe("system-config — getConfigValue fallback ladder", () => {
94 beforeEach(() => {
95 __resetCache();
96 });
97
98 it("returns empty string when neither DB nor env is set", async () => {
99 delete process.env.__SYSCFG_NOPE__;
100 const v = await getConfigValue("__SYSCFG_NOPE__", "__SYSCFG_NOPE__");
101 expect(v).toBe("");
102 });
103
104 it("returns env fallback when DB has no row", async () => {
105 const key = "__SYSCFG_ENV_ONLY__";
106 process.env[key] = "from-env";
107 __resetCache();
108 const v = await getConfigValue(key, key);
109 expect(v).toBe("from-env");
110 delete process.env[key];
111 });
112
113 // DB-only assertion — must have Postgres available.
114 it.skipIf(!HAS_DB)("returns DB value when both DB and env are set", async () => {
115 const key = `__SYSCFG_DB_${Date.now()}__`;
116 process.env[key] = "from-env";
117 try {
118 await setConfigValue(key, "from-db", null);
119 __resetCache();
120 const v = await getConfigValue(key, key);
121 expect(v).toBe("from-db");
122 } finally {
123 const { db } = await import("../db");
124 const { systemConfig } = await import("../db/schema");
125 const { eq } = await import("drizzle-orm");
126 await db.delete(systemConfig).where(eq(systemConfig.key, key));
127 delete process.env[key];
128 }
129 });
130});
131
132// ---------------------------------------------------------------------------
133// 4. setConfigValue — upsert + audit
134// ---------------------------------------------------------------------------
135
136describe.skipIf(!HAS_DB)("system-config — setConfigValue upserts + audits", () => {
137 it("writes a system_config row and an audit_log entry", async () => {
138 const { db } = await import("../db");
139 const { systemConfig, users, auditLog } = await import("../db/schema");
140 const { audit } = await import("../lib/notify");
141 const { eq, desc } = await import("drizzle-orm");
142 const { randomBytes } = await import("crypto");
143
144 const username = `integ-setval-${randomBytes(4).toString("hex")}`;
145 const [u] = await db
146 .insert(users)
147 .values({
148 username,
149 email: `${username}@test.local`,
150 passwordHash: "x",
151 })
152 .returning({ id: users.id });
153
154 const key = `__SYSCFG_AUDIT_${Date.now()}__`;
155 try {
156 await setConfigValue(key, "secret-value", u!.id);
157
158 // Row landed
159 const [row] = await db
160 .select()
161 .from(systemConfig)
162 .where(eq(systemConfig.key, key))
163 .limit(1);
164 expect(row).toBeTruthy();
165 expect(row!.value).toBe("secret-value");
166 expect(row!.updatedByUserId).toBe(u!.id);
167
168 // Mimic what the route handler does — record the key but never the
169 // value. We assert the audit row appears with action+target+key in
170 // metadata.
171 await audit({
172 userId: u!.id,
173 action: "admin.integrations.save",
174 targetType: "system_config",
175 targetId: key,
176 metadata: { key, hadValue: false, hasValue: true },
177 });
178
179 const auditRows = await db
180 .select()
181 .from(auditLog)
182 .where(eq(auditLog.targetId, key))
183 .orderBy(desc(auditLog.createdAt))
184 .limit(1);
185 expect(auditRows.length).toBe(1);
186 expect(auditRows[0]!.action).toBe("admin.integrations.save");
187 // Critical: the value itself must NOT appear in the audit metadata.
188 const meta = auditRows[0]!.metadata
189 ? JSON.parse(auditRows[0]!.metadata)
190 : {};
191 expect(JSON.stringify(meta)).not.toContain("secret-value");
192 expect(meta.key).toBe(key);
193 } finally {
194 await db.delete(systemConfig).where(eq(systemConfig.key, key));
195 // auditLog rows + users row cleaned up cascade-style by FK in dev DBs;
196 // explicit cleanup skipped here to keep the test small.
197 }
198 });
199});
200
201// ---------------------------------------------------------------------------
202// 5. Masked value semantics — re-submit must not overwrite real secret
203// ---------------------------------------------------------------------------
204
205describe("system-config — mask helpers", () => {
206 it("maskSecret returns empty for empty/nullish", () => {
207 expect(maskSecret("")).toBe("");
208 expect(maskSecret(null)).toBe("");
209 expect(maskSecret(undefined)).toBe("");
210 });
211
212 it("maskSecret keeps a short prefix + last 2 chars for long secrets", () => {
213 const m = maskSecret("re_abc123xyz789QR");
214 expect(m.startsWith("re_")).toBe(true);
215 expect(m).toContain("••••••");
216 expect(m.endsWith("QR")).toBe(true);
217 expect(m).not.toContain("abc123");
218 });
219
220 it("isMaskedValue detects strings that look like the rendered mask", () => {
221 expect(isMaskedValue("re_••••••QR")).toBe(true);
222 expect(isMaskedValue("sk-ant-real-token-here")).toBe(false);
223 expect(isMaskedValue("")).toBe(false);
224 expect(isMaskedValue(null)).toBe(false);
225 });
226
227 // Round-trip: a value masked, then submitted back unchanged, must NOT
228 // overwrite the underlying secret when the route's POST handler runs.
229 it.skipIf(!HAS_DB)(
230 "re-submitting a masked value preserves the real secret",
231 async () => {
232 const { db } = await import("../db");
233 const { systemConfig } = await import("../db/schema");
234 const { eq } = await import("drizzle-orm");
235 const key = `__SYSCFG_MASK_${Date.now()}__`;
236
237 try {
238 // Seed the real secret.
239 await setConfigValue(key, "real-secret-xyz", null);
240 const before = await getConfigValue(key, key);
241 expect(before).toBe("real-secret-xyz");
242
243 // Render its mask, then simulate the form POST handler's gate.
244 const masked = maskSecret(before);
245 expect(isMaskedValue(masked)).toBe(true);
246
247 // If this gate were missing, the value would now be `re_••••••yz`.
248 if (!isMaskedValue(masked)) {
249 await setConfigValue(key, masked, null);
250 }
251
252 // The real secret should still be there.
253 const after = await getConfigValue(key, key);
254 expect(after).toBe("real-secret-xyz");
255 } finally {
256 await db.delete(systemConfig).where(eq(systemConfig.key, key));
257 }
258 }
259 );
260});
261
262// ---------------------------------------------------------------------------
263// 6. Surface — INTEGRATION_FIELDS exposes the documented list
264// ---------------------------------------------------------------------------
265
266describe("system-config — INTEGRATION_FIELDS surface", () => {
267 it("includes every key the spec calls out", () => {
268 const keys = INTEGRATION_FIELDS.map((f) => f.key);
269 for (const expected of [
270 "ANTHROPIC_API_KEY",
271 "RESEND_API_KEY",
272 "EMAIL_FROM",
273 "GITHUB_TOKEN",
274 "GATETEST_URL",
275 "GATETEST_API_KEY",
276 "DEPLOY_EVENT_TOKEN",
277 "CRONTECH_DEPLOY_URL",
278 "CRONTECH_HMAC_SECRET",
279 ]) {
280 expect(keys).toContain(expected);
281 }
282 });
283
284 it("does NOT expose env-only keys (chicken-and-egg / infra)", () => {
285 const keys = INTEGRATION_FIELDS.map((f) => f.key);
286 for (const forbidden of [
287 "DATABASE_URL",
288 "SELF_HOST_REPO",
289 "BUILD_SHA",
290 "BUILD_TIME",
291 "PORT",
292 "GIT_REPOS_PATH",
293 ]) {
294 expect(keys).not.toContain(forbidden);
295 }
296 });
297
298 it("tags each field with a group + helper text", () => {
299 for (const f of INTEGRATION_FIELDS) {
300 expect(typeof f.label).toBe("string");
301 expect(f.label.length).toBeGreaterThan(0);
302 expect(typeof f.helper).toBe("string");
303 expect(f.helper.length).toBeGreaterThan(0);
304 expect(["ai", "email", "scm", "security", "observability", "webhook"]).toContain(
305 f.group
306 );
307 }
308 });
309});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
@@ -71,6 +71,7 @@ import adminDeploysPageRoutes from "./routes/admin-deploys-page";
7171import adminOpsRoutes from "./routes/admin-ops";
7272import adminSelfHostRoutes from "./routes/admin-self-host";
7373import adminDiagnoseRoutes from "./routes/admin-diagnose";
74import adminIntegrationsRoutes from "./routes/admin-integrations";
7475import advisoriesRoutes from "./routes/advisories";
7576import aiChangelogRoutes from "./routes/ai-changelog";
7677import aiExplainRoutes from "./routes/ai-explain";
@@ -396,6 +397,7 @@ app.route("/", onboardingRoutes);
396397
397398// Admin + feature routes
398399app.route("/", adminRoutes);
400app.route("/", adminIntegrationsRoutes);
399401app.route("/", adminDeploysRoutes);
400402app.route("/", adminDeploysPageRoutes);
401403// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
Modifiedsrc/db/schema.ts+15−0View fileUnifiedSplit
@@ -1853,6 +1853,21 @@ export const siteAdmins = pgTable("site_admins", {
18531853
18541854export type SiteAdmin = typeof siteAdmins.$inferSelect;
18551855
1856// /admin/integrations — DB-stored platform integration secrets (migration 0055).
1857// Loaded into process.env at boot so the existing synchronous config getters
1858// keep working transparently. Never exposes DATABASE_URL / SELF_HOST_REPO /
1859// PORT / GIT_REPOS_PATH — those stay env-only.
1860export const systemConfig = pgTable("system_config", {
1861 key: text("key").primaryKey(),
1862 value: text("value").notNull().default(""),
1863 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1864 updatedByUserId: uuid("updated_by_user_id").references(() => users.id, {
1865 onDelete: "set null",
1866 }),
1867});
1868
1869export type SystemConfig = typeof systemConfig.$inferSelect;
1870
18561871// F4 — Billing + quotas
18571872export const billingPlans = pgTable("billing_plans", {
18581873 id: uuid("id").primaryKey().defaultRandom(),
Modifiedsrc/index.ts+18−0View fileUnifiedSplit
@@ -8,10 +8,28 @@ import { ensureDemoActivity } from "./lib/demo-activity-seed";
88import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
99import { maybeSelfBootstrap } from "./lib/self-bootstrap";
1010import { notifySystemdReady } from "./lib/systemd-notify";
11import { loadConfigIntoEnv } from "./lib/system-config";
1112
1213// Ensure repos directory exists
1314await mkdir(config.gitReposPath, { recursive: true });
1415
16// /admin/integrations boot hook — pull saved integration secrets out of the
17// system_config table and into process.env BEFORE anything else reads them.
18// This is the magic that lets the existing synchronous config getters
19// (config.anthropicApiKey, config.resendApiKey, …) transparently pick up
20// values an admin saved through the UI, with no restart needed. If the DB
21// is unreachable at boot, env vars stay as the fallback — never blocks
22// startup.
23try {
24 const n = await loadConfigIntoEnv();
25 if (n > 0) console.log(`[system-config] loaded ${n} key(s) from DB into env`);
26} catch (err) {
27 console.warn(
28 "[system-config] boot load failed (env vars remain authoritative):",
29 err instanceof Error ? err.message : err
30 );
31}
32
1533// Self-bootstrap: if Gluecron's own canonical repo (`ccantynz/Gluecron.com.git`
1634// by default) doesn't exist on disk yet, initialize it from the GitHub mirror
1735// and install the post-receive hook. This is the platform's self-healing path
Addedsrc/lib/system-config.ts+244−0View fileUnifiedSplit
@@ -0,0 +1,244 @@
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
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;
163 group: "ai" | "email" | "scm" | "security" | "observability" | "webhook";
164}
165
166export const INTEGRATION_FIELDS: IntegrationField[] = [
167 {
168 key: "ANTHROPIC_API_KEY",
169 envFallback: "ANTHROPIC_API_KEY",
170 label: "Anthropic API key",
171 helper:
172 "Powers AI PR review, incident responder, commit messages, and auto-merge approval.",
173 helperLink: { href: "https://console.anthropic.com/", text: "console.anthropic.com" },
174 isSecret: true,
175 group: "ai",
176 },
177 {
178 key: "RESEND_API_KEY",
179 envFallback: "RESEND_API_KEY",
180 label: "Resend API key",
181 helper: "Verification emails, password reset, magic link sign-in.",
182 helperLink: { href: "https://resend.com/api-keys", text: "resend.com/api-keys" },
183 isSecret: true,
184 group: "email",
185 },
186 {
187 key: "EMAIL_FROM",
188 envFallback: "EMAIL_FROM",
189 label: "Email sender address",
190 helper: 'Format: `Gluecron <no-reply@your-domain>`.',
191 isSecret: false,
192 group: "email",
193 },
194 {
195 key: "GITHUB_TOKEN",
196 envFallback: "GITHUB_TOKEN",
197 label: "GitHub personal access token",
198 helper: "Used by the auto-merge sweep and GitHub-side API calls.",
199 helperLink: { href: "https://github.com/settings/tokens", text: "github.com/settings/tokens" },
200 isSecret: true,
201 group: "scm",
202 },
203 {
204 key: "GATETEST_URL",
205 envFallback: "GATETEST_URL",
206 label: "GateTest endpoint URL",
207 helper: "Push-time security scanner — leave blank to disable scans.",
208 isSecret: false,
209 group: "security",
210 },
211 {
212 key: "GATETEST_API_KEY",
213 envFallback: "GATETEST_API_KEY",
214 label: "GateTest API key",
215 helper: "Auth token sent with each scan request.",
216 isSecret: true,
217 group: "security",
218 },
219 {
220 key: "DEPLOY_EVENT_TOKEN",
221 envFallback: "DEPLOY_EVENT_TOKEN",
222 label: "Deploy events token",
223 helper:
224 "Shared secret for the live deploy timeline and AI incident responder.",
225 isSecret: true,
226 group: "observability",
227 },
228 {
229 key: "CRONTECH_DEPLOY_URL",
230 envFallback: "CRONTECH_DEPLOY_URL",
231 label: "Crontech webhook URL",
232 helper: "Optional: notify Crontech on every push to the canonical repo.",
233 isSecret: false,
234 group: "webhook",
235 },
236 {
237 key: "CRONTECH_HMAC_SECRET",
238 envFallback: "CRONTECH_HMAC_SECRET",
239 label: "Crontech HMAC secret",
240 helper: "Used to sign outbound Crontech webhook payloads.",
241 isSecret: true,
242 group: "webhook",
243 },
244];
Addedsrc/routes/admin-integrations.tsx+572−0View fileUnifiedSplit
@@ -0,0 +1,572 @@
1/**
2 * /admin/integrations — DB-stored platform integration secrets.
3 *
4 * GET /admin/integrations — render the form (masked values)
5 * POST /admin/integrations — upsert each field + audit-log every change
6 *
7 * Replaces the SSH-into-the-box workflow for runtime-changeable keys
8 * (ANTHROPIC_API_KEY, RESEND_API_KEY, GITHUB_TOKEN, etc.). Boot hook in
9 * `src/index.ts` loads saved rows into `process.env` BEFORE any other
10 * module reads them, so existing synchronous `config.X` getters keep
11 * working transparently — no restart needed.
12 *
13 * Gated by `isSiteAdmin` using the same `gate()` pattern as
14 * `src/routes/admin.tsx`. Scoped CSS prefixed `.admin-int-` to avoid
15 * collisions with the parent admin polish.
16 */
17
18import { Hono } from "hono";
19import { Layout } from "../views/layout";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { isSiteAdmin } from "../lib/admin";
23import { audit } from "../lib/notify";
24import {
25 getConfigValue,
26 setConfigValue,
27 maskSecret,
28 isMaskedValue,
29 INTEGRATION_FIELDS,
30} from "../lib/system-config";
31
32const integrations = new Hono<AuthEnv>();
33integrations.use("*", softAuth);
34
35/* ─────────────────────────────────────────────────────────────────────────
36 * Scoped CSS — every class prefixed `.admin-int-` so this surface can't
37 * bleed into the wider admin panel. Mirrors the gradient-hairline hero +
38 * card patterns from commits 07f4b70 and 98eb360.
39 * ───────────────────────────────────────────────────────────────────── */
40const styles = `
41 .admin-int-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
42
43 .admin-int-hero {
44 position: relative;
45 margin-bottom: var(--space-5);
46 padding: var(--space-5) var(--space-6);
47 background: var(--bg-elevated);
48 border: 1px solid var(--border);
49 border-radius: 16px;
50 overflow: hidden;
51 }
52 .admin-int-hero::before {
53 content: '';
54 position: absolute;
55 top: 0; left: 0; right: 0;
56 height: 2px;
57 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
58 opacity: 0.7;
59 pointer-events: none;
60 }
61 .admin-int-hero-orb {
62 position: absolute;
63 inset: -20% -10% auto auto;
64 width: 380px; height: 380px;
65 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
66 filter: blur(80px);
67 opacity: 0.7;
68 pointer-events: none;
69 z-index: 0;
70 }
71 .admin-int-hero-inner { position: relative; z-index: 1; max-width: 720px; }
72 .admin-int-eyebrow {
73 font-size: 12px;
74 color: var(--text-muted);
75 margin-bottom: var(--space-2);
76 letter-spacing: 0.02em;
77 display: inline-flex;
78 align-items: center;
79 gap: 8px;
80 }
81 .admin-int-eyebrow .pill {
82 display: inline-flex;
83 align-items: center;
84 justify-content: center;
85 width: 18px; height: 18px;
86 border-radius: 6px;
87 background: rgba(140,109,255,0.14);
88 color: #b69dff;
89 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
90 }
91 .admin-int-title {
92 font-size: clamp(28px, 4vw, 40px);
93 font-family: var(--font-display);
94 font-weight: 800;
95 letter-spacing: -0.028em;
96 line-height: 1.05;
97 margin: 0 0 var(--space-2);
98 color: var(--text-strong);
99 }
100 .admin-int-title-grad {
101 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
102 -webkit-background-clip: text;
103 background-clip: text;
104 -webkit-text-fill-color: transparent;
105 color: transparent;
106 }
107 .admin-int-sub {
108 font-size: 15px;
109 color: var(--text-muted);
110 margin: 0;
111 line-height: 1.5;
112 max-width: 620px;
113 }
114
115 .admin-int-banner {
116 margin-bottom: var(--space-4);
117 padding: 10px 14px;
118 border-radius: 10px;
119 font-size: 13.5px;
120 border: 1px solid var(--border);
121 background: rgba(255,255,255,0.025);
122 color: var(--text);
123 }
124 .admin-int-banner.is-ok {
125 border-color: rgba(52,211,153,0.40);
126 background: rgba(52,211,153,0.08);
127 color: #bbf7d0;
128 }
129 .admin-int-banner.is-error {
130 border-color: rgba(248,113,113,0.40);
131 background: rgba(248,113,113,0.08);
132 color: #fecaca;
133 }
134
135 .admin-int-section {
136 margin-bottom: var(--space-5);
137 background: var(--bg-elevated);
138 border: 1px solid var(--border);
139 border-radius: 14px;
140 overflow: hidden;
141 }
142 .admin-int-section-head {
143 padding: var(--space-4) var(--space-5);
144 border-bottom: 1px solid var(--border);
145 display: flex;
146 align-items: center;
147 justify-content: space-between;
148 gap: var(--space-3);
149 flex-wrap: wrap;
150 }
151 .admin-int-section-title {
152 margin: 0;
153 font-family: var(--font-display);
154 font-size: 17px;
155 font-weight: 700;
156 letter-spacing: -0.018em;
157 color: var(--text-strong);
158 }
159 .admin-int-section-sub {
160 margin: 4px 0 0;
161 font-size: 12.5px;
162 color: var(--text-muted);
163 }
164 .admin-int-section-body { padding: var(--space-4) var(--space-5); }
165
166 .admin-int-field { margin-bottom: var(--space-4); }
167 .admin-int-field:last-child { margin-bottom: 0; }
168 .admin-int-field-row {
169 display: flex;
170 align-items: center;
171 justify-content: space-between;
172 gap: var(--space-2);
173 margin-bottom: 6px;
174 }
175 .admin-int-field label {
176 display: block;
177 font-family: var(--font-mono);
178 font-size: 12.5px;
179 font-weight: 600;
180 color: var(--text-strong);
181 letter-spacing: -0.005em;
182 }
183 .admin-int-input {
184 width: 100%;
185 padding: 9px 12px;
186 font-size: 13.5px;
187 color: var(--text);
188 background: var(--bg);
189 border: 1px solid var(--border-strong);
190 border-radius: 8px;
191 outline: none;
192 font-family: var(--font-mono);
193 transition: border-color 120ms ease, box-shadow 120ms ease;
194 box-sizing: border-box;
195 }
196 .admin-int-input:focus {
197 border-color: var(--border-focus);
198 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
199 }
200 .admin-int-hint {
201 font-size: 11.5px;
202 color: var(--text-muted);
203 margin-top: 6px;
204 line-height: 1.45;
205 }
206 .admin-int-hint code {
207 font-family: var(--font-mono);
208 font-size: 11.5px;
209 background: var(--bg-tertiary);
210 padding: 1px 5px;
211 border-radius: 4px;
212 }
213 .admin-int-hint a { color: var(--accent); text-decoration: none; }
214 .admin-int-hint a:hover { text-decoration: underline; }
215
216 .admin-int-status {
217 display: inline-flex;
218 align-items: center;
219 gap: 4px;
220 padding: 2px 8px;
221 border-radius: 9999px;
222 font-size: 10.5px;
223 font-weight: 600;
224 letter-spacing: 0.04em;
225 text-transform: uppercase;
226 }
227 .admin-int-status.is-set {
228 background: rgba(52,211,153,0.14);
229 color: #6ee7b7;
230 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
231 }
232 .admin-int-status.is-missing {
233 background: rgba(251,191,36,0.10);
234 color: #fde68a;
235 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
236 }
237 .admin-int-status .dot {
238 width: 6px; height: 6px;
239 border-radius: 9999px;
240 background: currentColor;
241 }
242
243 .admin-int-foot {
244 padding: var(--space-3) var(--space-5);
245 border-top: 1px solid var(--border);
246 background: rgba(255,255,255,0.012);
247 display: flex;
248 justify-content: flex-end;
249 gap: var(--space-2);
250 align-items: center;
251 flex-wrap: wrap;
252 }
253 .admin-int-foot-hint {
254 margin-right: auto;
255 font-size: 12.5px;
256 color: var(--text-muted);
257 }
258
259 .admin-int-bottom-actions {
260 margin-top: var(--space-5);
261 padding: var(--space-4);
262 text-align: center;
263 color: var(--text-muted);
264 font-size: 13px;
265 border: 1px dashed var(--border);
266 border-radius: 12px;
267 }
268 .admin-int-bottom-actions a {
269 color: var(--accent);
270 text-decoration: none;
271 font-weight: 600;
272 }
273 .admin-int-bottom-actions a:hover { text-decoration: underline; }
274
275 .admin-int-403 {
276 max-width: 540px;
277 margin: var(--space-12) auto;
278 padding: var(--space-6);
279 text-align: center;
280 background: var(--bg-elevated);
281 border: 1px solid var(--border);
282 border-radius: 16px;
283 }
284 .admin-int-403 h2 {
285 font-family: var(--font-display);
286 font-size: 22px;
287 margin: 0 0 8px;
288 color: var(--text-strong);
289 }
290 .admin-int-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
291`;
292
293interface GroupDef {
294 id: string;
295 title: string;
296 blurb: string;
297}
298
299const GROUPS: Record<string, GroupDef> = {
300 ai: {
301 id: "ai",
302 title: "AI",
303 blurb: "Anthropic — powers PR review, incident response, commit messages.",
304 },
305 email: {
306 id: "email",
307 title: "Email",
308 blurb: "Verification, password reset, and magic-link delivery.",
309 },
310 scm: {
311 id: "scm",
312 title: "Source control",
313 blurb: "GitHub-side API calls (mirror sync, auto-merge sweep).",
314 },
315 security: {
316 id: "security",
317 title: "Security",
318 blurb: "Push-time security scanning via GateTest.",
319 },
320 observability: {
321 id: "observability",
322 title: "Observability",
323 blurb: "Deploy timeline + AI incident responder.",
324 },
325 webhook: {
326 id: "webhook",
327 title: "Outbound webhooks",
328 blurb: "Optional notifications to downstream platforms.",
329 },
330};
331
332async function gate(c: any): Promise<{ user: any } | Response> {
333 const user = c.get("user");
334 if (!user) return c.redirect("/login?next=/admin/integrations");
335 if (!(await isSiteAdmin(user.id))) {
336 return c.html(
337 <Layout title="Forbidden" user={user}>
338 <div class="admin-int-403">
339 <h2>403 — Not a site admin</h2>
340 <p>You don't have permission to view this page.</p>
341 </div>
342 <style dangerouslySetInnerHTML={{ __html: styles }} />
343 </Layout>,
344 403
345 );
346 }
347 return { user };
348}
349
350integrations.get("/admin/integrations", async (c) => {
351 const g = await gate(c);
352 if (g instanceof Response) return g;
353 const { user } = g;
354
355 // Load every field's current value (DB → env → empty). Run in parallel.
356 const values = await Promise.all(
357 INTEGRATION_FIELDS.map(async (f) => ({
358 field: f,
359 value: await getConfigValue(f.key, f.envFallback),
360 }))
361 );
362
363 const groups = new Map<string, typeof values>();
364 for (const v of values) {
365 const arr = groups.get(v.field.group) ?? [];
366 arr.push(v);
367 groups.set(v.field.group, arr);
368 }
369
370 const groupOrder: Array<keyof typeof GROUPS> = [
371 "ai",
372 "email",
373 "scm",
374 "security",
375 "observability",
376 "webhook",
377 ];
378
379 const msg = c.req.query("result") || c.req.query("error");
380 const isErr = !!c.req.query("error");
381
382 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
383
384 return c.html(
385 <Layout title="Integrations — admin" user={user}>
386 <div class="admin-int-wrap">
387 <section class="admin-int-hero">
388 <div class="admin-int-hero-orb" aria-hidden="true" />
389 <div class="admin-int-hero-inner">
390 <div class="admin-int-eyebrow">
391 <span class="pill" aria-hidden="true">
392 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
393 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
394 </svg>
395 </span>
396 Platform integrations · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
397 </div>
398 <h2 class="admin-int-title">
399 <span class="admin-int-title-grad">Wire it up.</span>
400 </h2>
401 <p class="admin-int-sub">
402 Every key you'd otherwise put in <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">/etc/gluecron.env</code>.
403 Changes apply immediately — no restart. {totalConfigured} of {INTEGRATION_FIELDS.length} configured.
404 </p>
405 </div>
406 </section>
407
408 {msg && (
409 <div class={"admin-int-banner " + (isErr ? "is-error" : "is-ok")}>
410 {decodeURIComponent(msg)}
411 </div>
412 )}
413
414 <form method="post" action="/admin/integrations">
415 {groupOrder.map((gid) => {
416 const items = groups.get(gid);
417 if (!items || items.length === 0) return null;
418 const g = GROUPS[gid]!;
419 return (
420 <section class="admin-int-section">
421 <header class="admin-int-section-head">
422 <div>
423 <h3 class="admin-int-section-title">{g.title}</h3>
424 <p class="admin-int-section-sub">{g.blurb}</p>
425 </div>
426 </header>
427 <div class="admin-int-section-body">
428 {items.map(({ field, value }) => {
429 const configured = value.trim().length > 0;
430 const display = field.isSecret && configured
431 ? maskSecret(value)
432 : value;
433 return (
434 <div class="admin-int-field">
435 <div class="admin-int-field-row">
436 <label for={`int-${field.key}`}>{field.key}</label>
437 <span
438 class={
439 "admin-int-status " +
440 (configured ? "is-set" : "is-missing")
441 }
442 >
443 <span class="dot" aria-hidden="true" />
444 {configured ? "configured" : "missing"}
445 </span>
446 </div>
447 <input
448 id={`int-${field.key}`}
449 type="text"
450 name={field.key}
451 value={display}
452 aria-label={field.label}
453 placeholder={
454 field.isSecret
455 ? "Paste the secret here"
456 : "Set a value"
457 }
458 class="admin-int-input"
459 autocomplete="off"
460 spellcheck={false}
461 />
462 <div class="admin-int-hint">
463 {field.helper}
464 {field.helperLink && (
465 <>
466 {" "}
467 <a
468 href={field.helperLink.href}
469 target="_blank"
470 rel="noopener noreferrer"
471 >
472 {field.helperLink.text} ↗
473 </a>
474 </>
475 )}
476 {" · env fallback: "}
477 <code>{field.envFallback}</code>
478 </div>
479 </div>
480 );
481 })}
482 </div>
483 </section>
484 );
485 })}
486
487 <div class="admin-int-section" style="margin-bottom:0">
488 <div class="admin-int-foot">
489 <span class="admin-int-foot-hint">
490 Values containing <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">••••••</code> are treated as unchanged — your real secret is preserved.
491 </span>
492 <button type="submit" class="btn btn-primary">
493 Save all changes
494 </button>
495 </div>
496 </div>
497 </form>
498
499 <div class="admin-int-bottom-actions">
500 Verify your changes turned the warnings green on{" "}
501 <a href="/admin/health">/admin/health</a>.
502 </div>
503 </div>
504 <style dangerouslySetInnerHTML={{ __html: styles }} />
505 </Layout>
506 );
507});
508
509integrations.post("/admin/integrations", async (c) => {
510 const g = await gate(c);
511 if (g instanceof Response) return g;
512 const { user } = g;
513
514 const body = await c.req.parseBody();
515
516 let saved = 0;
517 let skipped = 0;
518 const errors: string[] = [];
519
520 for (const field of INTEGRATION_FIELDS) {
521 const submitted = String(body[field.key] ?? "").trim();
522
523 // Don't overwrite a real secret with the mask we showed in the form.
524 if (isMaskedValue(submitted)) {
525 skipped++;
526 continue;
527 }
528
529 // Read the current value to detect a no-op (avoids spurious audit rows).
530 const current = await getConfigValue(field.key, field.envFallback);
531 if (submitted === current) {
532 skipped++;
533 continue;
534 }
535
536 try {
537 await setConfigValue(field.key, submitted, user.id);
538 await audit({
539 userId: user.id,
540 action: "admin.integrations.save",
541 targetType: "system_config",
542 targetId: field.key,
543 // Audit the KEY name + whether a value is now set — NEVER the value.
544 metadata: {
545 key: field.key,
546 hadValue: current.length > 0,
547 hasValue: submitted.length > 0,
548 },
549 });
550 saved++;
551 } catch (err) {
552 const msg = err instanceof Error ? err.message : String(err);
553 errors.push(`${field.key}: ${msg}`);
554 }
555 }
556
557 if (errors.length > 0) {
558 return c.redirect(
559 `/admin/integrations?error=${encodeURIComponent(
560 `Saved ${saved}, but ${errors.length} failed: ${errors.join("; ")}`
561 )}`
562 );
563 }
564
565 const summary =
566 saved === 0
567 ? "No changes — every field matched the current value."
568 : `Saved ${saved} integration${saved === 1 ? "" : "s"}.${skipped > 0 ? ` ${skipped} unchanged.` : ""}`;
569 return c.redirect(`/admin/integrations?result=${encodeURIComponent(summary)}`);
570});
571
572export default integrations;
Modifiedsrc/routes/admin.tsx+9−0View fileUnifiedSplit
@@ -759,6 +759,11 @@ const Icons = {
759759 <polyline points="12 19 5 12 12 5" />
760760 </svg>
761761 ),
762 key: (
763 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
764 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
765 </svg>
766 ),
762767};
763768
764769/** First-letter avatar helper. */
@@ -876,6 +881,10 @@ admin.get("/admin", async (c) => {
876881 <span class="admin-action-icon">{Icons.ops}</span>
877882 Operations
878883 </a>
884 <a href="/admin/integrations" class="admin-action is-primary">
885 <span class="admin-action-icon">{Icons.key}</span>
886 Integrations
887 </a>
879888 <a href="/admin/diagnose" class="admin-action is-primary">
880889 <span class="admin-action-icon">{Icons.pulse}</span>
881890 Health / Diagnose
882891