Commit0ec4eceunknown_key
feat(admin-ops): one-click GateTest scanner credentials card
feat(admin-ops): one-click GateTest scanner credentials card Adds a "GateTest scanner credentials" card on /admin/ops that surfaces GLUECRON_BASE_URL (from config.appBaseUrl) and a one-shot admin-scoped API token for the operator to paste into the GateTest scanner's environment — so GateTest can crawl the live gluecron.com site without needing terminal access. The token is hashed in the DB (same path tokens.tsx uses) and shown exactly once via the redirect query string, then never recoverable. Action is audit-logged under admin.ops.gatetest_token_issued. .env.example documents that these two vars belong on the GateTest side, not in Gluecron's own environment. https://claude.ai/code/session_01QFLWDxWw65DX6enMcS5Lwe
2 files changed+116−10ec4ecef3c94dcaf4deb7a67fc80cc090f35ddb6
2 changed files+116−1
Modified.env.example+8−0View fileUnifiedSplit
@@ -8,6 +8,14 @@ GATETEST_API_KEY=
88# Generate a secret with: openssl rand -hex 32
99GATETEST_CALLBACK_SECRET=
1010GATETEST_HMAC_SECRET=
11# ─── For the GateTest side, NOT Gluecron ─────────────────────────────────
12# When you point GateTest at gluecron.com to scan the live site, GateTest
13# needs the two values below in ITS environment (set them in the gatetest.ai
14# scanner config, not here). Issue the token from /admin/ops → "GateTest
15# scanner credentials" while signed in as site admin.
16# GLUECRON_BASE_URL=https://gluecron.com
17# GLUECRON_API_TOKEN=<paste from /admin/ops, shown once>
18# ─────────────────────────────────────────────────────────────────────────
1119CRONTECH_DEPLOY_URL=https://crontech.ai/api/webhooks/gluecron-push
1220# BLK-016 — only fire the Crontech deploy webhook for pushes to this
1321# `<owner>/<name>` (default `ccantynz-alt/crontech`). All other repo pushes
Modifiedsrc/routes/admin-ops.tsx+108−1View fileUnifiedSplit
@@ -34,13 +34,14 @@
3434import { Hono } from "hono";
3535import { and, desc, eq, sql } from "drizzle-orm";
3636import { db } from "../db";
37import { branchProtection, repositories, users } from "../db/schema";
37import { apiTokens, branchProtection, repositories, users } from "../db/schema";
3838import { platformDeploys } from "../db/schema-deploys";
3939import { Layout } from "../views/layout";
4040import { softAuth } from "../middleware/auth";
4141import type { AuthEnv } from "../middleware/auth";
4242import { isSiteAdmin } from "../lib/admin";
4343import { audit as realAudit } from "../lib/notify";
44import { config } from "../lib/config";
4445import {
4546 runEnableAutoMerge as realRunEnableAutoMerge,
4647 type DbLike,
@@ -463,6 +464,48 @@ ops.get("/admin/ops", async (c) => {
463464 </div>
464465 </CardShell>
465466
467 {/* ---- 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
466509 {/* ---- Rollback card ---- */}
467510 <CardShell title="Rollback">
468511 <div style="margin-bottom:12px;font-size:13px">
@@ -693,6 +736,70 @@ ops.post("/admin/ops/rollback", async (c) => {
693736 );
694737});
695738
739// ---------------------------------------------------------------------------
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
696803export const __test = {
697804 readAutoMergeState,
698805 readLatestDeploy,
699806