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