Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

signals.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.

signals.tsBlame348 lines · 1 contributor
a6d8fd5Claude1/**
2 * Block K9 — Production + test signal ingestion API.
3 *
4 * External systems (Crontech runtime, Gatetest runner, Sentry bridge) POST
5 * per-commit error signals here; the Gluecron web + agent layers consume
6 * them to annotate commits and drive fix loops.
7 *
8 * POST /api/v1/signals/error
9 * body: { repo: "owner/name", commit_sha, source, kind, message,
10 * stack_trace?, deploy_id?, environment?, severity?,
11 * sample_payload? }
12 * 200: { id, status, count }
13 * 400: invalid sha / missing fields / unknown repo format
14 * 401: no valid auth
15 * 403: token lacks contents:read AND isn't the repo owner
16 * 404: repo not found
17 *
18 * GET /api/v1/repos/:owner/:repo/signals
19 * 200: { total, signals: [...] } -- open only
20 *
21 * GET /api/v1/repos/:owner/:repo/commits/:sha/signals
22 * 200: { total, signals: [...] }
23 *
24 * POST /api/v1/signals/:id/dismiss (owner only, audit-logged)
25 * POST /api/v1/signals/:id/resolve (owner only, audit-logged)
26 *
27 * Auth: accepts three bearer-token flavours in Authorization:
28 * - glc_* personal access tokens (PAT) via softAuth-resolved user
29 * - glct_* OAuth access tokens
30 * - ghi_* marketplace install tokens (verifyInstallToken)
31 * Session cookies also work. Un-authenticated POSTs return 401 JSON
32 * rather than a /login redirect — API clients don't follow HTML.
33 */
34
35import { Hono } from "hono";
36import { and, eq } from "drizzle-orm";
37import { db } from "../db";
38import { repositories, users } from "../db/schema";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41import {
42 verifyInstallToken,
43 hasPermission,
44 type Permission,
45} from "../lib/marketplace";
46import { audit } from "../lib/notify";
47import {
48 _getProdSignalsTable,
49 dismissSignal,
50 isValidSha,
51 listOpenSignalsForRepo,
52 listSignalsForCommit,
53 recordSignal,
54 resolveSignal,
55 sanitiseKind,
56 sanitiseSeverity,
57 sanitiseSource,
58} from "../lib/prod-signals";
59
60const signals = new Hono<AuthEnv>();
61
62type AuthPrincipal =
63 | { kind: "user"; userId: string; scopes: string[] }
64 | { kind: "install"; permissions: Permission[]; botUsername: string };
65
66/**
67 * Resolve the caller. Prefers install token (ghi_*) when present since
68 * marketplace tokens are the canonical cross-org write path. Falls back
69 * to the softAuth-resolved user / PAT / OAuth token / session cookie.
70 */
71async function resolveAuth(c: any): Promise<AuthPrincipal | null> {
72 const authHeader = c.req.header("authorization") || "";
73 if (authHeader.toLowerCase().startsWith("bearer ")) {
74 const bearer = authHeader.slice(7).trim();
75 if (bearer.startsWith("ghi_")) {
76 const install = await verifyInstallToken(bearer);
77 if (!install) return null;
78 return {
79 kind: "install",
80 permissions: install.permissions,
81 botUsername: install.botUsername,
82 };
83 }
84 }
85 const user = c.get("user");
86 if (user) {
87 const scopes = (c.get("oauthScopes") as string[] | undefined) || [];
88 return { kind: "user", userId: user.id, scopes };
89 }
90 return null;
91}
92
93async function resolveRepoByName(ownerName: string, repoName: string) {
94 const [owner] = await db
95 .select()
96 .from(users)
97 .where(eq(users.username, ownerName))
98 .limit(1);
99 if (!owner) return null;
100 const [repo] = await db
101 .select()
102 .from(repositories)
103 .where(
104 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
105 )
106 .limit(1);
107 if (!repo) return null;
108 return { owner, repo };
109}
110
111function parseRepoSlug(raw: unknown): { owner: string; name: string } | null {
112 if (typeof raw !== "string") return null;
113 const parts = raw.trim().split("/");
114 if (parts.length !== 2) return null;
115 const [owner, name] = parts;
116 if (!owner || !name) return null;
117 return { owner, name };
118}
119
120function canWriteRepo(
121 principal: AuthPrincipal,
122 repoOwnerId: string
123): boolean {
124 if (principal.kind === "user") return principal.userId === repoOwnerId;
125 // Install tokens need contents:read (or higher — write implies read)
126 return hasPermission(principal.permissions, "contents:read");
127}
128
129// ---------------------------------------------------------------------------
130// POST ingest
131// ---------------------------------------------------------------------------
132signals.post("/api/v1/signals/error", softAuth, async (c) => {
133 const principal = await resolveAuth(c);
134 if (!principal) {
135 return c.json({ error: "Authentication required" }, 401);
136 }
137
138 let body: any = {};
139 try {
140 body = await c.req.json();
141 } catch {
142 return c.json({ error: "Invalid JSON body" }, 400);
143 }
144
145 const slug = parseRepoSlug(body.repo ?? body.repository);
146 if (!slug) {
147 return c.json(
148 { error: "Missing or malformed 'repo' (expected 'owner/name')" },
149 400
150 );
151 }
152
153 const sha = String(body.commit_sha ?? body.commitSha ?? "").toLowerCase();
154 if (!isValidSha(sha)) {
155 return c.json({ error: "Invalid commit_sha" }, 400);
156 }
157
158 const message = String(body.message ?? "");
159 if (!message.trim()) {
160 return c.json({ error: "'message' is required" }, 400);
161 }
162
163 const resolved = await resolveRepoByName(slug.owner, slug.name);
164 if (!resolved) return c.json({ error: "Repository not found" }, 404);
165
166 if (!canWriteRepo(principal, resolved.owner.id)) {
167 return c.json({ error: "Forbidden" }, 403);
168 }
169
170 const result = await recordSignal({
171 repositoryId: resolved.repo.id,
172 commitSha: sha,
173 source: sanitiseSource(body.source),
174 kind: sanitiseKind(body.kind),
175 severity: sanitiseSeverity(body.severity),
176 message,
177 stackTrace:
178 typeof body.stack_trace === "string"
179 ? body.stack_trace
180 : typeof body.stackTrace === "string"
181 ? body.stackTrace
182 : null,
183 deployId:
184 typeof body.deploy_id === "string"
185 ? body.deploy_id
186 : typeof body.deployId === "string"
187 ? body.deployId
188 : null,
189 environment:
190 typeof body.environment === "string" ? body.environment : null,
191 samplePayload:
192 typeof body.sample_payload === "string"
193 ? body.sample_payload
194 : typeof body.samplePayload === "string"
195 ? body.samplePayload
196 : null,
197 });
198
199 if (!result) {
200 return c.json({ error: "Could not record signal" }, 500);
201 }
202
203 return c.json({
204 id: result.id,
205 status: result.status,
206 count: result.count,
207 });
208});
209
210// ---------------------------------------------------------------------------
211// GET list (open signals for a repo)
212// ---------------------------------------------------------------------------
213signals.get("/api/v1/repos/:owner/:repo/signals", softAuth, async (c) => {
214 const { owner: ownerName, repo: repoName } = c.req.param();
215 const resolved = await resolveRepoByName(ownerName, repoName);
216 if (!resolved) return c.json({ error: "Repository not found" }, 404);
217
218 const user = c.get("user");
219 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
220 return c.json({ error: "Forbidden" }, 403);
221 }
222
223 const rows = await listOpenSignalsForRepo(resolved.repo.id);
224 return c.json({ total: rows.length, signals: rows });
225});
226
227// ---------------------------------------------------------------------------
228// GET list for a specific commit
229// ---------------------------------------------------------------------------
230signals.get(
231 "/api/v1/repos/:owner/:repo/commits/:sha/signals",
232 softAuth,
233 async (c) => {
234 const { owner: ownerName, repo: repoName, sha } = c.req.param();
235 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
236 const resolved = await resolveRepoByName(ownerName, repoName);
237 if (!resolved) return c.json({ error: "Repository not found" }, 404);
238
239 const user = c.get("user");
240 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
241 return c.json({ error: "Forbidden" }, 403);
242 }
243
244 const rows = await listSignalsForCommit(resolved.repo.id, sha);
245 return c.json({ total: rows.length, signals: rows });
246 }
247);
248
249// ---------------------------------------------------------------------------
250// POST dismiss
251// ---------------------------------------------------------------------------
252signals.post("/api/v1/signals/:id/dismiss", softAuth, async (c) => {
253 const principal = await resolveAuth(c);
254 if (!principal) return c.json({ error: "Authentication required" }, 401);
255 if (principal.kind !== "user") {
256 return c.json({ error: "Only repo owners can dismiss" }, 403);
257 }
258
259 const id = c.req.param("id");
260 const prodSignals = await _getProdSignalsTable();
261 const [row] = await db
262 .select()
263 .from(prodSignals)
264 .where(eq(prodSignals.id, id))
265 .limit(1);
266 if (!row) return c.json({ error: "Signal not found" }, 404);
267
268 const [repo] = await db
269 .select()
270 .from(repositories)
271 .where(eq(repositories.id, row.repositoryId))
272 .limit(1);
273 if (!repo) return c.json({ error: "Signal not found" }, 404);
274 if (repo.ownerId !== principal.userId) {
275 return c.json({ error: "Forbidden" }, 403);
276 }
277
278 const ok = await dismissSignal(id);
279 if (!ok) return c.json({ error: "Could not dismiss" }, 500);
280
281 await audit({
282 userId: principal.userId,
283 repositoryId: repo.id,
284 action: "signal.dismiss",
285 targetType: "prod_signal",
286 targetId: id,
287 });
288 return c.json({ ok: true });
289});
290
291// ---------------------------------------------------------------------------
292// POST resolve
293// ---------------------------------------------------------------------------
294signals.post("/api/v1/signals/:id/resolve", softAuth, async (c) => {
295 const principal = await resolveAuth(c);
296 if (!principal) return c.json({ error: "Authentication required" }, 401);
297 if (principal.kind !== "user") {
298 return c.json({ error: "Only repo owners can resolve" }, 403);
299 }
300
301 const id = c.req.param("id");
302
303 let body: any = {};
304 try {
305 body = await c.req.json();
306 } catch {
307 body = {};
308 }
309
310 const [row] = await db
311 .select()
312 .from(prodSignals)
313 .where(eq(prodSignals.id, id))
314 .limit(1);
315 if (!row) return c.json({ error: "Signal not found" }, 404);
316
317 const [repo] = await db
318 .select()
319 .from(repositories)
320 .where(eq(repositories.id, row.repositoryId))
321 .limit(1);
322 if (!repo) return c.json({ error: "Signal not found" }, 404);
323 if (repo.ownerId !== principal.userId) {
324 return c.json({ error: "Forbidden" }, 403);
325 }
326
327 const resolvedByCommit =
328 typeof body.resolved_by_commit === "string"
329 ? body.resolved_by_commit
330 : typeof body.resolvedByCommit === "string"
331 ? body.resolvedByCommit
332 : null;
333
334 const ok = await resolveSignal(id, resolvedByCommit);
335 if (!ok) return c.json({ error: "Could not resolve" }, 500);
336
337 await audit({
338 userId: principal.userId,
339 repositoryId: repo.id,
340 action: "signal.resolve",
341 targetType: "prod_signal",
342 targetId: id,
343 metadata: resolvedByCommit ? { resolvedByCommit } : undefined,
344 });
345 return c.json({ ok: true });
346});
347
348export default signals;