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

admin-ops.tsx

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

admin-ops.tsxBlame1574 lines · 2 contributors
9dd96b9Test User1/**
2 * Block R1 — `/admin/ops` site-admin operations console.
3 *
4 * Every operational lever the site admin used to pull from the terminal
5 * becomes a one-click form here:
6 *
7 * GET /admin/ops — render the ops page
8 * POST /admin/ops/auto-merge/enable — flip K2 auto-merge ON for ccantynz/main
9 * POST /admin/ops/auto-merge/disable — flip K2 auto-merge OFF for ccantynz/main
10 * POST /admin/ops/deploy/trigger — workflow_dispatch hetzner-deploy.yml (re-uses N4 internally)
11 * POST /admin/ops/rollback — workflow_dispatch with the previous-successful SHA
12 *
13 * Re-use, don't duplicate:
14 * - `runEnableAutoMerge` from `scripts/enable-auto-merge.ts` (N1) drives
15 * the auto-merge POSTs. We import its DI'd orchestrator + the real
16 * `audit` callback so tests can swap them.
17 * - `triggerRollback` from `src/lib/rollback-deploy.ts` (this block)
18 * drives the rollback POST. It mirrors N4's workflow_dispatch wire
19 * format and friendly-error mapping.
20 * - The deploy-trigger POST forwards to the existing N4 handler at
21 * `/admin/deploys/trigger` rather than re-implementing the GitHub API
22 * call. The page just calls it on the same Hono instance via a redirect.
23 *
24 * Readiness panel: we surface every check from
25 * `scripts/check-auto-merge-readiness.ts` so the operator can see what's
26 * blocking enablement before they hit the button.
27 *
28 * All POST handlers gate on `requireAuth` + `isSiteAdmin`, audit-log under
29 * `admin.ops.<action>`, and redirect back to `/admin/ops?success=<msg>` or
30 * `?error=<msg>`. CSRF protection is the same same-origin-or-token check
31 * the rest of the admin routes use.
32 */
33
34import { Hono } from "hono";
35import { and, desc, eq, sql } from "drizzle-orm";
36import { db } from "../db";
0ec4eceTest User37import { apiTokens, branchProtection, repositories, users } from "../db/schema";
9dd96b9Test User38import { platformDeploys } from "../db/schema-deploys";
39import { Layout } from "../views/layout";
40import { softAuth } from "../middleware/auth";
41import type { AuthEnv } from "../middleware/auth";
42import { isSiteAdmin } from "../lib/admin";
43import { audit as realAudit } from "../lib/notify";
0ec4eceTest User44import { config } from "../lib/config";
9dd96b9Test User45import {
46 runEnableAutoMerge as realRunEnableAutoMerge,
47 type DbLike,
48 type EnableAutoMergeArgs,
49 type EnableAutoMergeResult,
50} from "../../scripts/enable-auto-merge";
51import {
52 checkAnthropicKey,
53 checkAutopilotEnabled,
54 checkAutoMergeSweepRegistered,
55 checkMigration0040,
56 type CheckResult,
57} from "../../scripts/check-auto-merge-readiness";
58import {
59 findPreviousSuccessfulDeploy as realFindPrev,
60 triggerRollback as realTriggerRollback,
61 type PreviousDeploy,
62 type TriggerRollbackResult,
63} from "../lib/rollback-deploy";
64import { relativeTime, shortSha } from "./admin-deploys-page";
65
66// ---------------------------------------------------------------------------
67// DI hooks — every external collaborator is swappable so tests can drive
68// the handlers without spinning up Neon, GitHub, or the autopilot module.
69// ---------------------------------------------------------------------------
70
71type AuditFn = typeof realAudit;
72type RunEnableAutoMergeFn = (
73 db: DbLike,
74 args: EnableAutoMergeArgs,
75 audit: AuditFn
76) => Promise<EnableAutoMergeResult>;
77type FindPrevFn = typeof realFindPrev;
78type TriggerRollbackFn = typeof realTriggerRollback;
79
80interface OpsDeps {
81 runEnableAutoMerge: RunEnableAutoMergeFn;
82 findPreviousSuccessfulDeploy: FindPrevFn;
83 triggerRollback: TriggerRollbackFn;
84 audit: AuditFn;
85}
86
87const REAL_DEPS: OpsDeps = {
88 runEnableAutoMerge: realRunEnableAutoMerge,
89 findPreviousSuccessfulDeploy: realFindPrev,
90 triggerRollback: realTriggerRollback,
91 audit: realAudit,
92};
93
94let _deps: OpsDeps = REAL_DEPS;
95
96/** Test-only: replace one or more collaborators. Pass `null` to reset. */
97export function __setOpsDepsForTests(d: Partial<OpsDeps> | null): void {
98 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
99}
100
101// The repo + pattern we operate on. `/admin/ops` is a site-admin tool for
5c7fbd2Claude102// the platform's own repo. Env-overridable (SELF_HOST_REPO) because the
103// canonical name varies per deployment — on the main site it's
104// "ccantynz-alt/Gluecron.com" (the actual user who signed up). The CLI
105// (N1) still works for any other repo.
106const OPS_REPO = process.env.SELF_HOST_REPO || "ccantynz-alt/Gluecron.com";
9dd96b9Test User107const OPS_PATTERN = "main";
108
109// ---------------------------------------------------------------------------
110// Status panel — every read wrapped in try/catch so a single missing row
111// doesn't 500 the entire page.
112// ---------------------------------------------------------------------------
113
114interface AutoMergeState {
115 enabled: boolean;
116 exists: boolean;
117}
118
119async function readAutoMergeState(): Promise<AutoMergeState> {
120 try {
121 // Resolve owner/name from the constant. Owner is `ccantynz` and the
122 // repo name is `Gluecron.com`.
123 const [owner, repoName] = OPS_REPO.split("/");
124 if (!owner || !repoName) return { enabled: false, exists: false };
125 const [ownerRow] = await db
126 .select({ id: users.id })
127 .from(users)
128 .where(eq(users.username, owner))
129 .limit(1);
130 if (!ownerRow) return { enabled: false, exists: false };
131 const [repoRow] = await db
132 .select({ id: repositories.id })
133 .from(repositories)
134 .where(
135 and(
136 eq(repositories.ownerId, ownerRow.id),
137 eq(repositories.name, repoName)
138 )
139 )
140 .limit(1);
141 if (!repoRow) return { enabled: false, exists: false };
142 const [bp] = await db
143 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
144 .from(branchProtection)
145 .where(
146 and(
147 eq(branchProtection.repositoryId, repoRow.id),
148 eq(branchProtection.pattern, OPS_PATTERN)
149 )
150 )
151 .limit(1);
152 if (!bp) return { enabled: false, exists: false };
153 return { enabled: !!bp.enableAutoMerge, exists: true };
154 } catch (err) {
155 console.error("[admin-ops] readAutoMergeState:", err);
156 return { enabled: false, exists: false };
157 }
158}
159
160interface LatestDeploy {
161 sha: string;
162 status: string;
163 startedAt: Date;
164 finishedAt: Date | null;
165}
166
167async function readLatestDeploy(): Promise<LatestDeploy | null> {
168 try {
169 const [row] = await db
170 .select({
171 sha: platformDeploys.sha,
172 status: platformDeploys.status,
173 startedAt: platformDeploys.startedAt,
174 finishedAt: platformDeploys.finishedAt,
175 })
176 .from(platformDeploys)
177 .orderBy(desc(platformDeploys.startedAt))
178 .limit(1);
179 return row ?? null;
180 } catch (err) {
181 console.error("[admin-ops] readLatestDeploy:", err);
182 return null;
183 }
184}
185
186async function readReadinessChecks(): Promise<CheckResult[]> {
187 const out: CheckResult[] = [];
188 // 1. Migration probe
189 try {
190 out.push(
191 await checkMigration0040(async () => {
192 try {
193 const rows = await db.execute(
194 sql`SELECT column_name FROM information_schema.columns
195 WHERE table_name = 'branch_protection'
196 AND column_name = 'enable_auto_merge'
197 LIMIT 1`
198 );
199 const list =
200 (rows as any).rows ?? (Array.isArray(rows) ? rows : []);
201 return { exists: list.length > 0 };
202 } catch (err) {
203 return {
204 exists: false,
205 error: err instanceof Error ? err.message : String(err),
206 };
207 }
208 })
209 );
210 } catch {
211 out.push({
212 name: "Migration 0040 applied",
213 status: "fail",
214 reason: "check threw",
215 });
216 }
217 // 2 + 3. env-driven checks
218 out.push(checkAnthropicKey(process.env));
219 out.push(checkAutopilotEnabled(process.env));
220 // 4. autopilot sweep registration — best effort
221 try {
222 const mod = await import("../lib/autopilot");
223 out.push(checkAutoMergeSweepRegistered(mod.defaultTasks()));
224 } catch {
225 out.push({
226 name: "K3 auto-merge-sweep task registered",
227 status: "fail",
228 reason: "autopilot module failed to load",
229 });
230 }
231 return out;
232}
233
234// ---------------------------------------------------------------------------
235// Render helpers
236// ---------------------------------------------------------------------------
237
e2ae06aClaude238/* ─────────────────────────────────────────────────────────────────────────
239 * Scoped CSS — every class prefixed `.ops-` so this surface can't bleed
240 * into other admin pages. Mirrors the gradient-hairline hero + card
241 * patterns from admin-integrations.tsx and error-page.tsx.
242 * ───────────────────────────────────────────────────────────────────── */
243const opsStyles = `
eed4684Claude244 .ops-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e2ae06aClaude245
246 /* ─── Hero ─── */
247 .ops-hero {
248 position: relative;
249 margin-bottom: var(--space-5);
250 padding: var(--space-5) var(--space-6);
251 background: var(--bg-elevated);
252 border: 1px solid var(--border);
253 border-radius: 16px;
254 overflow: hidden;
255 }
256 .ops-hero::before {
257 content: '';
258 position: absolute;
259 top: 0; left: 0; right: 0;
260 height: 2px;
261 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
262 opacity: 0.7;
263 pointer-events: none;
264 }
265 .ops-hero-orb {
266 position: absolute;
267 inset: -20% -10% auto auto;
268 width: 380px; height: 380px;
269 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
270 filter: blur(80px);
271 opacity: 0.7;
272 pointer-events: none;
273 z-index: 0;
274 }
275 .ops-hero-inner {
276 position: relative;
277 z-index: 1;
278 display: flex;
279 align-items: flex-end;
280 justify-content: space-between;
281 gap: var(--space-4);
282 flex-wrap: wrap;
283 }
284 .ops-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
285 .ops-eyebrow {
286 font-size: 12px;
287 color: var(--text-muted);
288 margin-bottom: var(--space-2);
289 letter-spacing: 0.02em;
290 display: inline-flex;
291 align-items: center;
292 gap: 8px;
293 }
294 .ops-eyebrow .ops-eyebrow-pill {
295 display: inline-flex;
296 align-items: center;
297 justify-content: center;
298 width: 18px; height: 18px;
299 border-radius: 6px;
300 background: rgba(140,109,255,0.14);
301 color: #b69dff;
302 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
303 }
304 .ops-eyebrow .ops-who { color: var(--accent); font-weight: 600; }
305 .ops-title {
306 font-size: clamp(28px, 4vw, 40px);
307 font-family: var(--font-display);
308 font-weight: 800;
309 letter-spacing: -0.028em;
310 line-height: 1.05;
311 margin: 0 0 var(--space-2);
312 color: var(--text-strong);
313 }
314 .ops-title-grad {
315 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
316 -webkit-background-clip: text;
317 background-clip: text;
318 -webkit-text-fill-color: transparent;
319 color: transparent;
320 }
321 .ops-sub {
322 font-size: 15px;
323 color: var(--text-muted);
324 margin: 0;
325 line-height: 1.5;
326 max-width: 620px;
327 }
328 .ops-sub code {
329 font-family: var(--font-mono);
330 font-size: 13px;
331 background: var(--bg-tertiary);
332 padding: 1px 5px;
333 border-radius: 4px;
334 }
335 .ops-hero-back {
336 display: inline-flex;
337 align-items: center;
338 gap: 6px;
339 padding: 7px 12px;
340 font-size: 12.5px;
341 color: var(--text-muted);
342 background: rgba(255,255,255,0.02);
343 border: 1px solid var(--border);
344 border-radius: 8px;
345 text-decoration: none;
346 font-weight: 500;
347 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
348 }
349 .ops-hero-back:hover {
350 border-color: var(--border-strong);
351 color: var(--text-strong);
352 background: rgba(255,255,255,0.04);
353 text-decoration: none;
354 }
355
356 /* ─── Banners ─── */
357 .ops-banner {
358 margin-bottom: var(--space-4);
359 padding: 10px 14px;
360 border-radius: 10px;
361 font-size: 13.5px;
362 border: 1px solid var(--border);
363 background: rgba(255,255,255,0.025);
364 color: var(--text);
365 display: flex;
366 align-items: center;
367 gap: 10px;
368 }
369 .ops-banner.is-ok {
370 border-color: rgba(52,211,153,0.40);
371 background: rgba(52,211,153,0.08);
372 color: #bbf7d0;
373 }
374 .ops-banner.is-error {
375 border-color: rgba(248,113,113,0.40);
376 background: rgba(248,113,113,0.08);
377 color: #fecaca;
378 }
379 .ops-banner-dot {
380 width: 8px; height: 8px;
381 border-radius: 9999px;
382 background: currentColor;
383 flex-shrink: 0;
384 }
385
386 /* ─── Section cards ─── */
387 .ops-section {
388 margin-bottom: var(--space-5);
389 background: var(--bg-elevated);
390 border: 1px solid var(--border);
391 border-radius: 14px;
392 overflow: hidden;
393 }
394 .ops-section-head {
395 padding: var(--space-4) var(--space-5);
396 border-bottom: 1px solid var(--border);
397 display: flex;
398 align-items: flex-start;
399 justify-content: space-between;
400 gap: var(--space-3);
401 flex-wrap: wrap;
402 }
403 .ops-section-head-text { flex: 1; min-width: 240px; }
404 .ops-section-title {
405 margin: 0;
406 font-family: var(--font-display);
407 font-size: 17px;
408 font-weight: 700;
409 letter-spacing: -0.018em;
410 color: var(--text-strong);
411 display: flex;
412 align-items: center;
413 gap: 10px;
414 }
415 .ops-section-title-icon {
416 display: inline-flex;
417 align-items: center;
418 justify-content: center;
419 width: 26px; height: 26px;
420 border-radius: 8px;
421 background: rgba(140,109,255,0.12);
422 color: #b69dff;
423 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
424 flex-shrink: 0;
425 }
426 .ops-section-sub {
427 margin: 6px 0 0 36px;
428 font-size: 12.5px;
429 color: var(--text-muted);
430 line-height: 1.45;
431 }
432 .ops-section-body { padding: var(--space-4) var(--space-5); }
433
434 /* ─── Auto-merge toggle pill ─── */
435 .ops-toggle-row {
436 display: flex;
437 align-items: center;
438 justify-content: space-between;
439 gap: var(--space-3);
440 flex-wrap: wrap;
441 margin-bottom: var(--space-4);
442 }
443 .ops-toggle-form { margin: 0; }
444 .ops-toggle {
445 display: inline-flex;
446 align-items: center;
447 gap: 10px;
448 padding: 8px 18px 8px 12px;
449 border-radius: 9999px;
450 border: 1px solid var(--border-strong);
451 background: rgba(255,255,255,0.025);
452 color: var(--text);
453 font: inherit;
454 font-size: 13px;
455 font-weight: 600;
456 cursor: pointer;
457 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
458 }
459 .ops-toggle:hover { transform: translateY(-1px); border-color: rgba(140,109,255,0.45); }
460 .ops-toggle:disabled { cursor: not-allowed; opacity: 0.5; transform: none; }
461 .ops-toggle-dot {
462 width: 10px; height: 10px;
463 border-radius: 9999px;
464 background: #6b7280;
465 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
466 }
467 .ops-toggle.is-on {
468 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
469 color: #062b1f;
470 border-color: rgba(52,211,153,0.55);
471 box-shadow: 0 6px 18px -6px rgba(52,211,153,0.45);
472 }
473 .ops-toggle.is-on .ops-toggle-dot {
474 background: #062b1f;
475 box-shadow: 0 0 0 3px rgba(6,43,31,0.20);
476 }
477 .ops-toggle.is-danger {
478 background: rgba(248,113,113,0.10);
479 border-color: rgba(248,113,113,0.40);
480 color: #fecaca;
481 }
482 .ops-toggle.is-danger .ops-toggle-dot { background: #f87171; box-shadow: 0 0 0 3px rgba(248,113,113,0.25); }
483 .ops-toggle-status {
484 display: flex;
485 align-items: center;
486 gap: 10px;
487 font-size: 13px;
488 color: var(--text-muted);
489 flex-wrap: wrap;
490 }
491 .ops-toggle-status .ops-target {
492 font-family: var(--font-mono);
493 font-size: 12px;
494 color: var(--text);
495 background: rgba(255,255,255,0.04);
496 border: 1px solid var(--border);
497 padding: 2px 8px;
498 border-radius: 6px;
499 }
500
501 .ops-blurb {
502 font-size: 12.5px;
503 color: var(--text-muted);
504 line-height: 1.55;
505 margin: 0;
506 }
507
508 /* ─── Readiness traffic lights ─── */
509 .ops-readiness {
510 list-style: none;
511 padding: 0;
512 margin: 0;
513 display: flex;
514 flex-direction: column;
515 gap: 2px;
516 border-top: 1px solid var(--border);
517 }
518 .ops-readiness li {
519 display: flex;
520 align-items: flex-start;
521 gap: 12px;
522 padding: 10px 2px;
523 border-bottom: 1px solid var(--border);
524 font-size: 13px;
525 }
526 .ops-readiness li:last-child { border-bottom: none; }
527 .ops-light {
528 flex-shrink: 0;
529 margin-top: 5px;
530 width: 10px; height: 10px;
531 border-radius: 9999px;
532 background: #6b7280;
533 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
534 }
535 .ops-light.is-pass {
536 background: #34d399;
537 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
538 }
539 .ops-light.is-warn {
540 background: #fbbf24;
541 box-shadow: 0 0 0 3px rgba(251,191,36,0.22), 0 0 8px rgba(251,191,36,0.40);
542 }
543 .ops-light.is-fail {
544 background: #f87171;
545 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
546 animation: opsPulse 1.8s ease-in-out infinite;
547 }
548 @keyframes opsPulse {
549 0%, 100% { opacity: 1; transform: scale(1); }
550 50% { opacity: 0.7; transform: scale(0.92); }
551 }
552 @media (prefers-reduced-motion: reduce) {
553 .ops-light.is-fail { animation: none; }
554 }
555 .ops-readiness-text { flex: 1; min-width: 0; }
556 .ops-readiness-name {
557 font-family: var(--font-mono);
558 font-size: 12.5px;
559 font-weight: 600;
560 color: var(--text-strong);
561 word-break: break-word;
562 }
563 .ops-readiness-detail {
564 margin-top: 2px;
565 font-size: 12px;
566 color: var(--text-muted);
567 line-height: 1.45;
568 }
569 .ops-readiness-summary {
570 display: inline-flex;
571 align-items: center;
572 gap: 8px;
573 padding: 4px 10px;
574 border-radius: 9999px;
575 font-size: 11.5px;
576 font-weight: 600;
577 letter-spacing: 0.04em;
578 text-transform: uppercase;
579 flex-shrink: 0;
580 }
581 .ops-readiness-summary.is-ok {
582 background: rgba(52,211,153,0.14);
583 color: #6ee7b7;
584 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
585 }
586 .ops-readiness-summary.is-fail {
587 background: rgba(248,113,113,0.12);
588 color: #fecaca;
589 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
590 }
591 .ops-readiness-summary .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
592
593 /* ─── Pill (deploy status) ─── */
594 .ops-pill {
595 display: inline-flex;
596 align-items: center;
597 gap: 6px;
598 padding: 2px 10px;
599 border-radius: 9999px;
600 font-size: 11.5px;
601 font-weight: 600;
602 }
603 .ops-pill.is-ok { background: rgba(52,211,153,0.16); color: #6ee7b7; }
604 .ops-pill.is-bad { background: rgba(248,113,113,0.16); color: #fca5a5; }
605 .ops-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
606
607 /* ─── Deploy meta strip ─── */
608 .ops-meta-strip {
609 display: flex;
610 align-items: center;
611 gap: 10px;
612 font-size: 13px;
613 color: var(--text-muted);
614 margin-bottom: var(--space-3);
615 flex-wrap: wrap;
616 }
617 .ops-meta-strip code {
618 font-family: var(--font-mono);
619 font-size: 12px;
620 background: rgba(255,255,255,0.04);
621 border: 1px solid var(--border);
622 padding: 2px 7px;
623 border-radius: 6px;
624 color: var(--text);
625 }
626
627 /* ─── GateTest credentials block ─── */
628 .ops-creds {
629 display: grid;
630 grid-template-columns: 180px 1fr;
631 gap: 8px 14px;
632 margin-bottom: var(--space-3);
633 align-items: center;
634 }
635 @media (max-width: 600px) {
636 .ops-creds { grid-template-columns: 1fr; gap: 4px 0; }
637 .ops-creds-key { margin-top: var(--space-2); }
638 }
639 .ops-creds-key {
640 font-family: var(--font-mono);
641 font-size: 12.5px;
642 font-weight: 600;
643 color: var(--text-strong);
644 letter-spacing: -0.005em;
645 }
646 .ops-creds-val {
647 display: flex;
648 align-items: center;
649 gap: 8px;
650 min-width: 0;
651 }
652 .ops-creds-val code {
653 flex: 1;
654 min-width: 0;
655 font-family: var(--font-mono);
656 font-size: 12.5px;
657 color: var(--text);
658 background: rgba(255,255,255,0.04);
659 border: 1px solid var(--border);
660 padding: 6px 10px;
661 border-radius: 8px;
662 word-break: break-all;
663 overflow-wrap: anywhere;
664 }
665 .ops-creds-val code.is-muted { color: var(--text-muted); font-style: italic; }
666 .ops-creds-val code.is-fresh {
667 color: #e9d5ff;
668 background: linear-gradient(135deg, rgba(140,109,255,0.12), rgba(54,197,214,0.08));
669 border-color: rgba(140,109,255,0.40);
670 }
671 .ops-copy {
672 display: inline-flex;
673 align-items: center;
674 justify-content: center;
675 width: 32px; height: 32px;
676 flex-shrink: 0;
677 border-radius: 8px;
678 background: rgba(255,255,255,0.04);
679 border: 1px solid var(--border);
680 color: var(--text-muted);
681 cursor: pointer;
682 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
683 font: inherit;
684 }
685 .ops-copy:hover {
686 background: rgba(140,109,255,0.10);
687 border-color: rgba(140,109,255,0.40);
688 color: var(--text-strong);
689 }
690 .ops-copy.is-copied {
691 background: rgba(52,211,153,0.14);
692 border-color: rgba(52,211,153,0.45);
693 color: #6ee7b7;
694 }
695 .ops-copy svg { display: block; }
696
697 .ops-fresh-token-warn {
698 display: flex;
699 align-items: flex-start;
700 gap: 8px;
701 margin: 0 0 var(--space-3);
702 padding: 10px 12px;
703 font-size: 12.5px;
704 color: #fde68a;
705 background: rgba(251,191,36,0.08);
706 border: 1px solid rgba(251,191,36,0.35);
707 border-radius: 10px;
708 line-height: 1.45;
709 }
710 .ops-fresh-token-warn::before {
711 content: '!';
712 flex-shrink: 0;
713 display: inline-flex;
714 align-items: center;
715 justify-content: center;
716 width: 18px; height: 18px;
717 border-radius: 9999px;
718 background: rgba(251,191,36,0.20);
719 color: #fbbf24;
720 font-weight: 700;
721 font-size: 11px;
722 font-family: var(--font-display);
723 }
724
725 /* ─── Buttons ─── */
726 .ops-btn {
727 display: inline-flex;
728 align-items: center;
729 gap: 6px;
730 padding: 9px 16px;
731 border-radius: 10px;
732 font-size: 13px;
733 font-weight: 600;
734 text-decoration: none;
735 border: 1px solid transparent;
736 cursor: pointer;
737 font: inherit;
738 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
739 line-height: 1;
740 }
741 .ops-btn-primary {
742 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
743 color: #ffffff;
744 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
745 }
746 .ops-btn-primary:hover:not(:disabled) {
747 transform: translateY(-1px);
748 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
749 }
750 .ops-btn-primary:disabled {
751 cursor: not-allowed;
752 opacity: 0.5;
753 box-shadow: none;
754 transform: none;
755 }
756 .ops-btn-ghost {
757 background: transparent;
758 color: var(--text);
759 border-color: var(--border-strong);
760 }
761 .ops-btn-ghost:hover:not(:disabled) {
762 background: rgba(140,109,255,0.06);
763 border-color: rgba(140,109,255,0.45);
764 color: var(--text-strong);
765 }
766 .ops-btn-danger {
767 background: transparent;
768 color: #fca5a5;
769 border-color: rgba(248,113,113,0.35);
770 }
771 .ops-btn-danger:hover:not(:disabled) {
772 border-style: dashed;
773 border-color: rgba(248,113,113,0.70);
774 background: rgba(248,113,113,0.06);
775 color: #fecaca;
776 }
777 .ops-btn-danger:disabled {
778 cursor: not-allowed;
779 opacity: 0.5;
780 color: var(--text-muted);
781 border-color: var(--border);
782 }
783
784 .ops-actions {
785 display: flex;
786 align-items: center;
787 gap: var(--space-3);
788 flex-wrap: wrap;
789 }
790 .ops-actions form { margin: 0; }
791 .ops-action-hint {
792 font-size: 12px;
793 color: var(--text-muted);
794 line-height: 1.5;
795 max-width: 460px;
796 }
797
798 /* ─── 403 fallback ─── */
799 .ops-403 {
800 max-width: 540px;
801 margin: var(--space-12) auto;
802 padding: var(--space-6);
803 text-align: center;
804 background: var(--bg-elevated);
805 border: 1px solid var(--border);
806 border-radius: 16px;
807 }
808 .ops-403 h2 {
809 font-family: var(--font-display);
810 font-size: 22px;
811 margin: 0 0 8px;
812 color: var(--text-strong);
813 }
814 .ops-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
815`;
816
817/* Inline copy-to-clipboard helper. Looks for any `[data-ops-copy]` button,
818 * reads the linked element's textContent, and pulses a `.is-copied` class
819 * for ~1.6s. Works even in non-secure contexts via a hidden-textarea fallback. */
820const opsCopyScript = `
821 (function(){
822 function copyText(text, btn){
823 var done = function(){
824 btn.classList.add('is-copied');
825 var prev = btn.getAttribute('aria-label') || 'Copy';
826 btn.setAttribute('aria-label', 'Copied');
827 setTimeout(function(){
828 btn.classList.remove('is-copied');
829 btn.setAttribute('aria-label', prev);
830 }, 1600);
831 };
832 function fallback(){
833 var ta = document.createElement('textarea');
834 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
835 document.body.appendChild(ta); ta.select();
836 try { document.execCommand('copy'); done(); } catch(e){}
837 document.body.removeChild(ta);
838 }
839 if (navigator.clipboard && navigator.clipboard.writeText) {
840 navigator.clipboard.writeText(text).then(done).catch(fallback);
841 } else { fallback(); }
842 }
843 document.addEventListener('click', function(ev){
844 var t = ev.target;
845 while (t && t !== document.body && !(t.getAttribute && t.getAttribute('data-ops-copy') !== null)) {
846 t = t.parentNode;
847 }
848 if (!t || t === document.body) return;
849 var sel = t.getAttribute('data-ops-copy');
850 var src = sel ? document.querySelector(sel) : t.previousElementSibling;
851 if (!src) return;
852 copyText((src.textContent || '').trim(), t);
853 });
854 })();
855`;
856
857/* Small inline-SVG icons. */
858function IconShield() {
9dd96b9Test User859 return (
e2ae06aClaude860 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
861 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
862 </svg>
9dd96b9Test User863 );
864}
e2ae06aClaude865function IconBolt() {
866 return (
867 <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">
868 <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
869 </svg>
870 );
871}
872function IconCheck() {
873 return (
874 <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">
875 <polyline points="20 6 9 17 4 12" />
876 </svg>
877 );
878}
879function IconRocket() {
880 return (
881 <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">
882 <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
883 <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
884 <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
885 <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
886 </svg>
887 );
888}
889function IconKey() {
890 return (
891 <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">
892 <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" />
893 </svg>
894 );
895}
896function IconRollback() {
897 return (
898 <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">
899 <polyline points="1 4 1 10 7 10" />
900 <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
901 </svg>
902 );
903}
904function IconCopy() {
9dd96b9Test User905 return (
e2ae06aClaude906 <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">
907 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
908 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
909 </svg>
9dd96b9Test User910 );
911}
e2ae06aClaude912function IconArrowLeft() {
913 return (
914 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
915 <line x1="19" y1="12" x2="5" y2="12" />
916 <polyline points="12 19 5 12 12 5" />
917 </svg>
918 );
919}
920
921/** Map a CheckResult.status into a traffic-light class. */
922function readinessClass(status: string): "is-pass" | "is-warn" | "is-fail" {
923 if (status === "pass") return "is-pass";
924 if (status === "fail") return "is-fail";
925 return "is-warn";
926}
9dd96b9Test User927
928// ---------------------------------------------------------------------------
929// Gating
930// ---------------------------------------------------------------------------
931
932const ops = new Hono<AuthEnv>();
933ops.use("*", softAuth);
934
935async function gate(c: any): Promise<{ user: any } | Response> {
936 const user = c.get("user");
937 if (!user) return c.redirect("/login?next=/admin/ops");
938 if (!(await isSiteAdmin(user.id))) {
939 return c.html(
940 <Layout title="Forbidden" user={user}>
e2ae06aClaude941 <div class="ops-403">
9dd96b9Test User942 <h2>403 — Not a site admin</h2>
943 <p>You don't have permission to view this page.</p>
944 </div>
e2ae06aClaude945 <style dangerouslySetInnerHTML={{ __html: opsStyles }} />
9dd96b9Test User946 </Layout>,
947 403
948 );
949 }
950 return { user };
951}
952
953function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
954 return c.redirect(`/admin/ops?${kind}=${encodeURIComponent(msg)}`);
955}
956
957// ---------------------------------------------------------------------------
958// GET /admin/ops
959// ---------------------------------------------------------------------------
960
961ops.get("/admin/ops", async (c) => {
962 const g = await gate(c);
963 if (g instanceof Response) return g;
964 const { user } = g;
965
966 // Surface flash messages from prior POSTs.
967 const success = c.req.query("success");
968 const error = c.req.query("error");
969
970 // Pull every status read in parallel — a slow one shouldn't block the rest.
971 const [autoMergeState, readiness, latest, previous] = await Promise.all([
972 readAutoMergeState(),
973 readReadinessChecks(),
974 readLatestDeploy(),
975 _deps.findPreviousSuccessfulDeploy().catch(() => null),
976 ]);
977
978 const readinessAllGreen = readiness.every((r) => r.status === "pass");
e2ae06aClaude979 const readinessFailCount = readiness.filter((r) => r.status !== "pass").length;
980 const gatetestToken = c.req.query("gatetest_token");
9dd96b9Test User981
982 return c.html(
983 <Layout title="Operations — admin" user={user}>
e2ae06aClaude984 <div class="ops-wrap">
985 {/* ─── Hero ─── */}
986 <section class="ops-hero">
987 <div class="ops-hero-orb" aria-hidden="true" />
988 <div class="ops-hero-inner">
989 <div class="ops-hero-text">
990 <div class="ops-eyebrow">
991 <span class="ops-eyebrow-pill" aria-hidden="true">
992 <IconShield />
993 </span>
994 Operations · Site admin · <span class="ops-who">{user.username}</span>
995 </div>
996 <h1 class="ops-title">
997 <span class="ops-title-grad">Run the platform.</span>
998 </h1>
999 <p class="ops-sub">
1000 Every operational lever that used to live in a terminal — auto-merge,
1001 deploys, scanner credentials, rollback. Every action is audit-logged
1002 under <code>admin.ops.*</code>.
1003 </p>
1004 </div>
1005 <a href="/admin" class="ops-hero-back">
1006 <IconArrowLeft />
1007 Back to admin
1008 </a>
1009 </div>
1010 </section>
9dd96b9Test User1011
1012 {success && (
e2ae06aClaude1013 <div class="ops-banner is-ok" role="status">
1014 <span class="ops-banner-dot" aria-hidden="true" />
9dd96b9Test User1015 {decodeURIComponent(success)}
1016 </div>
1017 )}
1018 {error && (
e2ae06aClaude1019 <div class="ops-banner is-error" role="alert">
1020 <span class="ops-banner-dot" aria-hidden="true" />
9dd96b9Test User1021 {decodeURIComponent(error)}
1022 </div>
1023 )}
1024
e2ae06aClaude1025 {/* ─── Auto-merge section ─── */}
1026 <section class="ops-section">
1027 <header class="ops-section-head">
1028 <div class="ops-section-head-text">
1029 <h3 class="ops-section-title">
1030 <span class="ops-section-title-icon" aria-hidden="true">
1031 <IconBolt />
1032 </span>
1033 AI auto-merge on main
1034 </h3>
1035 <p class="ops-section-sub">
1036 When enabled, every PR Claude opens that passes gates auto-merges
1037 within ~30s and deploys ~25s later — under a minute end-to-end.
1038 </p>
1039 </div>
1040 </header>
1041 <div class="ops-section-body">
1042 <div class="ops-toggle-row">
1043 {autoMergeState.enabled ? (
1044 <form
1045 method="post"
1046 action="/admin/ops/auto-merge/disable"
1047 class="ops-toggle-form"
1048 >
1049 <button
1050 type="submit"
1051 class="ops-toggle is-on"
1052 aria-label="Disable AI auto-merge"
1053 title="Click to disable"
1054 >
1055 <span class="ops-toggle-dot" aria-hidden="true" />
1056 Enabled · click to disable
1057 </button>
1058 </form>
1059 ) : (
1060 <form
1061 method="post"
1062 action="/admin/ops/auto-merge/enable"
1063 class="ops-toggle-form"
1064 >
1065 <button
1066 type="submit"
1067 class={readinessAllGreen ? "ops-toggle" : "ops-toggle is-danger"}
1068 disabled={!readinessAllGreen}
1069 aria-label="Enable AI auto-merge"
1070 title={
1071 readinessAllGreen
1072 ? "Enable AI auto-merge"
1073 : "Fix the readiness items first"
1074 }
1075 >
1076 <span class="ops-toggle-dot" aria-hidden="true" />
1077 {readinessAllGreen
1078 ? "Disabled · click to enable"
1079 : "Blocked · fix readiness first"}
1080 </button>
1081 </form>
1082 )}
1083 <div class="ops-toggle-status">
1084 <span class="ops-target">{OPS_REPO}@{OPS_PATTERN}</span>
1085 </div>
1086 </div>
9dd96b9Test User1087
e2ae06aClaude1088 <header
1089 style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:6px"
9dd96b9Test User1090 >
e2ae06aClaude1091 <div
1092 style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:600"
1093 >
1094 Readiness check
1095 </div>
1096 <span
1097 class={
1098 "ops-readiness-summary " +
1099 (readinessAllGreen ? "is-ok" : "is-fail")
1100 }
1101 >
1102 <span class="dot" aria-hidden="true" />
1103 {readinessAllGreen
1104 ? "all green"
1105 : `${readinessFailCount} blocking`}
1106 </span>
1107 </header>
1108 <ul class="ops-readiness" aria-label="Auto-merge readiness checks">
9dd96b9Test User1109 {readiness.map((r) => (
e2ae06aClaude1110 <li>
9dd96b9Test User1111 <span
e2ae06aClaude1112 class={"ops-light " + readinessClass(r.status)}
1113 aria-label={r.status}
1114 />
1115 <div class="ops-readiness-text">
1116 <div class="ops-readiness-name">{r.name}</div>
9dd96b9Test User1117 {r.reason && (
e2ae06aClaude1118 <div class="ops-readiness-detail">{r.reason}</div>
9dd96b9Test User1119 )}
e2ae06aClaude1120 </div>
9dd96b9Test User1121 </li>
1122 ))}
1123 </ul>
1124 </div>
e2ae06aClaude1125 </section>
9dd96b9Test User1126
e2ae06aClaude1127 {/* ─── Deploy section ─── */}
1128 <section class="ops-section">
1129 <header class="ops-section-head">
1130 <div class="ops-section-head-text">
1131 <h3 class="ops-section-title">
1132 <span class="ops-section-title-icon" aria-hidden="true">
1133 <IconRocket />
1134 </span>
1135 Deploy
1136 </h3>
1137 <p class="ops-section-sub">
1138 Fires hetzner-deploy.yml on main. Typical run: 25–90 seconds.
1139 </p>
1140 </div>
1141 </header>
1142 <div class="ops-section-body">
1143 <div class="ops-meta-strip">
1144 {latest ? (
1145 <>
1146 <span>Last deploy</span>
1147 <span
1148 class={
1149 "ops-pill " +
1150 (latest.status === "succeeded" ? "is-ok" : "is-bad")
1151 }
1152 >
1153 <span class="dot" aria-hidden="true" />
1154 {latest.status}
1155 </span>
1156 <code>{shortSha(latest.sha)}</code>
1157 <span title={latest.startedAt.toISOString()}>
1158 {relativeTime(latest.startedAt)}
1159 </span>
1160 </>
1161 ) : (
1162 <span>Last deploy: —</span>
1163 )}
1164 </div>
1165 <div class="ops-actions">
1166 <form method="post" action="/admin/ops/deploy/trigger">
1167 <button type="submit" class="ops-btn ops-btn-primary">
1168 <IconRocket />
1169 Trigger deploy now
9dd96b9Test User1170 </button>
1171 </form>
e2ae06aClaude1172 <a href="/admin/deploys" class="ops-btn ops-btn-ghost">
1173 Watch deploys
1174 </a>
1175 </div>
1176 </div>
1177 </section>
1178
1179 {/* ─── GateTest credentials section ─── */}
1180 <section class="ops-section">
1181 <header class="ops-section-head">
1182 <div class="ops-section-head-text">
1183 <h3 class="ops-section-title">
1184 <span class="ops-section-title-icon" aria-hidden="true">
1185 <IconKey />
1186 </span>
1187 GateTest scanner credentials
1188 </h3>
1189 <p class="ops-section-sub">
1190 Two values to paste into GateTest's environment so it can scan
1191 this site. Token is admin-scoped — revoke at{" "}
1192 <a href="/settings/tokens" style="color:var(--accent);text-decoration:none">/settings/tokens</a>{" "}
1193 when scanning is done.
1194 </p>
1195 </div>
1196 </header>
1197 <div class="ops-section-body">
1198 <div class="ops-creds">
1199 <div class="ops-creds-key">GLUECRON_BASE_URL</div>
1200 <div class="ops-creds-val">
1201 <code id="ops-creds-base-url">{config.appBaseUrl}</code>
1202 <button
1203 type="button"
1204 class="ops-copy"
1205 data-ops-copy="#ops-creds-base-url"
1206 aria-label="Copy GLUECRON_BASE_URL"
1207 title="Copy"
1208 >
1209 <IconCopy />
1210 </button>
1211 </div>
1212
1213 <div class="ops-creds-key">GLUECRON_API_TOKEN</div>
1214 <div class="ops-creds-val">
1215 {gatetestToken ? (
1216 <>
1217 <code id="ops-creds-token" class="is-fresh">{gatetestToken}</code>
1218 <button
1219 type="button"
1220 class="ops-copy"
1221 data-ops-copy="#ops-creds-token"
1222 aria-label="Copy GLUECRON_API_TOKEN"
1223 title="Copy"
1224 >
1225 <IconCopy />
1226 </button>
1227 </>
1228 ) : (
1229 <code class="is-muted">— click below to issue (shown once) —</code>
1230 )}
1231 </div>
1232 </div>
1233
1234 {gatetestToken && (
1235 <p class="ops-fresh-token-warn">
1236 <span>
1237 Copy the token now. It is hashed in the DB and will not be
1238 shown again.
1239 </span>
1240 </p>
1241 )}
1242
1243 <div class="ops-actions">
1244 <form method="post" action="/admin/ops/gatetest-token">
1245 <button type="submit" class="ops-btn ops-btn-primary">
1246 <IconKey />
1247 Issue scanner token
1248 </button>
1249 </form>
1250 <span class="ops-action-hint">
1251 Mints a fresh admin-scoped API token and reveals it once on the
1252 next page load.
1253 </span>
1254 </div>
1255 </div>
1256 </section>
1257
1258 {/* ─── Rollback section ─── */}
1259 <section class="ops-section">
1260 <header class="ops-section-head">
1261 <div class="ops-section-head-text">
1262 <h3 class="ops-section-title">
1263 <span class="ops-section-title-icon" aria-hidden="true">
1264 <IconRollback />
1265 </span>
1266 Rollback
1267 </h3>
1268 <p class="ops-section-sub">
1269 Resets main to the previous tagged release. Use if the latest
1270 deploy broke something.
1271 </p>
1272 </div>
1273 </header>
1274 <div class="ops-section-body">
1275 <div class="ops-meta-strip">
1276 {previous ? (
1277 <>
1278 <span>Previous successful deploy</span>
1279 <code>{shortSha(previous.sha)}</code>
1280 <span title={previous.finishedAt.toISOString()}>
1281 {relativeTime(previous.finishedAt)}
1282 </span>
1283 </>
1284 ) : (
1285 <span>Previous successful deploy: —</span>
1286 )}
1287 </div>
1288 <div class="ops-actions">
9dd96b9Test User1289 <form
1290 method="post"
e2ae06aClaude1291 action="/admin/ops/rollback"
1292 onsubmit="return confirm('Roll back main to the previous tagged release?')"
9dd96b9Test User1293 >
1294 <button
1295 type="submit"
e2ae06aClaude1296 class="ops-btn ops-btn-danger"
1297 disabled={!previous}
9dd96b9Test User1298 title={
e2ae06aClaude1299 previous
1300 ? `Rollback to ${shortSha(previous.sha)}`
1301 : "No prior successful deploy on file"
9dd96b9Test User1302 }
1303 >
e2ae06aClaude1304 <IconRollback />
1305 {previous ? `Rollback to ${shortSha(previous.sha)}` : "Rollback"}
9dd96b9Test User1306 </button>
1307 </form>
e2ae06aClaude1308 <span class="ops-action-hint">
1309 Confirms before dispatching. Watch{" "}
1310 <a href="/admin/deploys" style="color:var(--accent);text-decoration:none">/admin/deploys</a>{" "}
1311 for progress.
9dd96b9Test User1312 </span>
e2ae06aClaude1313 </div>
9dd96b9Test User1314 </div>
e2ae06aClaude1315 </section>
9dd96b9Test User1316 </div>
e2ae06aClaude1317 <style dangerouslySetInnerHTML={{ __html: opsStyles }} />
1318 <script dangerouslySetInnerHTML={{ __html: opsCopyScript }} />
9dd96b9Test User1319 </Layout>
1320 );
1321});
1322
1323// ---------------------------------------------------------------------------
1324// POST /admin/ops/auto-merge/{enable,disable}
1325// ---------------------------------------------------------------------------
1326
1327async function handleAutoMergeFlip(c: any, off: boolean): Promise<Response> {
1328 const g = await gate(c);
1329 if (g instanceof Response) return g;
1330 const { user } = g;
1331
1332 try {
1333 const result = await _deps.runEnableAutoMerge(
1334 db as unknown as DbLike,
1335 {
1336 ownerSlash: OPS_REPO,
1337 pattern: OPS_PATTERN,
1338 off,
1339 actorUserId: user.id,
1340 },
1341 _deps.audit
1342 );
1343 try {
1344 await _deps.audit({
1345 userId: user.id,
1346 action: off ? "admin.ops.auto_merge_disable" : "admin.ops.auto_merge_enable",
1347 targetType: "branch_protection",
1348 targetId: result.after?.id,
1349 metadata: {
1350 repo: OPS_REPO,
1351 pattern: OPS_PATTERN,
1352 scriptAction: result.action,
1353 },
1354 });
1355 } catch {
1356 // audit failure is non-fatal for the user-facing flow
1357 }
1358 const verb = off ? "disabled" : "enabled";
1359 const tail =
1360 result.action === "noop"
1361 ? `already ${verb}`
1362 : result.action === "inserted"
1363 ? `${verb} (new rule created)`
1364 : `${verb}`;
1365 return redirectWith(c, "success", `Auto-merge ${tail} on ${OPS_REPO}@${OPS_PATTERN}.`);
1366 } catch (err) {
1367 const message = err instanceof Error ? err.message : String(err);
1368 return redirectWith(
1369 c,
1370 "error",
1371 `Failed to ${off ? "disable" : "enable"} auto-merge: ${message}`
1372 );
1373 }
1374}
1375
1376ops.post("/admin/ops/auto-merge/enable", (c) => handleAutoMergeFlip(c, false));
1377ops.post("/admin/ops/auto-merge/disable", (c) => handleAutoMergeFlip(c, true));
1378
1379// ---------------------------------------------------------------------------
1380// POST /admin/ops/deploy/trigger
1381//
1382// Re-uses the N4 handler. We don't duplicate the GitHub API call — we
1383// simply invoke `/admin/deploys/trigger` on the same Hono `app.request`
1384// with the caller's session cookie forwarded so softAuth + isSiteAdmin
1385// pass cleanly on the inner call.
1386// ---------------------------------------------------------------------------
1387
1388ops.post("/admin/ops/deploy/trigger", async (c) => {
1389 const g = await gate(c);
1390 if (g instanceof Response) return g;
1391 const { user } = g;
1392
1393 try {
1394 // Fetch the live app instance lazily — avoids a circular import at module
1395 // load time.
1396 const { default: app } = await import("../app");
1397 const cookie = c.req.header("cookie") ?? "";
1398 const origin = c.req.header("origin") ?? "";
1399 const host = c.req.header("host") ?? "";
1400 const res = await app.request("/admin/deploys/trigger", {
1401 method: "POST",
1402 headers: {
1403 "content-type": "application/json",
1404 cookie,
1405 origin,
1406 host,
1407 },
1408 body: JSON.stringify({}),
1409 });
1410 if (res.ok) {
1411 try {
1412 await _deps.audit({
1413 userId: user.id,
1414 action: "admin.ops.deploy_triggered",
1415 targetType: "workflow",
1416 targetId: "hetzner-deploy.yml",
1417 metadata: { repo: OPS_REPO },
1418 });
1419 } catch {
1420 /* non-fatal */
1421 }
1422 return redirectWith(c, "success", "Deploy dispatched — watch /admin/deploys for progress.");
1423 }
1424 let raw = "";
1425 try {
1426 raw = await res.text();
1427 } catch {
1428 /* swallow */
1429 }
1430 let msg = `deploy trigger returned ${res.status}`;
1431 try {
1432 const j = JSON.parse(raw);
1433 if (j?.error) msg = String(j.error);
1434 } catch {
1435 if (raw) msg = raw.slice(0, 240);
1436 }
1437 return redirectWith(c, "error", msg);
1438 } catch (err) {
1439 const message = err instanceof Error ? err.message : String(err);
1440 return redirectWith(c, "error", `Deploy trigger failed: ${message}`);
1441 }
1442});
1443
1444// ---------------------------------------------------------------------------
1445// POST /admin/ops/rollback
1446// ---------------------------------------------------------------------------
1447
1448ops.post("/admin/ops/rollback", async (c) => {
1449 const g = await gate(c);
1450 if (g instanceof Response) return g;
1451 const { user } = g;
1452
1453 let prev: PreviousDeploy | null = null;
1454 try {
1455 prev = await _deps.findPreviousSuccessfulDeploy();
1456 } catch (err) {
1457 const message = err instanceof Error ? err.message : String(err);
1458 return redirectWith(c, "error", `Rollback lookup failed: ${message}`);
1459 }
1460 if (!prev) {
1461 return redirectWith(
1462 c,
1463 "error",
1464 "No previous successful deploy on file — nothing to roll back to."
1465 );
1466 }
1467
1468 let result: TriggerRollbackResult;
1469 try {
1470 result = await _deps.triggerRollback({
1471 targetSha: prev.sha,
1472 triggeredByUserId: user.id,
1473 });
1474 } catch (err) {
1475 const message = err instanceof Error ? err.message : String(err);
1476 return redirectWith(c, "error", `Rollback dispatch threw: ${message}`);
1477 }
1478
1479 if (!result.ok) {
1480 return redirectWith(c, "error", result.error || "Rollback dispatch failed.");
1481 }
1482
1483 try {
1484 await _deps.audit({
1485 userId: user.id,
1486 action: "admin.ops.rollback_dispatched",
1487 targetType: "workflow",
1488 targetId: "hetzner-deploy.yml",
1489 metadata: { repo: OPS_REPO, target_sha: prev.sha },
1490 });
1491 } catch {
1492 /* non-fatal */
1493 }
1494
1495 return redirectWith(
1496 c,
1497 "success",
1498 `Rollback dispatched to ${shortSha(prev.sha)} — watch /admin/deploys for progress.`
1499 );
1500});
1501
0ec4eceTest User1502// ---------------------------------------------------------------------------
1503// POST /admin/ops/gatetest-token
1504// ---------------------------------------------------------------------------
1505//
1506// Mint a fresh admin-scoped API token for the GateTest scanner and surface
1507// it once via the redirect query string. The DB stores only the SHA-256
1508// hash (same shape tokens.tsx uses) so the plaintext is unrecoverable after
1509// this redirect — operator must copy it immediately into GateTest's
1510// environment.
1511
1512function generateGateTestToken(): string {
1513 const bytes = crypto.getRandomValues(new Uint8Array(32));
1514 return (
1515 "glc_" +
1516 Array.from(bytes)
1517 .map((b) => b.toString(16).padStart(2, "0"))
1518 .join("")
1519 );
1520}
1521
1522async function sha256Hex(input: string): Promise<string> {
1523 const data = new TextEncoder().encode(input);
1524 const hash = await crypto.subtle.digest("SHA-256", data);
1525 return Array.from(new Uint8Array(hash))
1526 .map((b) => b.toString(16).padStart(2, "0"))
1527 .join("");
1528}
1529
1530ops.post("/admin/ops/gatetest-token", async (c) => {
1531 const g = await gate(c);
1532 if (g instanceof Response) return g;
1533 const { user } = g;
1534
1535 const token = generateGateTestToken();
1536 const tokenH = await sha256Hex(token);
1537 const stamp = new Date().toISOString().slice(0, 10);
1538
1539 try {
1540 await db.insert(apiTokens).values({
1541 userId: user.id,
1542 name: `GateTest scanner (${stamp})`,
1543 tokenHash: tokenH,
1544 tokenPrefix: token.slice(0, 12),
1545 scopes: "admin",
1546 });
1547 } catch (err) {
1548 const message = err instanceof Error ? err.message : String(err);
1549 return redirectWith(c, "error", `Token insert failed: ${message}`);
1550 }
1551
1552 try {
1553 await _deps.audit({
1554 userId: user.id,
1555 action: "admin.ops.gatetest_token_issued",
1556 targetType: "api_token",
1557 metadata: { scope: "admin", prefix: token.slice(0, 12) },
1558 });
1559 } catch {
1560 /* non-fatal */
1561 }
1562
1563 return c.redirect(`/admin/ops?gatetest_token=${encodeURIComponent(token)}`);
1564});
1565
9dd96b9Test User1566export const __test = {
1567 readAutoMergeState,
1568 readLatestDeploy,
1569 readReadinessChecks,
1570 OPS_REPO,
1571 OPS_PATTERN,
1572};
1573
1574export default ops;