syntaxai/tdd.md · main · src / d21_handlers_webhook.ts
// 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 });
};