audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
1import { verifySignature, extractFact, extractRepoFullName, extractInstallationId } from "./webhook";
2import {
3 buildAuthorizeUrl,
4 exchangeCodeForToken,
5 fetchGithubUser,
6 fetchUserInstallationIds,
7 newSessionPayload,
8 signSession,
9 verifySession,
10 SESSION_COOKIE,
11 SESSION_TTL_SECONDS,
12 STATE_COOKIE,
13 STATE_TTL_SECONDS,
14 type SessionPayload,
15} from "./auth";
16import { parseCookies, setCookieHeader, clearCookieHeader } from "./cookies";
17import { createAppJwt, getInstallationToken } from "./github-app";
18import { listInstallationRepos, pollRepoProtection, pollOrgAccess } from "./poller";
19import { buildEvidenceRows, renderCsv, renderPdf, type Framework, type ExportFormat } from "./exporter";
20import {
21 renderDashboard,
22 renderAccessReview,
23 type ExportListRow,
24 type InstallationOption,
25} from "./dashboard";
26import { buildAccessDiff } from "./access-review";
27
28interface ExportJob {
29 jobId: string;
30 installationId: number;
31 framework: Framework;
32 format: ExportFormat;
33}
34
35const CONTENT_TYPE: Record<ExportFormat, string> = {
36 csv: "text/csv; charset=utf-8",
37 pdf: "application/pdf",
38};
39
40// Retention windows. Source of truth for retention; PRIVACY.md states the
41// same periods, so the two must be changed together.
42const SNAPSHOT_RETENTION_DAYS = 396; // ~13 months: an annual audit period + buffer
43const EXPORT_RETENTION_DAYS = 90;
44
45export default {
46 async fetch(request: Request, env: Env): Promise<Response> {
47 const url = new URL(request.url);
48
49 if (request.method === "POST" && url.pathname === "/webhooks/github") {
50 return handleWebhook(request, env);
51 }
52 if (request.method === "POST" && url.pathname === "/webhooks/marketplace") {
53 return handleMarketplaceWebhook(request, env);
54 }
55 if (request.method === "GET" && url.pathname === "/login") {
56 return handleLogin(request, env);
57 }
58 if (request.method === "GET" && url.pathname === "/callback") {
59 return handleCallback(request, env);
60 }
61 if (request.method === "GET" && url.pathname === "/logout") {
62 return handleLogout();
63 }
64 if (request.method === "POST" && url.pathname === "/admin/poll") {
65 return handleAdminPoll(request, env);
66 }
67 if (request.method === "POST" && url.pathname === "/admin/export") {
68 return handleAdminExport(request, env);
69 }
70 if (request.method === "POST" && url.pathname === "/admin/cleanup") {
71 return handleAdminCleanup(request, env);
72 }
73 if (request.method === "POST" && url.pathname === "/admin/purge") {
74 return handleAdminPurge(request, env);
75 }
76 const adminExportMatch = url.pathname.match(/^\/admin\/export\/([0-9a-f-]+)(\/download)?$/);
77 if (request.method === "GET" && adminExportMatch) {
78 const [, jobId, downloadSuffix] = adminExportMatch;
79 if (jobId) return handleAdminExportGet(request, env, jobId, downloadSuffix === "/download");
80 }
81 // Dashboard (session-authed) surfaces.
82 if (request.method === "POST" && url.pathname === "/resync") {
83 return handleResync(request, env);
84 }
85 if (request.method === "POST" && url.pathname === "/exports") {
86 return handleCreateExport(request, env);
87 }
88 if (request.method === "POST" && url.pathname === "/switch") {
89 return handleSwitchInstallation(request, env);
90 }
91 const exportMatch = url.pathname.match(/^\/exports\/([0-9a-f-]+)(\/download)?$/);
92 if (request.method === "GET" && exportMatch) {
93 const [, jobId, downloadSuffix] = exportMatch;
94 if (jobId) return handleSessionExportGet(request, env, jobId, downloadSuffix === "/download");
95 }
96 if (request.method === "GET" && url.pathname === "/access-review") {
97 return handleAccessReview(request, env);
98 }
99 if (request.method === "GET" && url.pathname === "/") {
100 return handleDashboard(request, env);
101 }
102
103 return new Response("Not found", { status: 404 });
104 },
105
106 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
107 ctx.waitUntil(
108 Promise.all([pollAllInstallations(env), runRetentionCleanup(env)]).then(() => undefined),
109 );
110 },
111
112 async queue(batch: MessageBatch<ExportJob>, env: Env): Promise<void> {
113 for (const message of batch.messages) {
114 try {
115 await renderExport(env, message.body);
116 message.ack();
117 } catch (err) {
118 const job = message.body;
119 console.error(`Export ${job.jobId} failed:`, err);
120 try {
121 await env.DB.prepare(
122 "UPDATE exports SET status = 'error', error = ?1, completed_at = ?2 WHERE id = ?3",
123 )
124 .bind((err as Error).message, new Date().toISOString(), job.jobId)
125 .run();
126 } catch (recordErr) {
127 // Recording the failure must not stop the ack below, or a
128 // deterministic render error would be retried to the DLQ limit.
129 console.error(`Could not record export failure for ${job.jobId}:`, recordErr);
130 }
131 // Rendering failure is deterministic (bad data / bug), not transient —
132 // don't retry, the error is recorded for the operator.
133 message.ack();
134 }
135 }
136 },
137} satisfies ExportedHandler<Env, ExportJob>;
138
139async function renderExport(env: Env, job: ExportJob): Promise<void> {
140 await env.DB.prepare("UPDATE exports SET status = 'processing' WHERE id = ?1").bind(job.jobId).run();
141
142 const rows = await buildEvidenceRows(env.DB, job.installationId, job.framework);
143 const body =
144 job.format === "pdf"
145 ? await renderPdf(rows, {
146 framework: job.framework,
147 installationId: job.installationId,
148 generatedAt: new Date().toISOString(),
149 })
150 : renderCsv(rows);
151 const r2Key = `exports/${job.installationId}/${job.jobId}.${job.format}`;
152
153 await env.EXPORTS.put(r2Key, body, {
154 httpMetadata: { contentType: CONTENT_TYPE[job.format] },
155 });
156
157 await env.DB.prepare(
158 "UPDATE exports SET status = 'done', r2_key = ?1, completed_at = ?2 WHERE id = ?3",
159 )
160 .bind(r2Key, new Date().toISOString(), job.jobId)
161 .run();
162}
163
164// Timing-safe bearer check against ADMIN_TOKEN. Returns true if authorized.
165function checkAdminAuth(request: Request, env: Env): boolean {
166 const auth = request.headers.get("Authorization");
167 const encoder = new TextEncoder();
168 const provided = encoder.encode(auth ?? "");
169 const want = encoder.encode(`Bearer ${env.ADMIN_TOKEN}`);
170 return provided.length === want.length && crypto.subtle.timingSafeEqual(provided, want);
171}
172
173interface PollSummary {
174 installationsPolled: number;
175 written: Array<{ installationId: number; repo: string; resource: string; status: string }>;
176 errors: string[];
177}
178
179// Baseline/drift poll for branch protection + ruleset state, since webhooks
180// only fire on changes — a repo protected before the App was installed
181// would otherwise never show up. Sequential per repo/installation is fine
182// at test-org scale; a large multi-org install would need to fan this out
183// through a Queue instead of looping inline in one scheduled invocation.
184async function pollAllInstallations(env: Env): Promise<PollSummary> {
185 const { results: installations } = await env.DB.prepare(
186 "SELECT installation_id FROM installations WHERE suspended_at IS NULL",
187 ).all<{ installation_id: number }>();
188
189 const summary: PollSummary = { installationsPolled: installations.length, written: [], errors: [] };
190
191 for (const { installation_id } of installations) {
192 try {
193 await pollInstallation(env, installation_id, summary);
194 } catch (err) {
195 const msg = `Poll failed for installation ${installation_id}: ${(err as Error).message}`;
196 console.error(msg);
197 summary.errors.push(msg);
198 }
199 }
200
201 return summary;
202}
203
204async function pollInstallation(env: Env, installationId: number, summary: PollSummary): Promise<void> {
205 const appJwt = await createAppJwt(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY);
206 const installationToken = await getInstallationToken(appJwt, installationId);
207 const repos = await listInstallationRepos(installationToken);
208 const capturedAt = new Date().toISOString();
209
210 for (const repo of repos) {
211 // Per-repo isolation: one failing repo must not abort the rest of the
212 // installation's poll.
213 try {
214 const facts = await pollRepoProtection(installationToken, repo);
215 for (const fact of facts) {
216 await env.DB.prepare(
217 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at)
218 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
219 )
220 .bind(installationId, fact.repo, fact.resource, fact.status, fact.rawPayload, capturedAt)
221 .run();
222 summary.written.push({ installationId, repo: fact.repo, resource: fact.resource, status: fact.status });
223 }
224 } catch (err) {
225 const msg = `Poll failed for repo ${repo.fullName}: ${(err as Error).message}`;
226 console.error(msg);
227 summary.errors.push(msg);
228 }
229 }
230
231 await pollAccess(env, installationId, installationToken, capturedAt, summary);
232}
233
234// Org membership + team membership, for the access-review diff. Isolated from
235// the repo poll so a failure here doesn't lose the posture data above.
236async function pollAccess(
237 env: Env,
238 installationId: number,
239 installationToken: string,
240 capturedAt: string,
241 summary: PollSummary,
242): Promise<void> {
243 try {
244 const orgRow = await env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1")
245 .bind(installationId)
246 .first<{ org_login: string }>();
247 if (!orgRow?.org_login) return;
248
249 const facts = await pollOrgAccess(installationToken, orgRow.org_login);
250 if (facts === null) {
251 // Installed on a personal account, not an org — no membership to review.
252 return;
253 }
254
255 for (const fact of facts) {
256 await env.DB.prepare(
257 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at, subject)
258 VALUES (?1, NULL, ?2, ?3, NULL, ?4, ?5)`,
259 )
260 .bind(installationId, fact.resource, fact.status, capturedAt, fact.subject)
261 .run();
262 summary.written.push({
263 installationId,
264 repo: fact.subject,
265 resource: fact.resource,
266 status: fact.status,
267 });
268 }
269 } catch (err) {
270 const msg = `Access poll failed for installation ${installationId}: ${(err as Error).message}`;
271 console.error(msg);
272 summary.errors.push(msg);
273 }
274}
275
276// Manual on-demand poll — same work as the scheduled handler, but callable
277// via HTTP so an operator (or eventually a dashboard "re-sync now" button)
278// can trigger it without waiting for the cron. Bearer-token guarded.
279async function handleAdminPoll(request: Request, env: Env): Promise<Response> {
280 if (!checkAdminAuth(request, env)) return new Response("Unauthorized", { status: 401 });
281 const summary = await pollAllInstallations(env);
282 return Response.json(summary);
283}
284
285// Enqueue an evidence export. Returns the job id; rendering happens off the
286// request path in the queue consumer (PDF/large CSV can exceed request CPU).
287async function handleAdminExport(request: Request, env: Env): Promise<Response> {
288 if (!checkAdminAuth(request, env)) return new Response("Unauthorized", { status: 401 });
289
290 const params = (await request.json().catch(() => ({}))) as {
291 installationId?: number;
292 framework?: string;
293 format?: string;
294 };
295 const framework = normalizeFramework(params.framework);
296 if (!framework) {
297 return new Response("framework must be soc2, iso27001, or all", { status: 400 });
298 }
299 const format = params.format ?? "csv";
300 if (format !== "csv" && format !== "pdf") {
301 return new Response("format must be csv or pdf", { status: 400 });
302 }
303
304 const installIds = params.installationId
305 ? [params.installationId]
306 : (
307 await env.DB.prepare("SELECT installation_id FROM installations WHERE suspended_at IS NULL").all<{
308 installation_id: number;
309 }>()
310 ).results.map((r) => r.installation_id);
311
312 const jobs: ExportJob[] = [];
313 for (const installationId of installIds) {
314 jobs.push(await enqueueExport(env, installationId, framework, format));
315 }
316
317 return Response.json({ jobs }, { status: 202 });
318}
319
320// Create the export job row + enqueue it. Shared by the admin (bearer) and
321// dashboard (session) entry points.
322async function enqueueExport(
323 env: Env,
324 installationId: number,
325 framework: Framework,
326 format: ExportFormat,
327): Promise<ExportJob> {
328 const jobId = crypto.randomUUID();
329 await env.DB.prepare(
330 `INSERT INTO exports (id, installation_id, framework, format, status, created_at)
331 VALUES (?1, ?2, ?3, ?4, 'queued', ?5)`,
332 )
333 .bind(jobId, installationId, framework, format, new Date().toISOString())
334 .run();
335 const job: ExportJob = { jobId, installationId, framework, format };
336 try {
337 await env.GENERATE_EXPORT.send(job);
338 } catch (err) {
339 // Otherwise the row sits at 'queued' forever with nothing to process it.
340 await env.DB.prepare(
341 "UPDATE exports SET status = 'error', error = ?1, completed_at = ?2 WHERE id = ?3",
342 )
343 .bind(`Failed to enqueue: ${(err as Error).message}`, new Date().toISOString(), jobId)
344 .run();
345 throw err;
346 }
347 return job;
348}
349
350// R2 caps the number of keys per bulk delete, so chunk rather than let a
351// large purge fail wholesale.
352const R2_DELETE_BATCH = 1000;
353
354async function deleteR2Objects(env: Env, keys: string[]): Promise<void> {
355 for (let i = 0; i < keys.length; i += R2_DELETE_BATCH) {
356 await env.EXPORTS.delete(keys.slice(i, i + R2_DELETE_BATCH));
357 }
358}
359
360// Delete everything we hold for one installation: R2 export objects, then
361// all D1 rows. Triggered by the `installation.deleted` webhook (uninstall)
362// and available on request via /admin/purge.
363async function purgeInstallation(env: Env, installationId: number): Promise<void> {
364 const { results } = await env.DB.prepare(
365 "SELECT r2_key FROM exports WHERE installation_id = ?1 AND r2_key IS NOT NULL",
366 )
367 .bind(installationId)
368 .all<{ r2_key: string }>();
369 await deleteR2Objects(env, results.map((r) => r.r2_key));
370
371 await env.DB.batch([
372 env.DB.prepare("DELETE FROM snapshots WHERE installation_id = ?1").bind(installationId),
373 env.DB.prepare("DELETE FROM exports WHERE installation_id = ?1").bind(installationId),
374 env.DB.prepare("DELETE FROM installations WHERE installation_id = ?1").bind(installationId),
375 ]);
376}
377
378interface CleanupResult {
379 snapshotsDeleted: number;
380 exportsDeleted: number;
381}
382
383// Enforce the retention windows: drop snapshots and export files (D1 rows +
384// R2 objects) past their age limit. Runs hourly from the scheduled handler;
385// a no-op most of the time.
386async function runRetentionCleanup(env: Env): Promise<CleanupResult> {
387 const now = Date.now();
388 const snapshotCutoff = new Date(now - SNAPSHOT_RETENTION_DAYS * 86_400_000).toISOString();
389 const exportCutoff = new Date(now - EXPORT_RETENTION_DAYS * 86_400_000).toISOString();
390
391 // Delete expired export R2 objects first (their keys live in the rows).
392 const { results: expiredExports } = await env.DB.prepare(
393 "SELECT r2_key FROM exports WHERE created_at < ?1 AND r2_key IS NOT NULL",
394 )
395 .bind(exportCutoff)
396 .all<{ r2_key: string }>();
397 await deleteR2Objects(env, expiredExports.map((r) => r.r2_key));
398
399 const exportsRes = await env.DB.prepare("DELETE FROM exports WHERE created_at < ?1").bind(exportCutoff).run();
400 const snapshotsRes = await env.DB.prepare("DELETE FROM snapshots WHERE captured_at < ?1")
401 .bind(snapshotCutoff)
402 .run();
403
404 return {
405 snapshotsDeleted: snapshotsRes.meta.changes ?? 0,
406 exportsDeleted: exportsRes.meta.changes ?? 0,
407 };
408}
409
410async function handleAdminCleanup(request: Request, env: Env): Promise<Response> {
411 if (!checkAdminAuth(request, env)) return new Response("Unauthorized", { status: 401 });
412 return Response.json(await runRetentionCleanup(env));
413}
414
415// On-request deletion of a specific installation's data (a documented
416// deletion trigger). Bearer-guarded.
417async function handleAdminPurge(request: Request, env: Env): Promise<Response> {
418 if (!checkAdminAuth(request, env)) return new Response("Unauthorized", { status: 401 });
419 const params = (await request.json().catch(() => ({}))) as { installationId?: number };
420 if (typeof params.installationId !== "number") {
421 return new Response("installationId (number) is required", { status: 400 });
422 }
423 await purgeInstallation(env, params.installationId);
424 return Response.json({ purged: params.installationId });
425}
426
427// GET /admin/export/:id -> job status JSON; /admin/export/:id/download -> file.
428// Admin (bearer) variant: no installation scoping.
429async function handleAdminExportGet(request: Request, env: Env, jobId: string, download: boolean): Promise<Response> {
430 if (!checkAdminAuth(request, env)) return new Response("Unauthorized", { status: 401 });
431 const job = await env.DB.prepare(
432 "SELECT id, installation_id, framework, format, status, r2_key, error, created_at, completed_at FROM exports WHERE id = ?1",
433 )
434 .bind(jobId)
435 .first<{ status: string; r2_key: string | null; format: ExportFormat }>();
436 return serveExport(env, job, jobId, download);
437}
438
439// Stream a finished export, or return its status JSON. `job` is already
440// scoped/authorized by the caller.
441async function serveExport(
442 env: Env,
443 job: { status: string; r2_key: string | null; format: ExportFormat } | null,
444 jobId: string,
445 download: boolean,
446): Promise<Response> {
447 if (!job) return new Response("Not found", { status: 404 });
448 if (!download) return Response.json(job);
449
450 if (job.status !== "done" || !job.r2_key) {
451 return new Response(`Export not ready (status: ${job.status})`, { status: 409 });
452 }
453 const object = await env.EXPORTS.get(job.r2_key);
454 if (!object) return new Response("Export file missing", { status: 410 });
455
456 return new Response(object.body, {
457 headers: {
458 "Content-Type": CONTENT_TYPE[job.format],
459 "Content-Disposition": `attachment; filename="${jobId}.${job.format}"`,
460 },
461 });
462}
463
464function normalizeFramework(value: string | undefined): Framework | null {
465 if (value === undefined || value === "all") return "all";
466 if (value === "soc2" || value === "iso27001") return value;
467 return null;
468}
469
470async function requireSession(request: Request, env: Env): Promise<SessionPayload | null> {
471 const token = parseCookies(request)[SESSION_COOKIE];
472 return token ? verifySession(token, env.SESSION_SECRET) : null;
473}
474
475// Authenticated dashboard: current compliance posture + export controls,
476// scoped to the logged-in user's installation.
477async function handleDashboard(request: Request, env: Env): Promise<Response> {
478 const session = await requireSession(request, env);
479 if (!session) return new Response(null, { status: 302, headers: { Location: "/login" } });
480
481 const url = new URL(request.url);
482 const framework = normalizeFramework(url.searchParams.get("framework") ?? undefined) ?? "all";
483
484 const [rows, orgRow, exportsResult, lastPollRow, installations] = await Promise.all([
485 buildEvidenceRows(env.DB, session.installationId, framework),
486 env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1")
487 .bind(session.installationId)
488 .first<{ org_login: string }>(),
489 env.DB.prepare(
490 `SELECT id, framework, format, status, created_at FROM exports
491 WHERE installation_id = ?1 ORDER BY created_at DESC LIMIT 10`,
492 )
493 .bind(session.installationId)
494 .all<ExportListRow>(),
495 env.DB.prepare(
496 `SELECT MAX(captured_at) AS t FROM snapshots
497 WHERE installation_id = ?1 AND resource IN ('branch_protection', 'repository_ruleset')`,
498 )
499 .bind(session.installationId)
500 .first<{ t: string | null }>(),
501 accessibleInstallations(env, session),
502 ]);
503
504 const html = renderDashboard({
505 login: session.login,
506 installationId: session.installationId,
507 orgLogin: orgRow?.org_login ?? "unknown",
508 installations,
509 framework,
510 rows,
511 exports: exportsResult.results,
512 lastPolledAt: lastPollRow?.t ?? null,
513 });
514
515 return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
516}
517
518// Installations this session may view, with their org names, for the header
519// switcher. Falls back to the single active id for sessions issued before
520// the switcher existed.
521async function accessibleInstallations(env: Env, session: SessionPayload): Promise<InstallationOption[]> {
522 const ids = session.installationIds?.length ? session.installationIds : [session.installationId];
523 const placeholders = ids.map(() => "?").join(",");
524 const { results } = await env.DB.prepare(
525 `SELECT installation_id, org_login FROM installations
526 WHERE installation_id IN (${placeholders}) ORDER BY org_login`,
527 )
528 .bind(...ids)
529 .all<InstallationOption>();
530 return results;
531}
532
533// POST /switch — change which installation the session is viewing. The
534// allowed set lives in the signed session, so a tampered id can't widen
535// access beyond what was granted at login.
536async function handleSwitchInstallation(request: Request, env: Env): Promise<Response> {
537 const session = await requireSession(request, env);
538 if (!session) return new Response("Unauthorized", { status: 401 });
539
540 const form = await request.formData();
541 const requested = Number(form.get("installationId"));
542 const allowed = session.installationIds?.length ? session.installationIds : [session.installationId];
543 if (!Number.isInteger(requested) || !allowed.includes(requested)) {
544 return new Response("No access to that installation", { status: 403 });
545 }
546
547 const rotated = await signSession({ ...session, installationId: requested }, env.SESSION_SECRET);
548 // Keep the original expiry — switching views shouldn't extend the session.
549 const remaining = Math.max(0, session.exp - Math.floor(Date.now() / 1000));
550 const headers = new Headers({ Location: form.get("return") === "access-review" ? "/access-review" : "/" });
551 headers.append("Set-Cookie", setCookieHeader(SESSION_COOKIE, rotated, remaining));
552 return new Response(null, { status: 303, headers });
553}
554
555// GET /access-review — membership changes since a chosen date, for the
556// periodic access review auditors ask for.
557async function handleAccessReview(request: Request, env: Env): Promise<Response> {
558 const session = await requireSession(request, env);
559 if (!session) return new Response(null, { status: 302, headers: { Location: "/login" } });
560
561 const url = new URL(request.url);
562 // Default comparison point: 30 days ago. An unparseable ?since= falls back
563 // rather than throwing — Date#toISOString raises on an invalid date, so a
564 // stale bookmark or empty form submit would otherwise 500 the page.
565 const sinceParam = url.searchParams.get("since");
566 const requested = sinceParam ? new Date(`${sinceParam}T23:59:59.999Z`) : null;
567 const since =
568 requested && !Number.isNaN(requested.getTime())
569 ? requested.toISOString()
570 : new Date(Date.now() - 30 * 86_400_000).toISOString();
571
572 const [diff, orgRow, installations] = await Promise.all([
573 buildAccessDiff(env.DB, session.installationId, since),
574 env.DB.prepare("SELECT org_login FROM installations WHERE installation_id = ?1")
575 .bind(session.installationId)
576 .first<{ org_login: string }>(),
577 accessibleInstallations(env, session),
578 ]);
579
580 const html = renderAccessReview({
581 login: session.login,
582 installationId: session.installationId,
583 orgLogin: orgRow?.org_login ?? "unknown",
584 installations,
585 since,
586 diff,
587 });
588 return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
589}
590
591// POST /exports — session-authed export of the user's own installation.
592async function handleCreateExport(request: Request, env: Env): Promise<Response> {
593 const session = await requireSession(request, env);
594 if (!session) return new Response("Unauthorized", { status: 401 });
595
596 const form = await request.formData();
597 const framework = normalizeFramework(String(form.get("framework") ?? "all")) ?? "all";
598 const format: ExportFormat = String(form.get("format")) === "pdf" ? "pdf" : "csv";
599
600 await enqueueExport(env, session.installationId, framework, format);
601 return Response.redirect(new URL("/", request.url).toString(), 303);
602}
603
604// GET /exports/:id[/download] — session-authed, scoped to the user's
605// installation so one org can't read another's export by guessing an id.
606async function handleSessionExportGet(request: Request, env: Env, jobId: string, download: boolean): Promise<Response> {
607 const session = await requireSession(request, env);
608 if (!session) return new Response("Unauthorized", { status: 401 });
609
610 const job = await env.DB.prepare(
611 "SELECT id, framework, format, status, r2_key, created_at FROM exports WHERE id = ?1 AND installation_id = ?2",
612 )
613 .bind(jobId, session.installationId)
614 .first<{ status: string; r2_key: string | null; format: ExportFormat }>();
615
616 return serveExport(env, job, jobId, download);
617}
618
619// POST /resync — run the poll for the user's installation inline so the
620// dashboard shows fresh state on the redirect. One installation's repos are
621// few enough to finish within the request.
622async function handleResync(request: Request, env: Env): Promise<Response> {
623 const session = await requireSession(request, env);
624 if (!session) return new Response("Unauthorized", { status: 401 });
625
626 const summary: PollSummary = { installationsPolled: 1, written: [], errors: [] };
627 try {
628 await pollInstallation(env, session.installationId, summary);
629 } catch (err) {
630 console.error(`Resync failed for installation ${session.installationId}:`, err);
631 }
632 return Response.redirect(new URL("/", request.url).toString(), 303);
633}
634
635function handleLogin(request: Request, env: Env): Response {
636 const state = crypto.randomUUID();
637 const redirectUri = new URL("/callback", request.url).toString();
638 const authorizeUrl = buildAuthorizeUrl(env, redirectUri, state);
639
640 return new Response(null, {
641 status: 302,
642 headers: {
643 Location: authorizeUrl,
644 "Set-Cookie": setCookieHeader(STATE_COOKIE, state, STATE_TTL_SECONDS),
645 },
646 });
647}
648
649async function handleCallback(request: Request, env: Env): Promise<Response> {
650 const url = new URL(request.url);
651 const code = url.searchParams.get("code");
652 const state = url.searchParams.get("state");
653 const cookies = parseCookies(request);
654
655 if (!code || !state || !cookies[STATE_COOKIE] || cookies[STATE_COOKIE] !== state) {
656 return new Response("Invalid OAuth state", { status: 400 });
657 }
658
659 const redirectUri = new URL("/callback", request.url).toString();
660
661 let accessToken: string;
662 try {
663 accessToken = await exchangeCodeForToken(env, code, redirectUri);
664 } catch (err) {
665 return new Response(`OAuth exchange failed: ${(err as Error).message}`, { status: 502 });
666 }
667
668 const [user, userInstallationIds] = await Promise.all([
669 fetchGithubUser(accessToken),
670 fetchUserInstallationIds(accessToken),
671 ]);
672
673 if (userInstallationIds.length === 0) {
674 return new Response("No accessible installations of this App", { status: 403 });
675 }
676
677 // Cross-check against installations we actually track, so a user who can
678 // see the App on some unrelated installation can't log into this one.
679 // Ordered by org so a user in several installations lands somewhere
680 // deterministic rather than on whichever row the DB happened to return.
681 const placeholders = userInstallationIds.map(() => "?").join(",");
682 const { results: known } = await env.DB.prepare(
683 `SELECT installation_id FROM installations
684 WHERE installation_id IN (${placeholders})
685 ORDER BY org_login`,
686 )
687 .bind(...userInstallationIds)
688 .all<{ installation_id: number }>();
689
690 const accessibleIds = known.map((k) => k.installation_id);
691 const defaultInstallationId = accessibleIds[0];
692 if (defaultInstallationId === undefined) {
693 return new Response("You don't have access to any org with this App installed", { status: 403 });
694 }
695
696 const session = await signSession(
697 newSessionPayload(user, defaultInstallationId, accessibleIds),
698 env.SESSION_SECRET,
699 );
700
701 const headers = new Headers({ Location: "/" });
702 headers.append("Set-Cookie", setCookieHeader(SESSION_COOKIE, session, SESSION_TTL_SECONDS));
703 headers.append("Set-Cookie", clearCookieHeader(STATE_COOKIE));
704
705 return new Response(null, { status: 302, headers });
706}
707
708function handleLogout(): Response {
709 return new Response(null, {
710 status: 302,
711 headers: {
712 Location: "/login",
713 "Set-Cookie": clearCookieHeader(SESSION_COOKIE),
714 },
715 });
716}
717
718// GitHub's webhook settings offer JSON or x-www-form-urlencoded, and the
719// latter wraps the payload in a `payload` field. Accept either so flipping
720// that setting can't 500 every delivery. Returns the canonical JSON text
721// alongside the parsed object, since the raw JSON is what we retain for the
722// audit trail.
723function parseWebhookBody(
724 bodyText: string,
725 contentType: string | null,
726): { payload: Record<string, unknown>; json: string } | null {
727 const json = (contentType ?? "").includes("x-www-form-urlencoded")
728 ? (new URLSearchParams(bodyText).get("payload") ?? "")
729 : bodyText;
730 try {
731 return { payload: JSON.parse(json) as Record<string, unknown>, json };
732 } catch {
733 return null;
734 }
735}
736
737// Marketplace listing events (marketplace_purchase). The listing is free, so
738// there is no billing to run — this records who subscribed and is the place
739// to add entitlement logic if paid plans are ever introduced. Kept separate
740// from the App webhook because the payload carries no installation and would
741// otherwise be discarded as "no installation context".
742async function handleMarketplaceWebhook(request: Request, env: Env): Promise<Response> {
743 const rawBody = await request.arrayBuffer();
744 const valid = await verifySignature(rawBody, request.headers.get("X-Hub-Signature-256"), env.GITHUB_WEBHOOK_SECRET);
745 if (!valid) return new Response("Invalid signature", { status: 401 });
746
747 const parsed = parseWebhookBody(
748 new TextDecoder().decode(rawBody),
749 request.headers.get("Content-Type"),
750 );
751 if (!parsed) return new Response("Unparseable payload", { status: 400 });
752 const { payload } = parsed;
753
754 const purchase = payload.marketplace_purchase as Record<string, unknown> | undefined;
755 const account = purchase?.account as Record<string, unknown> | undefined;
756 const plan = purchase?.plan as Record<string, unknown> | undefined;
757 console.log("marketplace event", {
758 action: payload.action,
759 account: account?.login,
760 plan: plan?.name,
761 });
762
763 return new Response("OK", { status: 200 });
764}
765
766async function handleWebhook(request: Request, env: Env): Promise<Response> {
767 const rawBody = await request.arrayBuffer();
768 const signature = request.headers.get("X-Hub-Signature-256");
769 const eventType = request.headers.get("X-GitHub-Event");
770
771 if (!eventType) {
772 return new Response("Missing X-GitHub-Event header", { status: 400 });
773 }
774
775 const valid = await verifySignature(rawBody, signature, env.GITHUB_WEBHOOK_SECRET);
776 if (!valid) {
777 return new Response("Invalid signature", { status: 401 });
778 }
779
780 const parsed = parseWebhookBody(
781 new TextDecoder().decode(rawBody),
782 request.headers.get("Content-Type"),
783 );
784 if (!parsed) return new Response("Unparseable payload", { status: 400 });
785 const { payload, json: bodyText } = parsed;
786
787 const installationId = extractInstallationId(payload);
788 if (installationId === null) {
789 // No installation context (e.g. an event type outside the app's install scope) — nothing to attribute a snapshot to.
790 return new Response("OK (no installation context)", { status: 202 });
791 }
792
793 // Installation lifecycle: uninstall purges all data; suspend/unsuspend
794 // toggle processing without deleting. Other actions (created,
795 // new_permissions_accepted) fall through to the normal snapshot path.
796 if (eventType === "installation") {
797 const action = typeof payload.action === "string" ? payload.action : "";
798 if (action === "deleted") {
799 await purgeInstallation(env, installationId);
800 return new Response("OK (purged)", { status: 200 });
801 }
802 if (action === "suspend") {
803 await env.DB.prepare("UPDATE installations SET suspended_at = ?2 WHERE installation_id = ?1")
804 .bind(installationId, new Date().toISOString())
805 .run();
806 return new Response("OK (suspended)", { status: 200 });
807 }
808 if (action === "unsuspend") {
809 await env.DB.prepare("UPDATE installations SET suspended_at = NULL WHERE installation_id = ?1")
810 .bind(installationId)
811 .run();
812 return new Response("OK (unsuspended)", { status: 200 });
813 }
814 }
815
816 const orgLogin = extractOrgLogin(payload);
817 const capturedAt = new Date().toISOString();
818
819 await env.DB.prepare(
820 `INSERT INTO installations (installation_id, org_login, installed_at)
821 VALUES (?1, ?2, ?3)
822 ON CONFLICT(installation_id) DO NOTHING`,
823 )
824 .bind(installationId, orgLogin, capturedAt)
825 .run();
826
827 const fact = extractFact(eventType, payload);
828 const repo = extractRepoFullName(payload);
829
830 await env.DB.prepare(
831 `INSERT INTO snapshots (installation_id, repo, resource, status, raw_payload, captured_at)
832 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
833 )
834 .bind(installationId, repo, fact.resource, fact.status, bodyText, capturedAt)
835 .run();
836
837 return new Response("OK", { status: 200 });
838}
839
840function extractOrgLogin(payload: Record<string, unknown>): string {
841 const installation = payload.installation as Record<string, unknown> | undefined;
842 const account = installation?.account as Record<string, unknown> | undefined;
843 if (typeof account?.login === "string") return account.login;
844
845 const organization = payload.organization as Record<string, unknown> | undefined;
846 if (typeof organization?.login === "string") return organization.login;
847
848 return "unknown";
849}