Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitad6d4adunknown_key

feat: inbound GateTest callback hook + PAT-authed backup API

feat: inbound GateTest callback hook + PAT-authed backup API

GateTest previously had no way to report async scan results back to
GlueCron. This adds two inbound paths:

Primary: POST /api/hooks/gatetest
  - Bearer token (GATETEST_CALLBACK_SECRET) or HMAC-SHA256
    (GATETEST_HMAC_SECRET) over raw body
  - Timing-safe credential comparison
  - Inserts into gate_runs, notifies repo owner on fail, writes audit_log
  - Refuses all traffic if no secret configured (no anonymous writes)

Backup: POST /api/v1/gate-runs
  - Authenticated with a standard GlueCron personal access token (glc_*)
  - Same payload shape as the primary path
  - Scopes to the token owner's repos
  - GET /api/v1/gate-runs?repository=... for listing
  - Advantage: nothing new to provision, revocable from /settings/tokens

Plus GET /api/hooks/ping — unauthenticated liveness probe.

GATETEST_HOOK.md documents the exact URLs, headers, payload, and
examples for GateTest to consume. BUILD_BIBLE.md parity scorecard
updated and new routes added to locked-blocks list. Five new tests;
81/81 pass.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 14, 2026Parent: a64e08d
6 files changed+7671ad6d4ad050c04cb4e292d2d7cbf4df4254907339
6 changed files+767−1
Modified.env.example+5−0View fileUnifiedSplit
33PORT=3000
44GATETEST_URL=https://gatetest.ai/api/scan/run
55GATETEST_API_KEY=
6# Inbound GateTest callback auth — set one so GateTest can POST results back.
7# See GATETEST_HOOK.md for payload + endpoint details.
8# Generate a secret with: openssl rand -hex 32
9GATETEST_CALLBACK_SECRET=
10GATETEST_HMAC_SECRET=
611CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
712ANTHROPIC_API_KEY=
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
102102### 2.4 Automation + AI
103103| Feature | Status | Notes |
104104|---|---|---|
105| Webhooks | ✅ | HMAC signed |
105| Webhooks (outbound, HMAC signed) | ✅ | `src/routes/webhooks.tsx` |
106| GateTest inbound callback | ✅ | `POST /api/hooks/gatetest`, bearer or HMAC |
107| Backup PAT-auth gate ingest | ✅ | `POST /api/v1/gate-runs` |
106108| Gate runs (test / secret / AI review) | ✅ | `gate_runs` table, `src/routes/gates.tsx` |
107109| Branch protection | ✅ | `branch_protection` table + UI |
108110| Auto-repair engine | ✅ | `src/lib/auto-repair.ts` |
280282### 4.6 Routes (locked endpoints — behaviour must be preserved)
281283- `src/routes/git.ts` — Smart HTTP (clone/push)
282284- `src/routes/api.ts` — REST (`POST /api/repos`, `GET /api/users/:u/repos`, `GET /api/repos/:o/:n`, `POST /api/setup`)
285- `src/routes/hooks.ts``POST /api/hooks/gatetest` (bearer/HMAC), `GET /api/hooks/ping`, `POST /api/v1/gate-runs` (PAT backup), `GET /api/v1/gate-runs`. See `GATETEST_HOOK.md`.
283286- `src/routes/auth.tsx` — register / login / logout
284287- `src/routes/web.tsx` — home / new / browse / blob / commits / raw / blame / star / search / profile
285288- `src/routes/issues.tsx` — issue CRUD + comments + labels + lock
AddedGATETEST_HOOK.md+182−0View fileUnifiedSplit
1# GateTest ↔ GlueCron integration
2
3GlueCron exposes **two** inbound paths for GateTest to report scan results back.
4Use the primary path for normal traffic; the backup path exists so you always
5have a way in if the shared secret is misconfigured.
6
7Base URL: `https://<your-gluecron-host>` (e.g. `https://gluecron.com`)
8
9---
10
11## Primary: shared-secret hook
12
13**URL:** `POST /api/hooks/gatetest`
14
15### Auth (pick one)
16
17**Option A — Bearer token**
18```
19Authorization: Bearer <GATETEST_CALLBACK_SECRET>
20```
21or if the GateTest side can't set `Authorization`:
22```
23X-GateTest-Token: <GATETEST_CALLBACK_SECRET>
24```
25
26**Option B — HMAC-SHA256 over raw body**
27```
28X-GateTest-Signature: sha256=<hex(hmac_sha256(GATETEST_HMAC_SECRET, rawBody))>
29```
30
31Both are compared with a timing-safe equality check.
32
33### Request body (JSON)
34
35```json
36{
37 "repository": "owner/name",
38 "sha": "0123abcd0123abcd0123abcd0123abcd01234567",
39 "ref": "refs/heads/main",
40 "pullRequestNumber": 42,
41 "status": "passed",
42 "summary": "12 tests passed, 0 failed, 3.4s",
43 "details": { "...": "arbitrary JSON, persisted as-is" },
44 "durationMs": 3400
45}
46```
47
48| Field | Required | Notes |
49|---|---|---|
50| `repository` | ✅ | `"owner/name"` |
51| `sha` | ✅ | full 40-char commit SHA |
52| `status` | ✅ | `"passed"` \| `"failed"` \| `"error"` \| `"success"` |
53| `ref` | optional | defaults to `refs/heads/main` |
54| `pullRequestNumber` | optional | links the gate run to a PR |
55| `summary` | optional | shown on the gates UI |
56| `details` | optional | arbitrary JSON, stored verbatim |
57| `durationMs` | optional | for metrics |
58
59### Response
60
61```json
62{ "ok": true, "gateRunId": "uuid-of-the-inserted-row" }
63```
64
65On failure:
66- `400` — invalid JSON or missing required fields
67- `401` — missing / invalid credentials
68- `404` — repository not known to GlueCron
69- `500` — DB error (retry-safe, idempotent on sha + gate_name)
70
71### Side effects on a `failed` status
721. Row inserted in `gate_runs`
732. In-app notification to the repo owner (kind `gate_failed`)
743. Entry in `audit_log` (action `gate_callback`)
75
76---
77
78## Backup: personal access token
79
80**URL:** `POST /api/v1/gate-runs`
81
82### Auth
83Standard GlueCron PAT, created at `/settings/tokens`:
84```
85Authorization: Bearer glc_<64-hex-chars>
86```
87Alternative header if Bearer isn't possible:
88```
89X-API-Token: glc_<64-hex-chars>
90```
91
92### Request body
93Identical to the primary path, plus optional `gateName`:
94```json
95{
96 "repository": "owner/name",
97 "sha": "...",
98 "status": "passed",
99 "gateName": "GateTest",
100 "summary": "...",
101 "details": { }
102}
103```
104
105### Authorisation rules
106- The PAT's owner must match the repo's owner (MVP rule — will expand with orgs/teams in Block B).
107- Token is rejected if expired.
108- Successful use updates `api_tokens.last_used_at`.
109
110### Listing prior runs
111```
112GET /api/v1/gate-runs?repository=owner/name&limit=20
113Authorization: Bearer glc_...
114```
115
116Returns:
117```json
118{ "ok": true, "runs": [ {...}, ... ] }
119```
120
121---
122
123## Liveness probe (no auth)
124
125```
126GET /api/hooks/ping
127```
128Returns:
129```json
130{
131 "ok": true,
132 "service": "gluecron",
133 "hooks": ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
134 "timestamp": "2026-04-14T..."
135}
136```
137
138Use this in GateTest's connectivity check before trying an authenticated call.
139
140---
141
142## Configuring the secrets
143
144On the GlueCron host (Railway / Fly / Docker) set either or both:
145```
146GATETEST_CALLBACK_SECRET=<long random bearer token>
147GATETEST_HMAC_SECRET=<long random HMAC key>
148```
149
150Generate with:
151```bash
152openssl rand -hex 32
153```
154
155Share the same value with GateTest. **If neither is set, the callback endpoint
156refuses all requests** — there is no anonymous write path by design.
157
158---
159
160## Example curl (primary path)
161
162```bash
163curl -X POST "https://gluecron.com/api/hooks/gatetest" \
164 -H "Authorization: Bearer $GATETEST_CALLBACK_SECRET" \
165 -H "Content-Type: application/json" \
166 -d '{
167 "repository": "alice/webapp",
168 "sha": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b",
169 "ref": "refs/heads/main",
170 "status": "failed",
171 "summary": "2 tests failed in src/auth.test.ts",
172 "durationMs": 4812
173 }'
174```
175
176Response:
177```json
178{ "ok": true, "gateRunId": "b7f3e2a1-..." }
179```
180
181The repo owner immediately sees a red notification; the run shows up at
182`/<owner>/<repo>/gates`.
Modifiedsrc/__tests__/green-ecosystem.test.ts+67−0View fileUnifiedSplit
156156 expect(html).toContain("Users");
157157 });
158158});
159
160describe("GateTest inbound hook", () => {
161 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
162 const res = await app.request("/api/hooks/ping");
163 expect(res.status).toBe(200);
164 const body = await res.json();
165 expect(body.ok).toBe(true);
166 expect(body.service).toBe("gluecron");
167 expect(Array.isArray(body.hooks)).toBe(true);
168 });
169
170 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
171 const prev = process.env.GATETEST_CALLBACK_SECRET;
172 const prevH = process.env.GATETEST_HMAC_SECRET;
173 delete process.env.GATETEST_CALLBACK_SECRET;
174 delete process.env.GATETEST_HMAC_SECRET;
175 const res = await app.request("/api/hooks/gatetest", {
176 method: "POST",
177 headers: { "content-type": "application/json" },
178 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
179 });
180 expect(res.status).toBe(401);
181 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
182 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
183 });
184
185 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
186 const prev = process.env.GATETEST_CALLBACK_SECRET;
187 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
188 const res = await app.request("/api/hooks/gatetest", {
189 method: "POST",
190 headers: {
191 "content-type": "application/json",
192 authorization: "Bearer wrong-token",
193 },
194 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
195 });
196 expect(res.status).toBe(401);
197 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
198 else process.env.GATETEST_CALLBACK_SECRET = prev;
199 });
200
201 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
202 const prev = process.env.GATETEST_CALLBACK_SECRET;
203 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
204 const res = await app.request("/api/hooks/gatetest", {
205 method: "POST",
206 headers: {
207 "content-type": "application/json",
208 authorization: "Bearer real-secret-abc123",
209 },
210 body: "not-json",
211 });
212 expect(res.status).toBe(400);
213 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
214 else process.env.GATETEST_CALLBACK_SECRET = prev;
215 });
216
217 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
218 const res = await app.request("/api/v1/gate-runs", {
219 method: "POST",
220 headers: { "content-type": "application/json" },
221 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
222 });
223 expect(res.status).toBe(401);
224 });
225});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
2727import insightsRoutes from "./routes/insights";
2828import searchRoutes from "./routes/search";
2929import healthRoutes from "./routes/health";
30import hookRoutes from "./routes/hooks";
3031import webRoutes from "./routes/web";
3132
3233const app = new Hono();
5253// Health + metrics
5354app.route("/", healthRoutes);
5455
56// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
57app.route("/", hookRoutes);
58
5559// REST API
5660app.route("/", apiRoutes);
5761
Addedsrc/routes/hooks.ts+505−0View fileUnifiedSplit
1/**
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 }
348 // Update last-used timestamp (fire and forget)
349 db.update(apiTokens)
350 .set({ lastUsedAt: new Date() })
351 .where(eq(apiTokens.id, row.id))
352 .catch(() => {});
353 return { ok: true, userId: row.userId };
354 } catch {
355 return { ok: false, error: "Auth lookup failed" };
356 }
357}
358
359/**
360 * POST /api/v1/gate-runs
361 * Backup path — accepts a personal access token and records a gate run.
362 * Identical payload shape to /api/hooks/gatetest.
363 */
364hooks.post("/api/v1/gate-runs", async (c) => {
365 const rawBody = await c.req.text();
366
367 const auth = await verifyPatAuth(c);
368 if (!auth.ok) {
369 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
370 }
371
372 let payload: GateTestPayload & { gateName?: string };
373 try {
374 payload = JSON.parse(rawBody);
375 } catch {
376 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
377 }
378
379 if (!payload.repository || !payload.sha || !payload.status) {
380 return c.json(
381 { ok: false, error: "Required: repository, sha, status" },
382 400
383 );
384 }
385
386 const repo = await resolveRepo(payload.repository);
387 if (!repo) {
388 return c.json(
389 { ok: false, error: `Unknown repository: ${payload.repository}` },
390 404
391 );
392 }
393
394 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
395 // For MVP, require the token owner to match the repo owner.
396 if (repo.ownerId !== auth.userId) {
397 return c.json(
398 { ok: false, error: "Token does not own this repository" },
399 403
400 );
401 }
402
403 const normalisedStatus =
404 payload.status === "passed" || payload.status === "success"
405 ? "passed"
406 : payload.status === "failed"
407 ? "failed"
408 : "failed";
409
410 const pullRequestId = await resolvePullRequestId(
411 repo.id,
412 payload.pullRequestNumber
413 );
414
415 let gateRunId: string | null = null;
416 try {
417 const [row] = await db
418 .insert(gateRuns)
419 .values({
420 repositoryId: repo.id,
421 pullRequestId: pullRequestId || undefined,
422 commitSha: payload.sha,
423 ref: payload.ref || "refs/heads/main",
424 gateName: payload.gateName || "GateTest",
425 status: normalisedStatus,
426 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
427 details: payload.details ? JSON.stringify(payload.details) : null,
428 durationMs: payload.durationMs,
429 completedAt: new Date(),
430 })
431 .returning({ id: gateRuns.id });
432 gateRunId = row?.id || null;
433 } catch (err) {
434 console.error("[hooks/backup] insert failed:", err);
435 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
436 }
437
438 if (normalisedStatus === "failed") {
439 try {
440 await notify(repo.ownerId, {
441 kind: "gate_failed",
442 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
443 body: payload.summary || "Gate failure reported via backup API",
444 repositoryId: repo.id,
445 });
446 } catch {
447 /* swallow */
448 }
449 }
450
451 try {
452 await audit({
453 userId: auth.userId,
454 action: "gate_callback_backup",
455 repositoryId: repo.id,
456 metadata: {
457 gateName: payload.gateName || "GateTest",
458 sha: payload.sha,
459 status: normalisedStatus,
460 source: "pat-api",
461 },
462 });
463 } catch {
464 /* swallow */
465 }
466
467 return c.json({ ok: true, gateRunId });
468});
469
470/**
471 * GET /api/v1/gate-runs?repository=owner/name&limit=20
472 * Backup read path — list recent gate runs for a repo (PAT-authed).
473 */
474hooks.get("/api/v1/gate-runs", async (c) => {
475 const auth = await verifyPatAuth(c);
476 if (!auth.ok) {
477 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
478 }
479
480 const repoFull = c.req.query("repository") || "";
481 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
482 if (!repoFull) {
483 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
484 }
485
486 const repo = await resolveRepo(repoFull);
487 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
488 if (repo.ownerId !== auth.userId) {
489 return c.json({ ok: false, error: "Forbidden" }, 403);
490 }
491
492 try {
493 const rows = await db
494 .select()
495 .from(gateRuns)
496 .where(eq(gateRuns.repositoryId, repo.id))
497 .orderBy(desc(gateRuns.createdAt))
498 .limit(limit);
499 return c.json({ ok: true, runs: rows });
500 } catch {
501 return c.json({ ok: false, error: "DB error" }, 500);
502 }
503});
504
505export default hooks;
0506