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

hooks.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

hooks.tsBlame511 lines · 1 contributor
ad6d4adClaude1/**
2 * Inbound API hooks — endpoints that external systems (GateTest, CI runners,
3 * Crontech deploy) call into to report async results.
4 *
5 * Security: every hook is authenticated via a shared-secret Bearer token OR
6 * HMAC signature over the raw body. Configure secrets via env vars:
7 * GATETEST_CALLBACK_SECRET — bearer token GateTest must present
8 * GATETEST_HMAC_SECRET — optional HMAC-SHA256 secret over the raw body
9 *
10 * Endpoints:
11 * POST /api/hooks/gatetest — GateTest scan result callback
12 * POST /api/hooks/gatetest/status — periodic status / heartbeat (optional)
13 *
14 * The GateTest service should POST JSON of shape:
15 * {
16 * "repository": "owner/repo",
17 * "sha": "<full-commit-sha>",
18 * "ref": "refs/heads/main",
19 * "pullRequestNumber": 42, // optional
20 * "status": "passed" | "failed" | "error",
21 * "summary": "12 tests passed, 0 failed",
22 * "details": { ... } // optional, persisted as JSON
23 * }
24 *
25 * Response: 200 OK with {ok:true, gateRunId} on success, 401 on auth failure,
26 * 400 on malformed payload, 404 if the repo is unknown.
27 */
28
29import { Hono } from "hono";
30import { and, desc, eq } from "drizzle-orm";
31import { createHmac, timingSafeEqual } from "crypto";
32import { db } from "../db";
33import {
34 apiTokens,
35 gateRuns,
36 pullRequests,
37 repositories,
38 users,
39} from "../db/schema";
40import { notify, audit } from "../lib/notify";
41
42const hooks = new Hono();
43
44interface GateTestPayload {
45 repository?: string;
46 sha?: string;
47 ref?: string;
48 pullRequestNumber?: number;
49 status?: "passed" | "failed" | "error" | "success";
50 summary?: string;
51 details?: unknown;
52 durationMs?: number;
53}
54
55function constantTimeEq(a: string, b: string): boolean {
56 const A = Buffer.from(a);
57 const B = Buffer.from(b);
58 if (A.length !== B.length) return false;
59 try {
60 return timingSafeEqual(A, B);
61 } catch {
62 return false;
63 }
64}
65
66function verifyGateTestAuth(c: any, rawBody: string): { ok: boolean; error?: string } {
67 const bearerSecret = process.env.GATETEST_CALLBACK_SECRET || "";
68 const hmacSecret = process.env.GATETEST_HMAC_SECRET || "";
69
70 // If no secret is configured, refuse by default — do NOT allow anonymous writes.
71 if (!bearerSecret && !hmacSecret) {
72 return {
73 ok: false,
74 error:
75 "Callback endpoint not configured: set GATETEST_CALLBACK_SECRET or GATETEST_HMAC_SECRET",
76 };
77 }
78
79 // Bearer auth (simpler, preferred for server-to-server)
80 if (bearerSecret) {
81 const auth = c.req.header("authorization") || "";
82 if (auth.startsWith("Bearer ")) {
83 const token = auth.slice(7).trim();
84 if (constantTimeEq(token, bearerSecret)) return { ok: true };
85 }
86 // Alternative header for tools that don't send Authorization
87 const xTok = c.req.header("x-gatetest-token") || "";
88 if (xTok && constantTimeEq(xTok, bearerSecret)) return { ok: true };
89 }
90
91 // HMAC signature over the raw body
92 if (hmacSecret) {
93 const sigHeader =
94 c.req.header("x-gatetest-signature") ||
95 c.req.header("x-signature-sha256") ||
96 "";
97 if (sigHeader) {
98 const expected =
99 "sha256=" +
100 createHmac("sha256", hmacSecret).update(rawBody).digest("hex");
101 if (constantTimeEq(sigHeader, expected)) return { ok: true };
102 }
103 }
104
105 return { ok: false, error: "Invalid or missing GateTest credentials" };
106}
107
108async function resolveRepo(full: string): Promise<{ id: string; ownerId: string; name: string } | null> {
109 if (!full || !full.includes("/")) return null;
110 const [owner, name] = full.split("/", 2);
111 try {
112 const [row] = await db
113 .select({
114 id: repositories.id,
115 ownerId: repositories.ownerId,
116 name: repositories.name,
117 })
118 .from(repositories)
119 .innerJoin(users, eq(repositories.ownerId, users.id))
120 .where(and(eq(users.username, owner), eq(repositories.name, name)))
121 .limit(1);
122 return row || null;
123 } catch {
124 return null;
125 }
126}
127
128async function resolvePullRequestId(
129 repositoryId: string,
130 number: number | undefined
131): Promise<string | null> {
132 if (!number) return null;
133 try {
134 const [row] = await db
135 .select({ id: pullRequests.id })
136 .from(pullRequests)
137 .where(
138 and(
139 eq(pullRequests.repositoryId, repositoryId),
140 eq(pullRequests.number, number)
141 )
142 )
143 .limit(1);
144 return row?.id || null;
145 } catch {
146 return null;
147 }
148}
149
150/**
151 * POST /api/hooks/gatetest
152 * Async scan result callback from GateTest.
153 */
154hooks.post("/api/hooks/gatetest", async (c) => {
155 const rawBody = await c.req.text();
156
157 const auth = verifyGateTestAuth(c, rawBody);
158 if (!auth.ok) {
159 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
160 }
161
162 let payload: GateTestPayload;
163 try {
164 payload = JSON.parse(rawBody) as GateTestPayload;
165 } catch {
166 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
167 }
168
169 if (!payload.repository || !payload.sha || !payload.status) {
170 return c.json(
171 {
172 ok: false,
173 error: "Required fields: repository (owner/name), sha, status",
174 },
175 400
176 );
177 }
178
179 const repo = await resolveRepo(payload.repository);
180 if (!repo) {
181 return c.json(
182 { ok: false, error: `Unknown repository: ${payload.repository}` },
183 404
184 );
185 }
186
187 const normalisedStatus =
188 payload.status === "passed" || payload.status === "success"
189 ? "passed"
190 : payload.status === "failed"
191 ? "failed"
192 : "failed"; // treat "error" as a hard fail
193
194 const pullRequestId = await resolvePullRequestId(
195 repo.id,
196 payload.pullRequestNumber
197 );
198
199 let gateRunId: string | null = null;
200 try {
201 const [row] = await db
202 .insert(gateRuns)
203 .values({
204 repositoryId: repo.id,
205 pullRequestId: pullRequestId || undefined,
206 commitSha: payload.sha,
207 ref: payload.ref || "refs/heads/main",
208 gateName: "GateTest",
209 status: normalisedStatus,
210 summary: payload.summary || `GateTest reported ${normalisedStatus}`,
211 details: payload.details ? JSON.stringify(payload.details) : null,
212 durationMs: payload.durationMs,
213 completedAt: new Date(),
214 })
215 .returning({ id: gateRuns.id });
216 gateRunId = row?.id || null;
217 } catch (err) {
218 console.error("[hooks/gatetest] insert failed:", err);
219 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
220 }
221
222 // Notify the repo owner on failure
223 if (normalisedStatus === "failed") {
224 try {
225 await notify(repo.ownerId, {
226 kind: "gate_failed",
227 title: `GateTest failed on ${payload.repository}`,
228 body: payload.summary || "GateTest reported failure via callback",
229 repositoryId: repo.id,
230 });
231 } catch (err) {
232 console.error("[hooks/gatetest] notify failed:", err);
233 }
234 }
235
236 try {
237 await audit({
238 userId: null,
239 action: "gate_callback",
240 repositoryId: repo.id,
241 metadata: {
242 gateName: "GateTest",
243 sha: payload.sha,
244 status: normalisedStatus,
245 source: "gatetest-callback",
246 },
247 });
248 } catch {
249 /* swallow */
250 }
251
252 return c.json({ ok: true, gateRunId });
253});
254
255/**
256 * GET /api/hooks/gatetest/recent
257 * Small read-only endpoint GateTest can hit to verify connectivity +
258 * see the 10 most recent gate runs for sanity checks.
259 */
260hooks.get("/api/hooks/gatetest/recent", async (c) => {
261 const rawBody = "";
262 const auth = verifyGateTestAuth(c, rawBody);
263 if (!auth.ok) {
264 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
265 }
266
267 try {
268 const rows = await db
269 .select({
270 id: gateRuns.id,
271 gateName: gateRuns.gateName,
272 status: gateRuns.status,
273 summary: gateRuns.summary,
274 createdAt: gateRuns.createdAt,
275 })
276 .from(gateRuns)
277 .orderBy(desc(gateRuns.createdAt))
278 .limit(10);
279 return c.json({ ok: true, runs: rows });
280 } catch (err) {
281 console.error("[hooks/gatetest] recent failed:", err);
282 return c.json({ ok: false, error: "DB error" }, 500);
283 }
284});
285
286/**
287 * GET /api/hooks/ping
288 * Unauthenticated liveness — for GateTest to probe reachability without
289 * needing credentials. Returns 200 + version info.
290 */
291hooks.get("/api/hooks/ping", (c) => {
292 return c.json({
293 ok: true,
294 service: "gluecron",
295 hooks: ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
296 timestamp: new Date().toISOString(),
297 });
298});
299
300// ---------------------------------------------------------------------------
301// Backup API: personal-access-token path
302//
303// If for any reason the shared-secret callback path is unavailable,
304// GateTest can authenticate with a standard GlueCron personal access token
305// and POST the same payload to /api/v1/gate-runs.
306//
307// Advantages of the backup path:
308// - no new secrets to provision (reuses existing PAT infra)
309// - scoped per-user (audit trail points at a real account)
310// - revocable from /settings/tokens
311//
312// Trade-off: slightly heavier auth (DB lookup per call), so prefer
313// /api/hooks/gatetest for high-volume traffic.
314// ---------------------------------------------------------------------------
315
316async function hashPat(token: string): Promise<string> {
317 const data = new TextEncoder().encode(token);
318 const hash = await crypto.subtle.digest("SHA-256", data);
319 return Array.from(new Uint8Array(hash))
320 .map((b) => b.toString(16).padStart(2, "0"))
321 .join("");
322}
323
324async function verifyPatAuth(
325 c: any
326): Promise<{ ok: boolean; userId?: string; error?: string }> {
327 const auth = c.req.header("authorization") || "";
328 const rawToken =
329 (auth.startsWith("Bearer ") ? auth.slice(7) : "").trim() ||
330 (c.req.header("x-api-token") || "").trim();
331 if (!rawToken) {
332 return { ok: false, error: "Missing Bearer token" };
333 }
334 if (!rawToken.startsWith("glc_")) {
335 return { ok: false, error: "Invalid token format" };
336 }
337 try {
338 const hashed = await hashPat(rawToken);
339 const [row] = await db
340 .select({ id: apiTokens.id, userId: apiTokens.userId, expiresAt: apiTokens.expiresAt })
341 .from(apiTokens)
342 .where(eq(apiTokens.tokenHash, hashed))
343 .limit(1);
344 if (!row) return { ok: false, error: "Unknown token" };
345 if (row.expiresAt && row.expiresAt < new Date()) {
346 return { ok: false, error: "Token expired" };
347 }
a28cedeClaude348 // Update last-used timestamp (fire and forget). Log persistent
349 // failures so DB issues surface (was silent .catch(() => {}).)
ad6d4adClaude350 db.update(apiTokens)
351 .set({ lastUsedAt: new Date() })
352 .where(eq(apiTokens.id, row.id))
a28cedeClaude353 .catch((err) => {
354 console.warn(
355 "[hooks] PAT lastUsedAt update failed:",
356 err instanceof Error ? err.message : err
357 );
358 });
ad6d4adClaude359 return { ok: true, userId: row.userId };
360 } catch {
361 return { ok: false, error: "Auth lookup failed" };
362 }
363}
364
365/**
366 * POST /api/v1/gate-runs
367 * Backup path — accepts a personal access token and records a gate run.
368 * Identical payload shape to /api/hooks/gatetest.
369 */
370hooks.post("/api/v1/gate-runs", async (c) => {
371 const rawBody = await c.req.text();
372
373 const auth = await verifyPatAuth(c);
374 if (!auth.ok) {
375 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
376 }
377
378 let payload: GateTestPayload & { gateName?: string };
379 try {
380 payload = JSON.parse(rawBody);
381 } catch {
382 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
383 }
384
385 if (!payload.repository || !payload.sha || !payload.status) {
386 return c.json(
387 { ok: false, error: "Required: repository, sha, status" },
388 400
389 );
390 }
391
392 const repo = await resolveRepo(payload.repository);
393 if (!repo) {
394 return c.json(
395 { ok: false, error: `Unknown repository: ${payload.repository}` },
396 404
397 );
398 }
399
400 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
401 // For MVP, require the token owner to match the repo owner.
402 if (repo.ownerId !== auth.userId) {
403 return c.json(
404 { ok: false, error: "Token does not own this repository" },
405 403
406 );
407 }
408
409 const normalisedStatus =
410 payload.status === "passed" || payload.status === "success"
411 ? "passed"
412 : payload.status === "failed"
413 ? "failed"
414 : "failed";
415
416 const pullRequestId = await resolvePullRequestId(
417 repo.id,
418 payload.pullRequestNumber
419 );
420
421 let gateRunId: string | null = null;
422 try {
423 const [row] = await db
424 .insert(gateRuns)
425 .values({
426 repositoryId: repo.id,
427 pullRequestId: pullRequestId || undefined,
428 commitSha: payload.sha,
429 ref: payload.ref || "refs/heads/main",
430 gateName: payload.gateName || "GateTest",
431 status: normalisedStatus,
432 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
433 details: payload.details ? JSON.stringify(payload.details) : null,
434 durationMs: payload.durationMs,
435 completedAt: new Date(),
436 })
437 .returning({ id: gateRuns.id });
438 gateRunId = row?.id || null;
439 } catch (err) {
440 console.error("[hooks/backup] insert failed:", err);
441 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
442 }
443
444 if (normalisedStatus === "failed") {
445 try {
446 await notify(repo.ownerId, {
447 kind: "gate_failed",
448 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
449 body: payload.summary || "Gate failure reported via backup API",
450 repositoryId: repo.id,
451 });
452 } catch {
453 /* swallow */
454 }
455 }
456
457 try {
458 await audit({
459 userId: auth.userId,
460 action: "gate_callback_backup",
461 repositoryId: repo.id,
462 metadata: {
463 gateName: payload.gateName || "GateTest",
464 sha: payload.sha,
465 status: normalisedStatus,
466 source: "pat-api",
467 },
468 });
469 } catch {
470 /* swallow */
471 }
472
473 return c.json({ ok: true, gateRunId });
474});
475
476/**
477 * GET /api/v1/gate-runs?repository=owner/name&limit=20
478 * Backup read path — list recent gate runs for a repo (PAT-authed).
479 */
480hooks.get("/api/v1/gate-runs", async (c) => {
481 const auth = await verifyPatAuth(c);
482 if (!auth.ok) {
483 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
484 }
485
486 const repoFull = c.req.query("repository") || "";
487 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
488 if (!repoFull) {
489 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
490 }
491
492 const repo = await resolveRepo(repoFull);
493 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
494 if (repo.ownerId !== auth.userId) {
495 return c.json({ ok: false, error: "Forbidden" }, 403);
496 }
497
498 try {
499 const rows = await db
500 .select()
501 .from(gateRuns)
502 .where(eq(gateRuns.repositoryId, repo.id))
503 .orderBy(desc(gateRuns.createdAt))
504 .limit(limit);
505 return c.json({ ok: true, runs: rows });
506 } catch {
507 return c.json({ ok: false, error: "DB error" }, 500);
508 }
509});
510
511export default hooks;