syntaxai/tdd.md · main · src / d21_handlers_webhook.ts

d21_handlers_webhook.ts 40 lines · 1938 bytes raw
// c21 — handlers: Forgejo push-webhook entry point. HMAC-verified, fires
// `judge()` in the background and acks immediately so the upstream push
// hook doesn't time out while we're checking out commits. Extracted
// from c21_app.ts per the SAMA Atomic rule — separate file from the
// manual /api/judge trigger because the auth model (HMAC vs. bearer)
// and the failure semantics (ack-and-fire vs. wait-for-verdict) are
// genuinely different concepts.

import { judge } from "./c14_judge.ts";
import { parseJson } from "./c14_request_parse.ts";
import { timingSafeEqual, hmacSha256Hex } from "./b32_session.ts";

export const forgejoWebhookHandler = async (req: Request): Promise<Response> => {
  if (req.method !== "POST") return new Response("POST only", { status: 405 });
  const secret = process.env.WEBHOOK_SECRET;
  if (!secret) return new Response("webhook not configured", { status: 503 });

  const body = await req.text();
  const provided =
    req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
  const expected = await hmacSha256Hex(secret, body);
  if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
    return new Response("invalid signature", { status: 401 });
  }

  const parsed = parseJson<{ repository?: { owner?: { login?: string }; name?: string }; ref?: string }>(body);
  if (!parsed.ok) return new Response("invalid json", { status: 400 });
  const payload = parsed.value;
  const owner = payload.repository?.owner?.login;
  const repo = payload.repository?.name;
  if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });

  // Fire the judge in the background; ack immediately so Forgejo
  // doesn't time out while we're checking out commits.
  void judge(owner, repo).catch((err) => {
    console.error(`judge failed for ${owner}/${repo}:`, err);
  });
  return Response.json({ accepted: true, owner, repo });
};