Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame813 lines · 3 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
238function CardShell({
239 title,
240 children,
241}: {
242 title: string;
243 children: any;
244}) {
245 return (
246 <div
dc26881CC LABS App247 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)"
9dd96b9Test User248 >
249 <h3
250 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
251 >
252 {title}
253 </h3>
254 {children}
255 </div>
256 );
257}
258
259function Pill({ ok, label }: { ok: boolean; label: string }) {
260 return (
261 <span
262 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
263 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
264 };color:${ok ? "#34d399" : "#f87171"}`}
265 >
266 <span aria-hidden="true">{ok ? "v" : "x"}</span>
267 <span>{label}</span>
268 </span>
269 );
270}
271
272// ---------------------------------------------------------------------------
273// Gating
274// ---------------------------------------------------------------------------
275
276const ops = new Hono<AuthEnv>();
277ops.use("*", softAuth);
278
279async function gate(c: any): Promise<{ user: any } | Response> {
280 const user = c.get("user");
281 if (!user) return c.redirect("/login?next=/admin/ops");
282 if (!(await isSiteAdmin(user.id))) {
283 return c.html(
284 <Layout title="Forbidden" user={user}>
285 <div class="empty-state">
286 <h2>403 — Not a site admin</h2>
287 <p>You don't have permission to view this page.</p>
288 </div>
289 </Layout>,
290 403
291 );
292 }
293 return { user };
294}
295
296function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
297 return c.redirect(`/admin/ops?${kind}=${encodeURIComponent(msg)}`);
298}
299
300// ---------------------------------------------------------------------------
301// GET /admin/ops
302// ---------------------------------------------------------------------------
303
304ops.get("/admin/ops", async (c) => {
305 const g = await gate(c);
306 if (g instanceof Response) return g;
307 const { user } = g;
308
309 // Surface flash messages from prior POSTs.
310 const success = c.req.query("success");
311 const error = c.req.query("error");
312
313 // Pull every status read in parallel — a slow one shouldn't block the rest.
314 const [autoMergeState, readiness, latest, previous] = await Promise.all([
315 readAutoMergeState(),
316 readReadinessChecks(),
317 readLatestDeploy(),
318 _deps.findPreviousSuccessfulDeploy().catch(() => null),
319 ]);
320
321 const readinessAllGreen = readiness.every((r) => r.status === "pass");
322
323 return c.html(
324 <Layout title="Operations — admin" user={user}>
dc26881CC LABS App325 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
9dd96b9Test User326 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
327 <h1 style="margin:0">Operations</h1>
328 <a href="/admin" class="btn btn-sm">
329 Back to admin
330 </a>
331 </div>
332 <p style="color:var(--text-muted);margin-bottom:20px">
333 Site-admin controls for the live platform. Every action here is
334 audit-logged under <code>admin.ops.*</code>.
335 </p>
336
337 {success && (
338 <div class="auth-success" style="margin-bottom:16px">
339 {decodeURIComponent(success)}
340 </div>
341 )}
342 {error && (
343 <div class="auth-error" style="margin-bottom:16px">
344 {decodeURIComponent(error)}
345 </div>
346 )}
347
348 {/* ---- Auto-merge card ---- */}
349 <CardShell title="AI auto-merge on main">
dc26881CC LABS App350 <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
9dd96b9Test User351 <span style="font-size:13px;color:var(--text-muted)">Status:</span>
352 <Pill
353 ok={autoMergeState.enabled}
354 label={autoMergeState.enabled ? "Enabled" : "Disabled"}
355 />
356 <span style="font-size:12px;color:var(--text-muted)">
357 {OPS_REPO}@{OPS_PATTERN}
358 </span>
359 </div>
360
361 <div style="margin-bottom:14px">
362 <div
363 style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px"
364 >
365 Readiness check
366 </div>
367 <ul style="list-style:none;padding:0;margin:0">
368 {readiness.map((r) => (
369 <li
370 style="display:flex;align-items:flex-start;gap:8px;padding:3px 0;font-size:13px"
371 >
372 <span
373 aria-hidden="true"
374 style={`color:${r.status === "pass" ? "#34d399" : "#f87171"};font-weight:700`}
375 >
376 {r.status === "pass" ? "v" : "x"}
377 </span>
378 <span>
379 {r.name}
380 {r.reason && (
381 <span style="color:var(--text-muted);margin-left:6px">
382 — {r.reason}
383 </span>
384 )}
385 </span>
386 </li>
387 ))}
388 </ul>
389 </div>
390
dc26881CC LABS App391 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User392 {autoMergeState.enabled ? (
393 <form
394 method="post"
395 action="/admin/ops/auto-merge/disable"
396 style="margin:0"
397 >
398 <button type="submit" class="btn btn-sm">
399 Disable
400 </button>
401 </form>
402 ) : (
403 <form
404 method="post"
405 action="/admin/ops/auto-merge/enable"
406 style="margin:0"
407 >
408 <button
409 type="submit"
410 class="btn btn-sm btn-primary"
411 disabled={!readinessAllGreen}
412 title={
413 readinessAllGreen
414 ? "Enable AI auto-merge"
415 : "Fix the readiness items first"
416 }
417 >
418 Enable
419 </button>
420 </form>
421 )}
422 <span style="font-size:12px;color:var(--text-muted)">
423 When enabled, every PR Claude opens that passes gates
424 auto-merges within ~30s and deploys ~25s later — under a
425 minute end-to-end.
426 </span>
427 </div>
428 </CardShell>
429
430 {/* ---- Deploy card ---- */}
431 <CardShell title="Deploy">
432 <div style="margin-bottom:12px;font-size:13px">
433 {latest ? (
434 <span>
435 <span style="color:var(--text-muted)">Last deploy: </span>
436 <Pill
437 ok={latest.status === "succeeded"}
438 label={latest.status}
439 />
440 {" · "}
441 <code class="meta-mono">{shortSha(latest.sha)}</code>
442 {" · "}
443 <span title={latest.startedAt.toISOString()}>
444 {relativeTime(latest.startedAt)}
445 </span>
446 </span>
447 ) : (
448 <span style="color:var(--text-muted)">
449 Last deploy: —
450 </span>
451 )}
452 </div>
dc26881CC LABS App453 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User454 <form
455 method="post"
456 action="/admin/ops/deploy/trigger"
457 style="margin:0"
458 >
459 <button type="submit" class="btn btn-sm btn-primary">
460 Trigger deploy now
461 </button>
462 </form>
463 <span style="font-size:12px;color:var(--text-muted)">
464 Fires hetzner-deploy.yml on main. ~25–90 sec.
465 </span>
466 </div>
467 </CardShell>
468
0ec4eceTest User469 {/* ---- GateTest scanner credentials card ---- */}
470 <CardShell title="GateTest scanner credentials">
471 <p style="font-size:13px;color:var(--text-muted);margin:0 0 12px 0">
472 Two values to paste into GateTest's environment so it can scan
473 this site. Token is admin-scoped — revoke at{" "}
474 <a href="/settings/tokens">/settings/tokens</a> when scanning is done.
475 </p>
476 <div style="display:grid;grid-template-columns:160px 1fr;gap:6px 10px;font-size:13px;margin-bottom:14px">
477 <code class="meta-mono">GLUECRON_BASE_URL</code>
478 <code class="meta-mono" style="word-break:break-all">
479 {config.appBaseUrl}
480 </code>
481 <code class="meta-mono">GLUECRON_API_TOKEN</code>
482 <code
483 class="meta-mono"
484 style={
485 c.req.query("gatetest_token")
486 ? "word-break:break-all;color:var(--accent)"
487 : "color:var(--text-muted)"
488 }
489 >
490 {c.req.query("gatetest_token") ||
491 "— click below to issue (shown once) —"}
492 </code>
493 </div>
494 {c.req.query("gatetest_token") && (
495 <p style="font-size:12px;color:#f59e0b;margin:0 0 12px 0">
496 Copy the token now. It is hashed in the DB and will not be shown
497 again.
498 </p>
499 )}
500 <form
501 method="post"
502 action="/admin/ops/gatetest-token"
503 style="margin:0"
504 >
505 <button type="submit" class="btn btn-sm btn-primary">
506 Issue scanner token
507 </button>
508 </form>
509 </CardShell>
510
9dd96b9Test User511 {/* ---- Rollback card ---- */}
512 <CardShell title="Rollback">
513 <div style="margin-bottom:12px;font-size:13px">
514 {previous ? (
515 <span>
516 <span style="color:var(--text-muted)">
517 Previous successful deploy:{" "}
518 </span>
519 <code class="meta-mono">{shortSha(previous.sha)}</code>
520 {" · "}
521 <span title={previous.finishedAt.toISOString()}>
522 {relativeTime(previous.finishedAt)}
523 </span>
524 </span>
525 ) : (
526 <span style="color:var(--text-muted)">
527 Previous successful deploy: —
528 </span>
529 )}
530 </div>
dc26881CC LABS App531 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User532 <form
533 method="post"
534 action="/admin/ops/rollback"
535 style="margin:0"
536 onsubmit="return confirm('Roll back main to the previous tagged release?')"
537 >
538 <button
539 type="submit"
540 class="btn btn-sm btn-danger"
541 disabled={!previous}
542 title={
543 previous
544 ? `Rollback to ${shortSha(previous.sha)}`
545 : "No prior successful deploy on file"
546 }
547 >
548 {previous ? `Rollback to ${shortSha(previous.sha)}` : "Rollback"}
549 </button>
550 </form>
551 <span style="font-size:12px;color:var(--text-muted)">
552 Resets main to the previous tagged release. Use if the latest
553 deploy broke something.
554 </span>
555 </div>
556 </CardShell>
557 </div>
558 </Layout>
559 );
560});
561
562// ---------------------------------------------------------------------------
563// POST /admin/ops/auto-merge/{enable,disable}
564// ---------------------------------------------------------------------------
565
566async function handleAutoMergeFlip(c: any, off: boolean): Promise<Response> {
567 const g = await gate(c);
568 if (g instanceof Response) return g;
569 const { user } = g;
570
571 try {
572 const result = await _deps.runEnableAutoMerge(
573 db as unknown as DbLike,
574 {
575 ownerSlash: OPS_REPO,
576 pattern: OPS_PATTERN,
577 off,
578 actorUserId: user.id,
579 },
580 _deps.audit
581 );
582 try {
583 await _deps.audit({
584 userId: user.id,
585 action: off ? "admin.ops.auto_merge_disable" : "admin.ops.auto_merge_enable",
586 targetType: "branch_protection",
587 targetId: result.after?.id,
588 metadata: {
589 repo: OPS_REPO,
590 pattern: OPS_PATTERN,
591 scriptAction: result.action,
592 },
593 });
594 } catch {
595 // audit failure is non-fatal for the user-facing flow
596 }
597 const verb = off ? "disabled" : "enabled";
598 const tail =
599 result.action === "noop"
600 ? `already ${verb}`
601 : result.action === "inserted"
602 ? `${verb} (new rule created)`
603 : `${verb}`;
604 return redirectWith(c, "success", `Auto-merge ${tail} on ${OPS_REPO}@${OPS_PATTERN}.`);
605 } catch (err) {
606 const message = err instanceof Error ? err.message : String(err);
607 return redirectWith(
608 c,
609 "error",
610 `Failed to ${off ? "disable" : "enable"} auto-merge: ${message}`
611 );
612 }
613}
614
615ops.post("/admin/ops/auto-merge/enable", (c) => handleAutoMergeFlip(c, false));
616ops.post("/admin/ops/auto-merge/disable", (c) => handleAutoMergeFlip(c, true));
617
618// ---------------------------------------------------------------------------
619// POST /admin/ops/deploy/trigger
620//
621// Re-uses the N4 handler. We don't duplicate the GitHub API call — we
622// simply invoke `/admin/deploys/trigger` on the same Hono `app.request`
623// with the caller's session cookie forwarded so softAuth + isSiteAdmin
624// pass cleanly on the inner call.
625// ---------------------------------------------------------------------------
626
627ops.post("/admin/ops/deploy/trigger", async (c) => {
628 const g = await gate(c);
629 if (g instanceof Response) return g;
630 const { user } = g;
631
632 try {
633 // Fetch the live app instance lazily — avoids a circular import at module
634 // load time.
635 const { default: app } = await import("../app");
636 const cookie = c.req.header("cookie") ?? "";
637 const origin = c.req.header("origin") ?? "";
638 const host = c.req.header("host") ?? "";
639 const res = await app.request("/admin/deploys/trigger", {
640 method: "POST",
641 headers: {
642 "content-type": "application/json",
643 cookie,
644 origin,
645 host,
646 },
647 body: JSON.stringify({}),
648 });
649 if (res.ok) {
650 try {
651 await _deps.audit({
652 userId: user.id,
653 action: "admin.ops.deploy_triggered",
654 targetType: "workflow",
655 targetId: "hetzner-deploy.yml",
656 metadata: { repo: OPS_REPO },
657 });
658 } catch {
659 /* non-fatal */
660 }
661 return redirectWith(c, "success", "Deploy dispatched — watch /admin/deploys for progress.");
662 }
663 let raw = "";
664 try {
665 raw = await res.text();
666 } catch {
667 /* swallow */
668 }
669 let msg = `deploy trigger returned ${res.status}`;
670 try {
671 const j = JSON.parse(raw);
672 if (j?.error) msg = String(j.error);
673 } catch {
674 if (raw) msg = raw.slice(0, 240);
675 }
676 return redirectWith(c, "error", msg);
677 } catch (err) {
678 const message = err instanceof Error ? err.message : String(err);
679 return redirectWith(c, "error", `Deploy trigger failed: ${message}`);
680 }
681});
682
683// ---------------------------------------------------------------------------
684// POST /admin/ops/rollback
685// ---------------------------------------------------------------------------
686
687ops.post("/admin/ops/rollback", async (c) => {
688 const g = await gate(c);
689 if (g instanceof Response) return g;
690 const { user } = g;
691
692 let prev: PreviousDeploy | null = null;
693 try {
694 prev = await _deps.findPreviousSuccessfulDeploy();
695 } catch (err) {
696 const message = err instanceof Error ? err.message : String(err);
697 return redirectWith(c, "error", `Rollback lookup failed: ${message}`);
698 }
699 if (!prev) {
700 return redirectWith(
701 c,
702 "error",
703 "No previous successful deploy on file — nothing to roll back to."
704 );
705 }
706
707 let result: TriggerRollbackResult;
708 try {
709 result = await _deps.triggerRollback({
710 targetSha: prev.sha,
711 triggeredByUserId: user.id,
712 });
713 } catch (err) {
714 const message = err instanceof Error ? err.message : String(err);
715 return redirectWith(c, "error", `Rollback dispatch threw: ${message}`);
716 }
717
718 if (!result.ok) {
719 return redirectWith(c, "error", result.error || "Rollback dispatch failed.");
720 }
721
722 try {
723 await _deps.audit({
724 userId: user.id,
725 action: "admin.ops.rollback_dispatched",
726 targetType: "workflow",
727 targetId: "hetzner-deploy.yml",
728 metadata: { repo: OPS_REPO, target_sha: prev.sha },
729 });
730 } catch {
731 /* non-fatal */
732 }
733
734 return redirectWith(
735 c,
736 "success",
737 `Rollback dispatched to ${shortSha(prev.sha)} — watch /admin/deploys for progress.`
738 );
739});
740
0ec4eceTest User741// ---------------------------------------------------------------------------
742// POST /admin/ops/gatetest-token
743// ---------------------------------------------------------------------------
744//
745// Mint a fresh admin-scoped API token for the GateTest scanner and surface
746// it once via the redirect query string. The DB stores only the SHA-256
747// hash (same shape tokens.tsx uses) so the plaintext is unrecoverable after
748// this redirect — operator must copy it immediately into GateTest's
749// environment.
750
751function generateGateTestToken(): string {
752 const bytes = crypto.getRandomValues(new Uint8Array(32));
753 return (
754 "glc_" +
755 Array.from(bytes)
756 .map((b) => b.toString(16).padStart(2, "0"))
757 .join("")
758 );
759}
760
761async function sha256Hex(input: string): Promise<string> {
762 const data = new TextEncoder().encode(input);
763 const hash = await crypto.subtle.digest("SHA-256", data);
764 return Array.from(new Uint8Array(hash))
765 .map((b) => b.toString(16).padStart(2, "0"))
766 .join("");
767}
768
769ops.post("/admin/ops/gatetest-token", async (c) => {
770 const g = await gate(c);
771 if (g instanceof Response) return g;
772 const { user } = g;
773
774 const token = generateGateTestToken();
775 const tokenH = await sha256Hex(token);
776 const stamp = new Date().toISOString().slice(0, 10);
777
778 try {
779 await db.insert(apiTokens).values({
780 userId: user.id,
781 name: `GateTest scanner (${stamp})`,
782 tokenHash: tokenH,
783 tokenPrefix: token.slice(0, 12),
784 scopes: "admin",
785 });
786 } catch (err) {
787 const message = err instanceof Error ? err.message : String(err);
788 return redirectWith(c, "error", `Token insert failed: ${message}`);
789 }
790
791 try {
792 await _deps.audit({
793 userId: user.id,
794 action: "admin.ops.gatetest_token_issued",
795 targetType: "api_token",
796 metadata: { scope: "admin", prefix: token.slice(0, 12) },
797 });
798 } catch {
799 /* non-fatal */
800 }
801
802 return c.redirect(`/admin/ops?gatetest_token=${encodeURIComponent(token)}`);
803});
804
9dd96b9Test User805export const __test = {
806 readAutoMergeState,
807 readLatestDeploy,
808 readReadinessChecks,
809 OPS_REPO,
810 OPS_PATTERN,
811};
812
813export default ops;