CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(admin): /admin/integrations becomes the full infra-secrets panel — reveal, rotate, clear, alerting keys, live #5557

MergedXSccantynz wants to mergefeat/infra-secrets-panelmainopened 5d ago
3 changed files+297−16
Modifiedsrc/__tests__/admin-integrations.test.ts+1−0View fileUnifiedSplit
313313 "security",
314314 "observability",
315315 "webhook",
316 "alerting",
316317 ]).toContain(f.group);
317318 }
318319 });
Modifiedsrc/lib/system-config.ts+90−1View fileUnifiedSplit
112112 else delete process.env[key];
113113}
114114
115/**
116 * Delete a config value entirely: row gone, cache gone, process.env
117 * unset — the env-file fallback (if any) becomes authoritative again.
118 * Added 2026-08-27 for the infra-secrets panel's Clear action; before
119 * this the only way to remove a DB-saved value was a manual SQL delete.
120 */
121export async function deleteConfigValue(key: string): Promise<void> {
122 await db.delete(systemConfig).where(eq(systemConfig.key, key));
123 cache.delete(key);
124 delete process.env[key];
125}
126
115127/**
116128 * Boot hook: copy every saved row into `process.env` before other modules
117129 * read it. Called once from `src/index.ts` near startup. Fire-and-forget —
160172 helper: string;
161173 helperLink?: { href: string; text: string };
162174 isSecret: boolean;
175 /**
176 * True for tokens THIS platform mints (shared secrets whose other half
177 * the operator pastes elsewhere, e.g. a GitHub workflow secret). These
178 * get a "Generate" button — server-side random, stored, shown once —
179 * which is what rotation actually means for a self-issued credential.
180 * Never set on third-party keys: we cannot rotate Anthropic's key.
181 */
182 mintable?: boolean;
163183 group:
164184 | "platform"
165185 | "ai"
167187 | "scm"
168188 | "security"
169189 | "observability"
170 | "webhook";
190 | "webhook"
191 | "alerting";
171192}
172193
173194export const INTEGRATION_FIELDS: IntegrationField[] = [
286307 isSecret: true,
287308 group: "webhook",
288309 },
310 // ── Alerting + reporting (added 2026-08-27) ──────────────────────────
311 // These were the reason the owner's alert-wiring session required SSH:
312 // every key below was env-file-only while this panel existed for
313 // exactly this purpose. All readers are lazy process.env lookups, so
314 // values saved here take effect without a deploy.
315 {
316 key: "MONITOR_ALERT_WEBHOOK_URL",
317 envFallback: "MONITOR_ALERT_WEBHOOK_URL",
318 label: "Alert webhook URL",
319 helper:
320 "Where the spine PAGES on red checks, failed deploys and error spikes (Vapron page endpoint, ntfy, or any Slack-compatible receiver). Unset = detection without delivery.",
321 isSecret: true,
322 group: "alerting",
323 },
324 {
325 key: "EMAIL_PROVIDER",
326 envFallback: "EMAIL_PROVIDER",
327 label: "Email provider",
328 helper:
329 "log (dev default), resend, or http (the estate rail — POSTs to the Email rail URL below).",
330 isSecret: false,
331 group: "alerting",
332 },
333 {
334 key: "EMAIL_HTTP_URL",
335 envFallback: "EMAIL_HTTP_URL",
336 label: "Email rail URL",
337 helper:
338 "Estate email endpoint (Vapron's MTA) accepting {from,to,subject,text,html}. Used when provider = http.",
339 isSecret: false,
340 group: "alerting",
341 },
342 {
343 key: "EMAIL_HTTP_TOKEN",
344 envFallback: "EMAIL_HTTP_TOKEN",
345 label: "Email rail bearer token",
346 helper: "Authorization: Bearer sent with every email rail POST.",
347 isSecret: true,
348 group: "alerting",
349 },
350 {
351 key: "HEARTBEAT_REPORT_TOKEN",
352 envFallback: "HEARTBEAT_REPORT_TOKEN",
353 label: "Off-box heartbeat token",
354 helper:
355 "Shared secret the external heartbeat presents to file incidents from outside the box. Generate here, then paste the same value as a secret on the heartbeat workflow. Unset = a dead server cannot get its downtime recorded.",
356 isSecret: true,
357 mintable: true,
358 group: "alerting",
359 },
360 {
361 key: "ESTATE_WATCH_URLS",
362 envFallback: "ESTATE_WATCH_URLS",
363 label: "Estate watch URLs",
364 helper:
365 "Comma-separated https URLs of sibling products to probe (estate:<host> checks). Their outages page but never count toward this platform's uptime.",
366 isSecret: false,
367 group: "alerting",
368 },
369 {
370 key: "REPORTING_EPOCH",
371 envFallback: "REPORTING_EPOCH",
372 label: "Reporting epoch",
373 helper:
374 "ISO date /status measures from. Set at customer-launch for the clean reporting era; unset = the incident ledger's birth (2026-08-09).",
375 isSecret: false,
376 group: "alerting",
377 },
289378];
Modifiedsrc/routes/admin-integrations.tsx+206−15View fileUnifiedSplit
2222import type { AuthEnv } from "../middleware/auth";
2323import { isSiteAdmin } from "../lib/admin";
2424import { audit } from "../lib/notify";
25import { randomBytes } from "crypto";
2526import {
2627 getConfigValue,
2728 setConfigValue,
29 deleteConfigValue,
2830 maskSecret,
2931 isMaskedValue,
3032 INTEGRATION_FIELDS,
181183 color: var(--text-strong);
182184 letter-spacing: -0.005em;
183185 }
186 .admin-int-input-row {
187 display: flex;
188 gap: 8px;
189 align-items: center;
190 }
191 .admin-int-input-row .admin-int-input { flex: 1; min-width: 0; }
192 .admin-int-mini-btn {
193 flex-shrink: 0;
194 padding: 7px 12px;
195 font-size: 12px;
196 font-weight: 600;
197 border-radius: 7px;
198 border: 1px solid var(--border);
199 background: var(--bg);
200 color: var(--text);
201 cursor: pointer;
202 white-space: nowrap;
203 }
204 .admin-int-mini-btn:hover {
205 border-color: var(--accent);
206 color: var(--accent);
207 }
208 .admin-int-mini-btn.is-danger:hover {
209 border-color: var(--red);
210 color: var(--red);
211 }
212 .admin-int-mini-btn:disabled { opacity: 0.5; cursor: wait; }
184213 .admin-int-input {
185214 width: 100%;
186215 padding: 9px 12px;
434463 title: "Outbound webhooks",
435464 blurb: "Optional notifications to downstream platforms.",
436465 },
466 alerting: {
467 id: "alerting",
468 title: "Alerting & reporting",
469 blurb:
470 "The channels that turn detection into delivery — pages, estate email, the off-box heartbeat — plus the reporting epoch. Values here apply live; no SSH, no deploy.",
471 },
437472};
438473
439474async function gate(c: any): Promise<{ user: any } | Response> {
482517 "security",
483518 "observability",
484519 "webhook",
520 "alerting",
485521 ];
486522
487523 const msg = c.req.query("result") || c.req.query("error");
611647 {configured ? "configured" : "missing"}
612648 </span>
613649 </div>
614 <input
615 id={`int-${field.key}`}
616 type="text"
617 name={field.key}
618 value={display}
619 aria-label={field.label}
620 placeholder={
621 field.isSecret
622 ? "Paste the secret here"
623 : "Set a value"
624 }
625 class="admin-int-input"
626 autocomplete="off"
627 spellcheck={false}
628 />
650 <div class="admin-int-input-row">
651 <input
652 id={`int-${field.key}`}
653 type="text"
654 name={field.key}
655 value={display}
656 aria-label={field.label}
657 placeholder={
658 field.isSecret
659 ? "Paste the secret here"
660 : "Set a value"
661 }
662 class="admin-int-input"
663 autocomplete="off"
664 spellcheck={false}
665 />
666 {field.isSecret && configured && (
667 <button
668 type="button"
669 class="admin-int-mini-btn"
670 data-int-reveal={field.key}
671 title="Reveal the stored value (audit-logged)"
672 >
673 Reveal
674 </button>
675 )}
676 {field.mintable && (
677 <button
678 type="button"
679 class="admin-int-mini-btn"
680 data-int-generate={field.key}
681 title="Generate a fresh random value server-side, store it, and show it once (audit-logged). This IS rotation for a platform-minted secret — paste the new value at the other end."
682 >
683 {configured ? "Rotate" : "Generate"}
684 </button>
685 )}
686 {configured && (
687 <button
688 type="button"
689 class="admin-int-mini-btn is-danger"
690 data-int-clear={field.key}
691 title="Delete the saved value (audit-logged). A value from the box's env file returns on next restart."
692 >
693 Clear
694 </button>
695 )}
696 </div>
629697 <div class="admin-int-hint">
630698 {field.helper}
631699 {field.helperLink && (
704772 document.body.removeChild(ta);
705773 }
706774 });
775
776 // Reveal / Rotate / Clear — the infra-secrets actions
777 // (2026-08-27). Each POSTs to its endpoint; reveal and
778 // generate write the plaintext into the field so the
779 // operator can copy it; clear reloads to show the honest
780 // "missing" state. Every action is audit-logged server-side.
781 function intAction(attr, path, then) {
782 document.querySelectorAll('[' + attr + ']').forEach(function (btn) {
783 btn.addEventListener('click', function () {
784 var key = btn.getAttribute(attr);
785 btn.disabled = true;
786 fetch(path, {
787 method: 'POST',
788 headers: { 'content-type': 'application/json' },
789 body: JSON.stringify({ key: key }),
790 })
791 .then(function (r) { return r.json(); })
792 .then(function (data) { then(key, data, btn); })
793 .catch(function () { btn.disabled = false; });
794 });
795 });
796 }
797 intAction('data-int-reveal', '/admin/integrations/reveal', function (key, data, btn) {
798 btn.disabled = false;
799 if (data && typeof data.value === 'string') {
800 var input = document.getElementById('int-' + key);
801 if (input) { input.value = data.value; input.focus(); }
802 btn.textContent = 'Revealed';
803 }
804 });
805 intAction('data-int-generate', '/admin/integrations/generate', function (key, data, btn) {
806 btn.disabled = false;
807 if (data && typeof data.value === 'string') {
808 var input = document.getElementById('int-' + key);
809 if (input) { input.value = data.value; input.focus(); input.select && input.select(); }
810 btn.textContent = 'Stored — copy now';
811 }
812 });
813 intAction('data-int-clear', '/admin/integrations/clear', function (key, data) {
814 if (data && data.ok) window.location.reload();
815 });
707816 })();
708817 `,
709818 }}
712821 );
713822});
714823
824/**
825 * The infra-secrets actions. All three are admin-gated by the same
826 * gate() as the page, take {key} (must be a declared INTEGRATION_FIELDS
827 * key — arbitrary env reads/writes are refused), and write an audit row.
828 *
829 * - reveal: returns the stored plaintext. Reveal-with-audit beats
830 * reveal-never: the operator OWNS these values and pasting
831 * them elsewhere (workflow secrets, peer boxes) is the job.
832 * - generate: mints a fresh 256-bit hex value server-side, stores it,
833 * returns it ONCE for the operator to paste at the other
834 * end. Only fields marked mintable — we cannot rotate a
835 * third party's key. This IS rotation for self-issued
836 * shared secrets.
837 * - clear: deletes the DB row and unsets the process value. A value
838 * from the box's env file returns on next restart — stated
839 * in the button's title so the operator isn't surprised.
840 */
841function fieldByKey(key: unknown) {
842 return INTEGRATION_FIELDS.find((f) => f.key === key) ?? null;
843}
844
845integrations.post("/admin/integrations/reveal", async (c) => {
846 const g = await gate(c);
847 if (g instanceof Response) return g;
848 const { user } = g;
849 const body = await c.req.json<{ key?: string }>().catch(() => ({} as { key?: string }));
850 const field = fieldByKey(body.key);
851 if (!field) return c.json({ error: "Unknown config key" }, 400);
852 const value = await getConfigValue(field.key, field.envFallback);
853 await audit({
854 userId: user.id,
855 action: "config.revealed",
856 targetType: "system_config",
857 targetId: field.key,
858 metadata: { key: field.key },
859 });
860 return c.json({ value });
861});
862
863integrations.post("/admin/integrations/generate", async (c) => {
864 const g = await gate(c);
865 if (g instanceof Response) return g;
866 const { user } = g;
867 const body = await c.req.json<{ key?: string }>().catch(() => ({} as { key?: string }));
868 const field = fieldByKey(body.key);
869 if (!field) return c.json({ error: "Unknown config key" }, 400);
870 if (!field.mintable) {
871 return c.json(
872 { error: "Not a platform-minted secret — paste the provider's value instead" },
873 400
874 );
875 }
876 const value = randomBytes(32).toString("hex");
877 await setConfigValue(field.key, value, user.id);
878 await audit({
879 userId: user.id,
880 action: "config.rotated",
881 targetType: "system_config",
882 targetId: field.key,
883 metadata: { key: field.key },
884 });
885 return c.json({ value });
886});
887
888integrations.post("/admin/integrations/clear", async (c) => {
889 const g = await gate(c);
890 if (g instanceof Response) return g;
891 const { user } = g;
892 const body = await c.req.json<{ key?: string }>().catch(() => ({} as { key?: string }));
893 const field = fieldByKey(body.key);
894 if (!field) return c.json({ error: "Unknown config key" }, 400);
895 await deleteConfigValue(field.key);
896 await audit({
897 userId: user.id,
898 action: "config.cleared",
899 targetType: "system_config",
900 targetId: field.key,
901 metadata: { key: field.key },
902 });
903 return c.json({ ok: true });
904});
905
715906integrations.post("/admin/integrations", async (c) => {
716907 const g = await gate(c);
717908 if (g instanceof Response) return g;
718909
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts