audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

v1.0.1: src/index.ts · raw

  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}