audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

v1.0.4: 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, 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}