import { env } from "@code/env/convex"; import { isProcessedEvent, MAX_WEBHOOK_PAYLOAD_BYTES, } from "@code/primitives/git-webhook"; import { makeFunctionReference } from "convex/server"; import type { Id } from "./_generated/dataModel"; import { httpAction } from "./_generated/server"; const recordWebhookDeliveryRef = makeFunctionReference< "mutation", { deliveryId: string; event: string; externalRepositoryId?: string; payloadHash: string; provider: "github" | "gitea"; }, { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } >("gitProvisioning:recordWebhookDelivery"); const resolveRepositoryByExternalIdRef = makeFunctionReference< "query", { externalRepositoryId: string; provider: "github" | "gitea"; }, { _id: Id<"gitRepositories"> } | null >("gitProvisioning:resolveRepositoryByExternalId"); const markDeliveryProcessedRef = makeFunctionReference< "mutation", { deliveryId: Id<"gitWebhookDeliveries">; repositoryRef?: Id<"gitRepositories">; }, null >("gitProvisioning:markDeliveryProcessed"); /* eslint-disable @typescript-eslint/no-explicit-any -- action ctx type is complex without codegen */ const processDelivery = async ( ctx: any, input: { readonly deliveryId: string; readonly event: string; readonly externalRepositoryId?: string; readonly payloadHash: string; readonly provider: "github" | "gitea"; } ): Promise<{ deliveryId: string; duplicate: boolean }> => { const result: { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } = await ctx.runMutation(recordWebhookDeliveryRef, input); if (result.duplicate) { return result; } // Resolve the repository reference if we have an external ID. let repositoryRef: Id<"gitRepositories"> | undefined; if (input.externalRepositoryId) { const repo = await ctx.runQuery(resolveRepositoryByExternalIdRef, { externalRepositoryId: input.externalRepositoryId, provider: input.provider, }); if (repo) { repositoryRef = repo._id as Id<"gitRepositories">; } } await ctx.runMutation(markDeliveryProcessedRef, { deliveryId: result.deliveryId, ...(repositoryRef ? { repositoryRef } : {}), }); return result; }; const verifyHmacSignature = async ( body: string, signature: string, secret: string, algorithm: "sha256" | "sha1" ): Promise => { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { hash: algorithm, name: "HMAC" }, false, ["verify"] ); let sigHex = signature; if (signature.startsWith("sha256=")) { sigHex = signature.slice(7); } else if (signature.startsWith("sha1=")) { sigHex = signature.slice(5); } const sigBytes = new Uint8Array( sigHex.match(/.{2}/gu)?.flatMap((byte) => Number.parseInt(byte, 16)) ?? [] ); return await crypto.subtle.verify( "HMAC", key, sigBytes, new TextEncoder().encode(body) ); }; const hashPayload = async (body: string): Promise => { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(body) ); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); }; export const githubWebhook = httpAction(async (ctx, request) => { const deliveryId = request.headers.get("x-github-delivery") ?? ""; const event = request.headers.get("x-github-event") ?? ""; const signature = request.headers.get("x-hub-signature-256") ?? ""; if (!deliveryId || !event) { return new Response("Missing GitHub delivery headers", { status: 400 }); } const rawBody = await request.text(); const payloadBytes = new TextEncoder().encode(rawBody).length; if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) { return new Response("Payload too large", { status: 413 }); } if (!env.GITHUB_WEBHOOK_SECRET) { return new Response("GitHub webhook secret not configured", { status: 500, }); } const valid = await verifyHmacSignature( rawBody, signature, env.GITHUB_WEBHOOK_SECRET, "sha256" ); if (!valid) { return new Response("Invalid signature", { status: 401 }); } if (!isProcessedEvent(event)) { return new Response("Event ignored", { status: 200 }); } const payload = JSON.parse(rawBody) as { action?: string; repository?: { full_name: string; html_url: string; id: number; name: string; owner: { login: string }; }; }; const externalRepositoryId = payload.repository ? String(payload.repository.id) : undefined; const payloadHash = await hashPayload(rawBody); // Persist delivery, resolve repository, and mark processed. const result = await processDelivery(ctx, { deliveryId, event, externalRepositoryId, payloadHash, provider: "github", }); return Response.json({ deliveryId, duplicate: result.duplicate, event, externalRepositoryId, payloadHash, processed: true, provider: "github", }); }); export const puterWebhook = httpAction(async (ctx, request) => { const deliveryId = request.headers.get("x-gitea-delivery") ?? ""; const event = request.headers.get("x-gitea-event") ?? ""; const signature = request.headers.get("x-gitea-signature") ?? ""; if (!deliveryId || !event) { return new Response("Missing Gitea delivery headers", { status: 400 }); } const rawBody = await request.text(); const payloadBytes = new TextEncoder().encode(rawBody).length; if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) { return new Response("Payload too large", { status: 413 }); } if (!env.GITEA_WEBHOOK_SECRET) { return new Response("Gitea webhook secret not configured", { status: 500 }); } const valid = await verifyHmacSignature( rawBody, signature, env.GITEA_WEBHOOK_SECRET, "sha256" ); if (!valid) { return new Response("Invalid signature", { status: 401 }); } if (!isProcessedEvent(event)) { return new Response("Event ignored", { status: 200 }); } const payload = JSON.parse(rawBody) as { action?: string; repository?: { full_name: string; html_url: string; id: number; name: string; owner: { login: string }; }; }; const externalRepositoryId = payload.repository ? String(payload.repository.id) : undefined; const payloadHash = await hashPayload(rawBody); const result = await processDelivery(ctx, { deliveryId, event, externalRepositoryId, payloadHash, provider: "gitea", }); return Response.json({ deliveryId, duplicate: result.duplicate, event, externalRepositoryId, payloadHash, processed: true, provider: "gitea", }); });