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