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-deploys.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-deploys.tsxBlame138 lines · 1 contributor
f764c07Claude1/**
2 * Block N4 — Admin deploy trigger.
3 *
4 * POST /admin/deploys/trigger — kick off the hetzner-deploy.yml
5 * workflow via GitHub workflow_dispatch.
6 *
7 * Reads `GITHUB_TOKEN` (operator-provided, repo+workflow scopes) from the
8 * server environment. The CLI talks to GitHub directly; this route is the
9 * "click a button on the admin page" equivalent so the operator never has to
10 * leave Gluecron to ship a hot-fix.
11 *
12 * NOTE: The companion `/admin/deploys` page (Block N3) has not landed yet on
13 * this branch. We ship the trigger endpoint now so the CLI + tests can land
14 * cleanly; once N3 adds the page, its "Trigger deploy" button posts here.
15 */
16
17import { Hono } from "hono";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { isSiteAdmin } from "../lib/admin";
21import { audit } from "../lib/notify";
22
23const GH_API = "https://api.github.com";
24
25/**
26 * Dependency-injected fetcher so tests can drive the route without hitting
27 * the real api.github.com.
28 */
29export type GithubFetch = (
30 url: string,
31 init?: { method?: string; headers?: Record<string, string>; body?: string }
32) => Promise<{ status: number; ok: boolean; text: () => Promise<string> }>;
33
34let _githubFetch: GithubFetch | null = null;
35let _envOverride: { GITHUB_TOKEN?: string } | null = null;
36
37/** Test-only: override the github fetcher. Pass `null` to restore default. */
38export function __setGithubFetchForTests(f: GithubFetch | null): void {
39 _githubFetch = f;
40}
41
42/** Test-only: override env reads. Pass `null` to fall back to process.env. */
43export function __setEnvForTests(e: { GITHUB_TOKEN?: string } | null): void {
44 _envOverride = e;
45}
46
47function ghToken(): string | undefined {
48 if (_envOverride) return _envOverride.GITHUB_TOKEN;
49 return process.env.GITHUB_TOKEN;
50}
51
52function ghFetch(): GithubFetch {
53 return _githubFetch ?? ((fetch as unknown) as GithubFetch);
54}
55
56const admin = new Hono<AuthEnv>();
57admin.use("*", softAuth);
58
59async function gate(c: any): Promise<{ user: any } | Response> {
60 const user = c.get("user");
61 if (!user) return c.json({ error: "auth required" }, 401);
62 if (!(await isSiteAdmin(user.id))) {
63 return c.json({ error: "site admin required" }, 403);
64 }
65 return { user };
66}
67
68admin.post("/admin/deploys/trigger", async (c) => {
69 const g = await gate(c);
70 if (g instanceof Response) return g;
71 const { user } = g;
72
73 const token = ghToken();
74 if (!token) {
75 return c.json(
76 {
77 error:
78 "GITHUB_TOKEN is not set on the server — configure GITHUB_TOKEN on the box first (e.g. /etc/gluecron.env).",
79 },
80 400
81 );
82 }
83
84 // Body is optional — defaults are the hetzner deploy on main of this repo.
85 let body: any = {};
86 try {
87 body = await c.req.json();
88 } catch {
89 body = {};
90 }
91 const repo = String(body.repo || "ccantynz/Gluecron.com");
92 const workflow = String(body.workflow || "hetzner-deploy.yml");
93 const ref = String(body.ref || "main");
94 const [owner, name] = repo.split("/");
95 if (!owner || !name) {
96 return c.json({ error: "expected repo as owner/name" }, 400);
97 }
98
99 const url = `${GH_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
100 const res = await ghFetch()(url, {
101 method: "POST",
102 headers: {
103 accept: "application/vnd.github+json",
104 authorization: `Bearer ${token}`,
105 "content-type": "application/json",
106 "x-github-api-version": "2022-11-28",
107 "user-agent": "gluecron-admin",
108 },
109 body: JSON.stringify({ ref }),
110 });
111
112 if (res.status !== 204) {
113 const raw = await res.text();
114 let msg = raw;
115 try {
116 const j = JSON.parse(raw);
117 msg = j?.message || raw;
118 } catch {
119 // raw it is
120 }
121 return c.json(
122 { error: `github responded ${res.status}: ${msg || "request failed"}` },
123 502
124 );
125 }
126
127 await audit({
128 userId: user.id,
129 action: "admin.deploy.triggered",
130 targetType: "workflow",
131 targetId: `${repo}:${workflow}@${ref}`,
132 metadata: { repo, workflow, ref },
133 });
134
135 return c.json({ ok: true, repo, workflow, ref });
136});
137
138export default admin;