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.tsxBlame811 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
102// the platform's own repo. If the operator needs to flip auto-merge on a
103// different repo they still have the CLI (N1).
104const OPS_REPO = "ccantynz/Gluecron.com";
105const OPS_PATTERN = "main";
106
107// ---------------------------------------------------------------------------
108// Status panel — every read wrapped in try/catch so a single missing row
109// doesn't 500 the entire page.
110// ---------------------------------------------------------------------------
111
112interface AutoMergeState {
113 enabled: boolean;
114 exists: boolean;
115}
116
117async function readAutoMergeState(): Promise<AutoMergeState> {
118 try {
119 // Resolve owner/name from the constant. Owner is `ccantynz` and the
120 // repo name is `Gluecron.com`.
121 const [owner, repoName] = OPS_REPO.split("/");
122 if (!owner || !repoName) return { enabled: false, exists: false };
123 const [ownerRow] = await db
124 .select({ id: users.id })
125 .from(users)
126 .where(eq(users.username, owner))
127 .limit(1);
128 if (!ownerRow) return { enabled: false, exists: false };
129 const [repoRow] = await db
130 .select({ id: repositories.id })
131 .from(repositories)
132 .where(
133 and(
134 eq(repositories.ownerId, ownerRow.id),
135 eq(repositories.name, repoName)
136 )
137 )
138 .limit(1);
139 if (!repoRow) return { enabled: false, exists: false };
140 const [bp] = await db
141 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
142 .from(branchProtection)
143 .where(
144 and(
145 eq(branchProtection.repositoryId, repoRow.id),
146 eq(branchProtection.pattern, OPS_PATTERN)
147 )
148 )
149 .limit(1);
150 if (!bp) return { enabled: false, exists: false };
151 return { enabled: !!bp.enableAutoMerge, exists: true };
152 } catch (err) {
153 console.error("[admin-ops] readAutoMergeState:", err);
154 return { enabled: false, exists: false };
155 }
156}
157
158interface LatestDeploy {
159 sha: string;
160 status: string;
161 startedAt: Date;
162 finishedAt: Date | null;
163}
164
165async function readLatestDeploy(): Promise<LatestDeploy | null> {
166 try {
167 const [row] = await db
168 .select({
169 sha: platformDeploys.sha,
170 status: platformDeploys.status,
171 startedAt: platformDeploys.startedAt,
172 finishedAt: platformDeploys.finishedAt,
173 })
174 .from(platformDeploys)
175 .orderBy(desc(platformDeploys.startedAt))
176 .limit(1);
177 return row ?? null;
178 } catch (err) {
179 console.error("[admin-ops] readLatestDeploy:", err);
180 return null;
181 }
182}
183
184async function readReadinessChecks(): Promise<CheckResult[]> {
185 const out: CheckResult[] = [];
186 // 1. Migration probe
187 try {
188 out.push(
189 await checkMigration0040(async () => {
190 try {
191 const rows = await db.execute(
192 sql`SELECT column_name FROM information_schema.columns
193 WHERE table_name = 'branch_protection'
194 AND column_name = 'enable_auto_merge'
195 LIMIT 1`
196 );
197 const list =
198 (rows as any).rows ?? (Array.isArray(rows) ? rows : []);
199 return { exists: list.length > 0 };
200 } catch (err) {
201 return {
202 exists: false,
203 error: err instanceof Error ? err.message : String(err),
204 };
205 }
206 })
207 );
208 } catch {
209 out.push({
210 name: "Migration 0040 applied",
211 status: "fail",
212 reason: "check threw",
213 });
214 }
215 // 2 + 3. env-driven checks
216 out.push(checkAnthropicKey(process.env));
217 out.push(checkAutopilotEnabled(process.env));
218 // 4. autopilot sweep registration — best effort
219 try {
220 const mod = await import("../lib/autopilot");
221 out.push(checkAutoMergeSweepRegistered(mod.defaultTasks()));
222 } catch {
223 out.push({
224 name: "K3 auto-merge-sweep task registered",
225 status: "fail",
226 reason: "autopilot module failed to load",
227 });
228 }
229 return out;
230}
231
232// ---------------------------------------------------------------------------
233// Render helpers
234// ---------------------------------------------------------------------------
235
236function CardShell({
237 title,
238 children,
239}: {
240 title: string;
241 children: any;
242}) {
243 return (
244 <div
dc26881CC LABS App245 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)"
9dd96b9Test User246 >
247 <h3
248 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
249 >
250 {title}
251 </h3>
252 {children}
253 </div>
254 );
255}
256
257function Pill({ ok, label }: { ok: boolean; label: string }) {
258 return (
259 <span
260 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
261 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
262 };color:${ok ? "#34d399" : "#f87171"}`}
263 >
264 <span aria-hidden="true">{ok ? "v" : "x"}</span>
265 <span>{label}</span>
266 </span>
267 );
268}
269
270// ---------------------------------------------------------------------------
271// Gating
272// ---------------------------------------------------------------------------
273
274const ops = new Hono<AuthEnv>();
275ops.use("*", softAuth);
276
277async function gate(c: any): Promise<{ user: any } | Response> {
278 const user = c.get("user");
279 if (!user) return c.redirect("/login?next=/admin/ops");
280 if (!(await isSiteAdmin(user.id))) {
281 return c.html(
282 <Layout title="Forbidden" user={user}>
283 <div class="empty-state">
284 <h2>403 — Not a site admin</h2>
285 <p>You don't have permission to view this page.</p>
286 </div>
287 </Layout>,
288 403
289 );
290 }
291 return { user };
292}
293
294function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
295 return c.redirect(`/admin/ops?${kind}=${encodeURIComponent(msg)}`);
296}
297
298// ---------------------------------------------------------------------------
299// GET /admin/ops
300// ---------------------------------------------------------------------------
301
302ops.get("/admin/ops", async (c) => {
303 const g = await gate(c);
304 if (g instanceof Response) return g;
305 const { user } = g;
306
307 // Surface flash messages from prior POSTs.
308 const success = c.req.query("success");
309 const error = c.req.query("error");
310
311 // Pull every status read in parallel — a slow one shouldn't block the rest.
312 const [autoMergeState, readiness, latest, previous] = await Promise.all([
313 readAutoMergeState(),
314 readReadinessChecks(),
315 readLatestDeploy(),
316 _deps.findPreviousSuccessfulDeploy().catch(() => null),
317 ]);
318
319 const readinessAllGreen = readiness.every((r) => r.status === "pass");
320
321 return c.html(
322 <Layout title="Operations — admin" user={user}>
dc26881CC LABS App323 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
9dd96b9Test User324 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
325 <h1 style="margin:0">Operations</h1>
326 <a href="/admin" class="btn btn-sm">
327 Back to admin
328 </a>
329 </div>
330 <p style="color:var(--text-muted);margin-bottom:20px">
331 Site-admin controls for the live platform. Every action here is
332 audit-logged under <code>admin.ops.*</code>.
333 </p>
334
335 {success && (
336 <div class="auth-success" style="margin-bottom:16px">
337 {decodeURIComponent(success)}
338 </div>
339 )}
340 {error && (
341 <div class="auth-error" style="margin-bottom:16px">
342 {decodeURIComponent(error)}
343 </div>
344 )}
345
346 {/* ---- Auto-merge card ---- */}
347 <CardShell title="AI auto-merge on main">
dc26881CC LABS App348 <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
9dd96b9Test User349 <span style="font-size:13px;color:var(--text-muted)">Status:</span>
350 <Pill
351 ok={autoMergeState.enabled}
352 label={autoMergeState.enabled ? "Enabled" : "Disabled"}
353 />
354 <span style="font-size:12px;color:var(--text-muted)">
355 {OPS_REPO}@{OPS_PATTERN}
356 </span>
357 </div>
358
359 <div style="margin-bottom:14px">
360 <div
361 style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px"
362 >
363 Readiness check
364 </div>
365 <ul style="list-style:none;padding:0;margin:0">
366 {readiness.map((r) => (
367 <li
368 style="display:flex;align-items:flex-start;gap:8px;padding:3px 0;font-size:13px"
369 >
370 <span
371 aria-hidden="true"
372 style={`color:${r.status === "pass" ? "#34d399" : "#f87171"};font-weight:700`}
373 >
374 {r.status === "pass" ? "v" : "x"}
375 </span>
376 <span>
377 {r.name}
378 {r.reason && (
379 <span style="color:var(--text-muted);margin-left:6px">
380 — {r.reason}
381 </span>
382 )}
383 </span>
384 </li>
385 ))}
386 </ul>
387 </div>
388
dc26881CC LABS App389 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User390 {autoMergeState.enabled ? (
391 <form
392 method="post"
393 action="/admin/ops/auto-merge/disable"
394 style="margin:0"
395 >
396 <button type="submit" class="btn btn-sm">
397 Disable
398 </button>
399 </form>
400 ) : (
401 <form
402 method="post"
403 action="/admin/ops/auto-merge/enable"
404 style="margin:0"
405 >
406 <button
407 type="submit"
408 class="btn btn-sm btn-primary"
409 disabled={!readinessAllGreen}
410 title={
411 readinessAllGreen
412 ? "Enable AI auto-merge"
413 : "Fix the readiness items first"
414 }
415 >
416 Enable
417 </button>
418 </form>
419 )}
420 <span style="font-size:12px;color:var(--text-muted)">
421 When enabled, every PR Claude opens that passes gates
422 auto-merges within ~30s and deploys ~25s later — under a
423 minute end-to-end.
424 </span>
425 </div>
426 </CardShell>
427
428 {/* ---- Deploy card ---- */}
429 <CardShell title="Deploy">
430 <div style="margin-bottom:12px;font-size:13px">
431 {latest ? (
432 <span>
433 <span style="color:var(--text-muted)">Last deploy: </span>
434 <Pill
435 ok={latest.status === "succeeded"}
436 label={latest.status}
437 />
438 {" · "}
439 <code class="meta-mono">{shortSha(latest.sha)}</code>
440 {" · "}
441 <span title={latest.startedAt.toISOString()}>
442 {relativeTime(latest.startedAt)}
443 </span>
444 </span>
445 ) : (
446 <span style="color:var(--text-muted)">
447 Last deploy: —
448 </span>
449 )}
450 </div>
dc26881CC LABS App451 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User452 <form
453 method="post"
454 action="/admin/ops/deploy/trigger"
455 style="margin:0"
456 >
457 <button type="submit" class="btn btn-sm btn-primary">
458 Trigger deploy now
459 </button>
460 </form>
461 <span style="font-size:12px;color:var(--text-muted)">
462 Fires hetzner-deploy.yml on main. ~25–90 sec.
463 </span>
464 </div>
465 </CardShell>
466
0ec4eceTest User467 {/* ---- GateTest scanner credentials card ---- */}
468 <CardShell title="GateTest scanner credentials">
469 <p style="font-size:13px;color:var(--text-muted);margin:0 0 12px 0">
470 Two values to paste into GateTest's environment so it can scan
471 this site. Token is admin-scoped — revoke at{" "}
472 <a href="/settings/tokens">/settings/tokens</a> when scanning is done.
473 </p>
474 <div style="display:grid;grid-template-columns:160px 1fr;gap:6px 10px;font-size:13px;margin-bottom:14px">
475 <code class="meta-mono">GLUECRON_BASE_URL</code>
476 <code class="meta-mono" style="word-break:break-all">
477 {config.appBaseUrl}
478 </code>
479 <code class="meta-mono">GLUECRON_API_TOKEN</code>
480 <code
481 class="meta-mono"
482 style={
483 c.req.query("gatetest_token")
484 ? "word-break:break-all;color:var(--accent)"
485 : "color:var(--text-muted)"
486 }
487 >
488 {c.req.query("gatetest_token") ||
489 "— click below to issue (shown once) —"}
490 </code>
491 </div>
492 {c.req.query("gatetest_token") && (
493 <p style="font-size:12px;color:#f59e0b;margin:0 0 12px 0">
494 Copy the token now. It is hashed in the DB and will not be shown
495 again.
496 </p>
497 )}
498 <form
499 method="post"
500 action="/admin/ops/gatetest-token"
501 style="margin:0"
502 >
503 <button type="submit" class="btn btn-sm btn-primary">
504 Issue scanner token
505 </button>
506 </form>
507 </CardShell>
508
9dd96b9Test User509 {/* ---- Rollback card ---- */}
510 <CardShell title="Rollback">
511 <div style="margin-bottom:12px;font-size:13px">
512 {previous ? (
513 <span>
514 <span style="color:var(--text-muted)">
515 Previous successful deploy:{" "}
516 </span>
517 <code class="meta-mono">{shortSha(previous.sha)}</code>
518 {" · "}
519 <span title={previous.finishedAt.toISOString()}>
520 {relativeTime(previous.finishedAt)}
521 </span>
522 </span>
523 ) : (
524 <span style="color:var(--text-muted)">
525 Previous successful deploy: —
526 </span>
527 )}
528 </div>
dc26881CC LABS App529 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User530 <form
531 method="post"
532 action="/admin/ops/rollback"
533 style="margin:0"
534 onsubmit="return confirm('Roll back main to the previous tagged release?')"
535 >
536 <button
537 type="submit"
538 class="btn btn-sm btn-danger"
539 disabled={!previous}
540 title={
541 previous
542 ? `Rollback to ${shortSha(previous.sha)}`
543 : "No prior successful deploy on file"
544 }
545 >
546 {previous ? `Rollback to ${shortSha(previous.sha)}` : "Rollback"}
547 </button>
548 </form>
549 <span style="font-size:12px;color:var(--text-muted)">
550 Resets main to the previous tagged release. Use if the latest
551 deploy broke something.
552 </span>
553 </div>
554 </CardShell>
555 </div>
556 </Layout>
557 );
558});
559
560// ---------------------------------------------------------------------------
561// POST /admin/ops/auto-merge/{enable,disable}
562// ---------------------------------------------------------------------------
563
564async function handleAutoMergeFlip(c: any, off: boolean): Promise<Response> {
565 const g = await gate(c);
566 if (g instanceof Response) return g;
567 const { user } = g;
568
569 try {
570 const result = await _deps.runEnableAutoMerge(
571 db as unknown as DbLike,
572 {
573 ownerSlash: OPS_REPO,
574 pattern: OPS_PATTERN,
575 off,
576 actorUserId: user.id,
577 },
578 _deps.audit
579 );
580 try {
581 await _deps.audit({
582 userId: user.id,
583 action: off ? "admin.ops.auto_merge_disable" : "admin.ops.auto_merge_enable",
584 targetType: "branch_protection",
585 targetId: result.after?.id,
586 metadata: {
587 repo: OPS_REPO,
588 pattern: OPS_PATTERN,
589 scriptAction: result.action,
590 },
591 });
592 } catch {
593 // audit failure is non-fatal for the user-facing flow
594 }
595 const verb = off ? "disabled" : "enabled";
596 const tail =
597 result.action === "noop"
598 ? `already ${verb}`
599 : result.action === "inserted"
600 ? `${verb} (new rule created)`
601 : `${verb}`;
602 return redirectWith(c, "success", `Auto-merge ${tail} on ${OPS_REPO}@${OPS_PATTERN}.`);
603 } catch (err) {
604 const message = err instanceof Error ? err.message : String(err);
605 return redirectWith(
606 c,
607 "error",
608 `Failed to ${off ? "disable" : "enable"} auto-merge: ${message}`
609 );
610 }
611}
612
613ops.post("/admin/ops/auto-merge/enable", (c) => handleAutoMergeFlip(c, false));
614ops.post("/admin/ops/auto-merge/disable", (c) => handleAutoMergeFlip(c, true));
615
616// ---------------------------------------------------------------------------
617// POST /admin/ops/deploy/trigger
618//
619// Re-uses the N4 handler. We don't duplicate the GitHub API call — we
620// simply invoke `/admin/deploys/trigger` on the same Hono `app.request`
621// with the caller's session cookie forwarded so softAuth + isSiteAdmin
622// pass cleanly on the inner call.
623// ---------------------------------------------------------------------------
624
625ops.post("/admin/ops/deploy/trigger", async (c) => {
626 const g = await gate(c);
627 if (g instanceof Response) return g;
628 const { user } = g;
629
630 try {
631 // Fetch the live app instance lazily — avoids a circular import at module
632 // load time.
633 const { default: app } = await import("../app");
634 const cookie = c.req.header("cookie") ?? "";
635 const origin = c.req.header("origin") ?? "";
636 const host = c.req.header("host") ?? "";
637 const res = await app.request("/admin/deploys/trigger", {
638 method: "POST",
639 headers: {
640 "content-type": "application/json",
641 cookie,
642 origin,
643 host,
644 },
645 body: JSON.stringify({}),
646 });
647 if (res.ok) {
648 try {
649 await _deps.audit({
650 userId: user.id,
651 action: "admin.ops.deploy_triggered",
652 targetType: "workflow",
653 targetId: "hetzner-deploy.yml",
654 metadata: { repo: OPS_REPO },
655 });
656 } catch {
657 /* non-fatal */
658 }
659 return redirectWith(c, "success", "Deploy dispatched — watch /admin/deploys for progress.");
660 }
661 let raw = "";
662 try {
663 raw = await res.text();
664 } catch {
665 /* swallow */
666 }
667 let msg = `deploy trigger returned ${res.status}`;
668 try {
669 const j = JSON.parse(raw);
670 if (j?.error) msg = String(j.error);
671 } catch {
672 if (raw) msg = raw.slice(0, 240);
673 }
674 return redirectWith(c, "error", msg);
675 } catch (err) {
676 const message = err instanceof Error ? err.message : String(err);
677 return redirectWith(c, "error", `Deploy trigger failed: ${message}`);
678 }
679});
680
681// ---------------------------------------------------------------------------
682// POST /admin/ops/rollback
683// ---------------------------------------------------------------------------
684
685ops.post("/admin/ops/rollback", async (c) => {
686 const g = await gate(c);
687 if (g instanceof Response) return g;
688 const { user } = g;
689
690 let prev: PreviousDeploy | null = null;
691 try {
692 prev = await _deps.findPreviousSuccessfulDeploy();
693 } catch (err) {
694 const message = err instanceof Error ? err.message : String(err);
695 return redirectWith(c, "error", `Rollback lookup failed: ${message}`);
696 }
697 if (!prev) {
698 return redirectWith(
699 c,
700 "error",
701 "No previous successful deploy on file — nothing to roll back to."
702 );
703 }
704
705 let result: TriggerRollbackResult;
706 try {
707 result = await _deps.triggerRollback({
708 targetSha: prev.sha,
709 triggeredByUserId: user.id,
710 });
711 } catch (err) {
712 const message = err instanceof Error ? err.message : String(err);
713 return redirectWith(c, "error", `Rollback dispatch threw: ${message}`);
714 }
715
716 if (!result.ok) {
717 return redirectWith(c, "error", result.error || "Rollback dispatch failed.");
718 }
719
720 try {
721 await _deps.audit({
722 userId: user.id,
723 action: "admin.ops.rollback_dispatched",
724 targetType: "workflow",
725 targetId: "hetzner-deploy.yml",
726 metadata: { repo: OPS_REPO, target_sha: prev.sha },
727 });
728 } catch {
729 /* non-fatal */
730 }
731
732 return redirectWith(
733 c,
734 "success",
735 `Rollback dispatched to ${shortSha(prev.sha)} — watch /admin/deploys for progress.`
736 );
737});
738
0ec4eceTest User739// ---------------------------------------------------------------------------
740// POST /admin/ops/gatetest-token
741// ---------------------------------------------------------------------------
742//
743// Mint a fresh admin-scoped API token for the GateTest scanner and surface
744// it once via the redirect query string. The DB stores only the SHA-256
745// hash (same shape tokens.tsx uses) so the plaintext is unrecoverable after
746// this redirect — operator must copy it immediately into GateTest's
747// environment.
748
749function generateGateTestToken(): string {
750 const bytes = crypto.getRandomValues(new Uint8Array(32));
751 return (
752 "glc_" +
753 Array.from(bytes)
754 .map((b) => b.toString(16).padStart(2, "0"))
755 .join("")
756 );
757}
758
759async function sha256Hex(input: string): Promise<string> {
760 const data = new TextEncoder().encode(input);
761 const hash = await crypto.subtle.digest("SHA-256", data);
762 return Array.from(new Uint8Array(hash))
763 .map((b) => b.toString(16).padStart(2, "0"))
764 .join("");
765}
766
767ops.post("/admin/ops/gatetest-token", async (c) => {
768 const g = await gate(c);
769 if (g instanceof Response) return g;
770 const { user } = g;
771
772 const token = generateGateTestToken();
773 const tokenH = await sha256Hex(token);
774 const stamp = new Date().toISOString().slice(0, 10);
775
776 try {
777 await db.insert(apiTokens).values({
778 userId: user.id,
779 name: `GateTest scanner (${stamp})`,
780 tokenHash: tokenH,
781 tokenPrefix: token.slice(0, 12),
782 scopes: "admin",
783 });
784 } catch (err) {
785 const message = err instanceof Error ? err.message : String(err);
786 return redirectWith(c, "error", `Token insert failed: ${message}`);
787 }
788
789 try {
790 await _deps.audit({
791 userId: user.id,
792 action: "admin.ops.gatetest_token_issued",
793 targetType: "api_token",
794 metadata: { scope: "admin", prefix: token.slice(0, 12) },
795 });
796 } catch {
797 /* non-fatal */
798 }
799
800 return c.redirect(`/admin/ops?gatetest_token=${encodeURIComponent(token)}`);
801});
802
9dd96b9Test User803export const __test = {
804 readAutoMergeState,
805 readLatestDeploy,
806 readReadinessChecks,
807 OPS_REPO,
808 OPS_PATTERN,
809};
810
811export default ops;