Files
zopu-code/packages/backend/convex/gitWebhooks.ts
2026-07-31 18:26:41 +05:30

253 lines
6.6 KiB
TypeScript

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<boolean> => {
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<string> => {
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",
});
});