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.tsBlame596 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";
eead172Claude41import {
42 generatePatchForGateTestFinding,
43 severityAtOrAboveMedium,
44 type GateTestFinding,
45} from "../lib/ai-patch-generator";
46import { config } from "../lib/config";
ad6d4adClaude47
48const hooks = new Hono();
49
eead172Claude50/**
51 * Best-effort extraction of an array of GateTest findings from a
52 * webhook payload's `details` blob. GateTest's schema isn't pinned in
53 * code yet, so we accept a handful of common shapes:
54 * - `details.findings: [...]`
55 * - `details.issues: [...]`
56 * - `details.results: [...]`
57 * - `details: [...]` (raw array)
58 */
59function extractFindings(details: unknown): GateTestFinding[] {
60 if (!details) return [];
61 if (Array.isArray(details)) return details as GateTestFinding[];
62 if (typeof details === "object") {
63 const d = details as Record<string, unknown>;
64 for (const key of ["findings", "issues", "results", "violations"]) {
65 const v = d[key];
66 if (Array.isArray(v)) return v as GateTestFinding[];
67 }
68 }
69 return [];
70}
71
ad6d4adClaude72interface GateTestPayload {
73 repository?: string;
74 sha?: string;
75 ref?: string;
76 pullRequestNumber?: number;
77 status?: "passed" | "failed" | "error" | "success";
78 summary?: string;
79 details?: unknown;
80 durationMs?: number;
81}
82
83function constantTimeEq(a: string, b: string): boolean {
84 const A = Buffer.from(a);
85 const B = Buffer.from(b);
86 if (A.length !== B.length) return false;
87 try {
88 return timingSafeEqual(A, B);
89 } catch {
90 return false;
91 }
92}
93
94function verifyGateTestAuth(c: any, rawBody: string): { ok: boolean; error?: string } {
95 const bearerSecret = process.env.GATETEST_CALLBACK_SECRET || "";
96 const hmacSecret = process.env.GATETEST_HMAC_SECRET || "";
97
98 // If no secret is configured, refuse by default — do NOT allow anonymous writes.
99 if (!bearerSecret && !hmacSecret) {
100 return {
101 ok: false,
102 error:
103 "Callback endpoint not configured: set GATETEST_CALLBACK_SECRET or GATETEST_HMAC_SECRET",
104 };
105 }
106
107 // Bearer auth (simpler, preferred for server-to-server)
108 if (bearerSecret) {
109 const auth = c.req.header("authorization") || "";
110 if (auth.startsWith("Bearer ")) {
111 const token = auth.slice(7).trim();
112 if (constantTimeEq(token, bearerSecret)) return { ok: true };
113 }
114 // Alternative header for tools that don't send Authorization
115 const xTok = c.req.header("x-gatetest-token") || "";
116 if (xTok && constantTimeEq(xTok, bearerSecret)) return { ok: true };
117 }
118
119 // HMAC signature over the raw body
120 if (hmacSecret) {
121 const sigHeader =
122 c.req.header("x-gatetest-signature") ||
123 c.req.header("x-signature-sha256") ||
124 "";
125 if (sigHeader) {
126 const expected =
127 "sha256=" +
128 createHmac("sha256", hmacSecret).update(rawBody).digest("hex");
129 if (constantTimeEq(sigHeader, expected)) return { ok: true };
130 }
131 }
132
133 return { ok: false, error: "Invalid or missing GateTest credentials" };
134}
135
136async function resolveRepo(full: string): Promise<{ id: string; ownerId: string; name: string } | null> {
137 if (!full || !full.includes("/")) return null;
138 const [owner, name] = full.split("/", 2);
139 try {
140 const [row] = await db
141 .select({
142 id: repositories.id,
143 ownerId: repositories.ownerId,
144 name: repositories.name,
145 })
146 .from(repositories)
147 .innerJoin(users, eq(repositories.ownerId, users.id))
148 .where(and(eq(users.username, owner), eq(repositories.name, name)))
149 .limit(1);
150 return row || null;
151 } catch {
152 return null;
153 }
154}
155
156async function resolvePullRequestId(
157 repositoryId: string,
158 number: number | undefined
159): Promise<string | null> {
160 if (!number) return null;
161 try {
162 const [row] = await db
163 .select({ id: pullRequests.id })
164 .from(pullRequests)
165 .where(
166 and(
167 eq(pullRequests.repositoryId, repositoryId),
168 eq(pullRequests.number, number)
169 )
170 )
171 .limit(1);
172 return row?.id || null;
173 } catch {
174 return null;
175 }
176}
177
178/**
179 * POST /api/hooks/gatetest
180 * Async scan result callback from GateTest.
181 */
182hooks.post("/api/hooks/gatetest", async (c) => {
183 const rawBody = await c.req.text();
184
185 const auth = verifyGateTestAuth(c, rawBody);
186 if (!auth.ok) {
187 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
188 }
189
190 let payload: GateTestPayload;
191 try {
192 payload = JSON.parse(rawBody) as GateTestPayload;
193 } catch {
194 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
195 }
196
197 if (!payload.repository || !payload.sha || !payload.status) {
198 return c.json(
199 {
200 ok: false,
201 error: "Required fields: repository (owner/name), sha, status",
202 },
203 400
204 );
205 }
206
207 const repo = await resolveRepo(payload.repository);
208 if (!repo) {
209 return c.json(
210 { ok: false, error: `Unknown repository: ${payload.repository}` },
211 404
212 );
213 }
214
215 const normalisedStatus =
216 payload.status === "passed" || payload.status === "success"
217 ? "passed"
218 : payload.status === "failed"
219 ? "failed"
220 : "failed"; // treat "error" as a hard fail
221
222 const pullRequestId = await resolvePullRequestId(
223 repo.id,
224 payload.pullRequestNumber
225 );
226
227 let gateRunId: string | null = null;
228 try {
229 const [row] = await db
230 .insert(gateRuns)
231 .values({
232 repositoryId: repo.id,
233 pullRequestId: pullRequestId || undefined,
234 commitSha: payload.sha,
235 ref: payload.ref || "refs/heads/main",
236 gateName: "GateTest",
237 status: normalisedStatus,
238 summary: payload.summary || `GateTest reported ${normalisedStatus}`,
239 details: payload.details ? JSON.stringify(payload.details) : null,
240 durationMs: payload.durationMs,
241 completedAt: new Date(),
242 })
243 .returning({ id: gateRuns.id });
244 gateRunId = row?.id || null;
245 } catch (err) {
246 console.error("[hooks/gatetest] insert failed:", err);
247 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
248 }
249
250 // Notify the repo owner on failure
251 if (normalisedStatus === "failed") {
252 try {
253 await notify(repo.ownerId, {
254 kind: "gate_failed",
255 title: `GateTest failed on ${payload.repository}`,
256 body: payload.summary || "GateTest reported failure via callback",
257 repositoryId: repo.id,
258 });
259 } catch (err) {
260 console.error("[hooks/gatetest] notify failed:", err);
261 }
262 }
263
264 try {
265 await audit({
266 userId: null,
267 action: "gate_callback",
268 repositoryId: repo.id,
269 metadata: {
270 gateName: "GateTest",
271 sha: payload.sha,
272 status: normalisedStatus,
273 source: "gatetest-callback",
274 },
275 });
276 } catch {
277 /* swallow */
278 }
279
eead172Claude280 // AI patch generator — if the gate failed AND the env flag is on AND
281 // an Anthropic key is configured, fire-and-forget a patch PR for the
282 // first actionable medium+ severity finding. Never blocks the
283 // webhook response.
284 if (
285 normalisedStatus === "failed" &&
286 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
287 config.anthropicApiKey
288 ) {
289 const findings = extractFindings(payload.details).filter((f) =>
290 severityAtOrAboveMedium(f.severity)
291 );
292 if (findings.length > 0) {
293 generatePatchForGateTestFinding({
294 repositoryId: repo.id,
295 baseSha: payload.sha,
296 findings,
297 reportUrl:
298 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
299 ? ((payload.details as Record<string, string>).reportUrl)
300 : null,
301 }).catch((err) =>
302 console.error(
303 "[hooks/gatetest] AI patch generator crashed:",
304 err instanceof Error ? err.message : err
305 )
306 );
307 }
308 }
309
ad6d4adClaude310 return c.json({ ok: true, gateRunId });
311});
312
313/**
314 * GET /api/hooks/gatetest/recent
315 * Small read-only endpoint GateTest can hit to verify connectivity +
316 * see the 10 most recent gate runs for sanity checks.
317 */
318hooks.get("/api/hooks/gatetest/recent", async (c) => {
319 const rawBody = "";
320 const auth = verifyGateTestAuth(c, rawBody);
321 if (!auth.ok) {
322 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
323 }
324
325 try {
326 const rows = await db
327 .select({
328 id: gateRuns.id,
329 gateName: gateRuns.gateName,
330 status: gateRuns.status,
331 summary: gateRuns.summary,
332 createdAt: gateRuns.createdAt,
333 })
334 .from(gateRuns)
335 .orderBy(desc(gateRuns.createdAt))
336 .limit(10);
337 return c.json({ ok: true, runs: rows });
338 } catch (err) {
339 console.error("[hooks/gatetest] recent failed:", err);
340 return c.json({ ok: false, error: "DB error" }, 500);
341 }
342});
343
344/**
345 * GET /api/hooks/ping
346 * Unauthenticated liveness — for GateTest to probe reachability without
347 * needing credentials. Returns 200 + version info.
348 */
349hooks.get("/api/hooks/ping", (c) => {
350 return c.json({
351 ok: true,
352 service: "gluecron",
353 hooks: ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
354 timestamp: new Date().toISOString(),
355 });
356});
357
358// ---------------------------------------------------------------------------
359// Backup API: personal-access-token path
360//
361// If for any reason the shared-secret callback path is unavailable,
362// GateTest can authenticate with a standard GlueCron personal access token
363// and POST the same payload to /api/v1/gate-runs.
364//
365// Advantages of the backup path:
366// - no new secrets to provision (reuses existing PAT infra)
367// - scoped per-user (audit trail points at a real account)
368// - revocable from /settings/tokens
369//
370// Trade-off: slightly heavier auth (DB lookup per call), so prefer
371// /api/hooks/gatetest for high-volume traffic.
372// ---------------------------------------------------------------------------
373
374async function hashPat(token: string): Promise<string> {
375 const data = new TextEncoder().encode(token);
376 const hash = await crypto.subtle.digest("SHA-256", data);
377 return Array.from(new Uint8Array(hash))
378 .map((b) => b.toString(16).padStart(2, "0"))
379 .join("");
380}
381
382async function verifyPatAuth(
383 c: any
384): Promise<{ ok: boolean; userId?: string; error?: string }> {
385 const auth = c.req.header("authorization") || "";
386 const rawToken =
387 (auth.startsWith("Bearer ") ? auth.slice(7) : "").trim() ||
388 (c.req.header("x-api-token") || "").trim();
389 if (!rawToken) {
390 return { ok: false, error: "Missing Bearer token" };
391 }
392 if (!rawToken.startsWith("glc_")) {
393 return { ok: false, error: "Invalid token format" };
394 }
395 try {
396 const hashed = await hashPat(rawToken);
397 const [row] = await db
398 .select({ id: apiTokens.id, userId: apiTokens.userId, expiresAt: apiTokens.expiresAt })
399 .from(apiTokens)
400 .where(eq(apiTokens.tokenHash, hashed))
401 .limit(1);
402 if (!row) return { ok: false, error: "Unknown token" };
403 if (row.expiresAt && row.expiresAt < new Date()) {
404 return { ok: false, error: "Token expired" };
405 }
a28cedeClaude406 // Update last-used timestamp (fire and forget). Log persistent
407 // failures so DB issues surface (was silent .catch(() => {}).)
ad6d4adClaude408 db.update(apiTokens)
409 .set({ lastUsedAt: new Date() })
410 .where(eq(apiTokens.id, row.id))
a28cedeClaude411 .catch((err) => {
412 console.warn(
413 "[hooks] PAT lastUsedAt update failed:",
414 err instanceof Error ? err.message : err
415 );
416 });
ad6d4adClaude417 return { ok: true, userId: row.userId };
418 } catch {
419 return { ok: false, error: "Auth lookup failed" };
420 }
421}
422
423/**
424 * POST /api/v1/gate-runs
425 * Backup path — accepts a personal access token and records a gate run.
426 * Identical payload shape to /api/hooks/gatetest.
427 */
428hooks.post("/api/v1/gate-runs", async (c) => {
429 const rawBody = await c.req.text();
430
431 const auth = await verifyPatAuth(c);
432 if (!auth.ok) {
433 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
434 }
435
436 let payload: GateTestPayload & { gateName?: string };
437 try {
438 payload = JSON.parse(rawBody);
439 } catch {
440 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
441 }
442
443 if (!payload.repository || !payload.sha || !payload.status) {
444 return c.json(
445 { ok: false, error: "Required: repository, sha, status" },
446 400
447 );
448 }
449
450 const repo = await resolveRepo(payload.repository);
451 if (!repo) {
452 return c.json(
453 { ok: false, error: `Unknown repository: ${payload.repository}` },
454 404
455 );
456 }
457
458 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
459 // For MVP, require the token owner to match the repo owner.
460 if (repo.ownerId !== auth.userId) {
461 return c.json(
462 { ok: false, error: "Token does not own this repository" },
463 403
464 );
465 }
466
467 const normalisedStatus =
468 payload.status === "passed" || payload.status === "success"
469 ? "passed"
470 : payload.status === "failed"
471 ? "failed"
472 : "failed";
473
474 const pullRequestId = await resolvePullRequestId(
475 repo.id,
476 payload.pullRequestNumber
477 );
478
479 let gateRunId: string | null = null;
480 try {
481 const [row] = await db
482 .insert(gateRuns)
483 .values({
484 repositoryId: repo.id,
485 pullRequestId: pullRequestId || undefined,
486 commitSha: payload.sha,
487 ref: payload.ref || "refs/heads/main",
488 gateName: payload.gateName || "GateTest",
489 status: normalisedStatus,
490 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
491 details: payload.details ? JSON.stringify(payload.details) : null,
492 durationMs: payload.durationMs,
493 completedAt: new Date(),
494 })
495 .returning({ id: gateRuns.id });
496 gateRunId = row?.id || null;
497 } catch (err) {
498 console.error("[hooks/backup] insert failed:", err);
499 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
500 }
501
502 if (normalisedStatus === "failed") {
503 try {
504 await notify(repo.ownerId, {
505 kind: "gate_failed",
506 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
507 body: payload.summary || "Gate failure reported via backup API",
508 repositoryId: repo.id,
509 });
510 } catch {
511 /* swallow */
512 }
513 }
514
515 try {
516 await audit({
517 userId: auth.userId,
518 action: "gate_callback_backup",
519 repositoryId: repo.id,
520 metadata: {
521 gateName: payload.gateName || "GateTest",
522 sha: payload.sha,
523 status: normalisedStatus,
524 source: "pat-api",
525 },
526 });
527 } catch {
528 /* swallow */
529 }
530
eead172Claude531 // Same fire-and-forget AI patch hook as the primary callback path.
532 if (
533 normalisedStatus === "failed" &&
534 process.env.AI_PATCH_GENERATOR_ENABLED === "1" &&
535 config.anthropicApiKey
536 ) {
537 const findings = extractFindings(payload.details).filter((f) =>
538 severityAtOrAboveMedium(f.severity)
539 );
540 if (findings.length > 0) {
541 generatePatchForGateTestFinding({
542 repositoryId: repo.id,
543 baseSha: payload.sha,
544 findings,
545 reportUrl:
546 typeof (payload.details as Record<string, unknown> | null)?.reportUrl === "string"
547 ? ((payload.details as Record<string, string>).reportUrl)
548 : null,
549 }).catch((err) =>
550 console.error(
551 "[hooks/backup] AI patch generator crashed:",
552 err instanceof Error ? err.message : err
553 )
554 );
555 }
556 }
557
ad6d4adClaude558 return c.json({ ok: true, gateRunId });
559});
560
561/**
562 * GET /api/v1/gate-runs?repository=owner/name&limit=20
563 * Backup read path — list recent gate runs for a repo (PAT-authed).
564 */
565hooks.get("/api/v1/gate-runs", async (c) => {
566 const auth = await verifyPatAuth(c);
567 if (!auth.ok) {
568 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
569 }
570
571 const repoFull = c.req.query("repository") || "";
572 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
573 if (!repoFull) {
574 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
575 }
576
577 const repo = await resolveRepo(repoFull);
578 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
579 if (repo.ownerId !== auth.userId) {
580 return c.json({ ok: false, error: "Forbidden" }, 403);
581 }
582
583 try {
584 const rows = await db
585 .select()
586 .from(gateRuns)
587 .where(eq(gateRuns.repositoryId, repo.id))
588 .orderBy(desc(gateRuns.createdAt))
589 .limit(limit);
590 return c.json({ ok: true, runs: rows });
591 } catch {
592 return c.json({ ok: false, error: "DB error" }, 500);
593 }
594});
595
596export default hooks;