Files
spoon/packages/backend/convex/githubWebhooks.ts
T

92 lines
3.2 KiB
TypeScript

import { internal } from './_generated/api';
import { httpAction } from './_generated/server';
import { verifyGithubSignature } from './githubWebhookVerify';
/**
* Minimal shape of the GitHub webhook payloads we act on. We intentionally do
* not import Octokit's webhook types — we only read the few fields below, and
* everything is treated as untrusted input.
*/
type GithubWebhookPayload = {
action?: string;
repository?: { full_name?: string };
installation?: { id?: number };
};
/**
* Signature-verified GitHub App webhook endpoint (POST /webhooks/github).
*
* Runs in the DEFAULT Convex runtime (Web Crypto is available) — NOT `'use
* node'`. It never processes an unverified payload and never trusts a user id
* from the body: it maps only installation.id and repository.full_name onto
* existing owned records.
*/
export const handleGithubWebhook = httpAction(async (ctx, request) => {
const secret = process.env.GITHUB_APP_WEBHOOK_SECRET;
// Fail closed: with no configured secret we cannot verify anything.
if (!secret) return new Response('Webhook not configured', { status: 503 });
const rawBody = await request.text();
const signature = request.headers.get('x-hub-signature-256');
const ok = await verifyGithubSignature(secret, rawBody, signature);
if (!ok) return new Response('Invalid signature', { status: 401 });
// Only parse AFTER the signature passes — never touch an unverified payload.
const event = request.headers.get('x-github-event');
let payload: GithubWebhookPayload;
try {
payload = JSON.parse(rawBody) as GithubWebhookPayload;
} catch {
return new Response('Invalid payload', { status: 400 });
}
if (event === 'push') {
const fullName = payload.repository?.full_name;
if (fullName) {
const [owner, repo] = fullName.split('/');
if (owner && repo) {
const targets = await ctx.runQuery(internal.spoons.findForRepo, {
owner,
repo,
});
for (const target of targets) {
await ctx.scheduler.runAfter(
0,
internal.githubSync.refreshSpoonById,
{ spoonId: target.spoonId, ownerId: target.ownerId },
);
}
return new Response(
JSON.stringify({ ok: true, scheduled: targets.length }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
} else if (
event === 'installation' ||
event === 'installation_repositories'
) {
const installationId = String(payload.installation?.id ?? '');
const action = payload.action;
if (installationId) {
const status =
action === 'deleted' || action === 'suspend'
? ('revoked' as const)
: action === 'unsuspend' || action === 'created'
? ('active' as const)
: ('needs_reauth' as const);
await ctx.runMutation(internal.github.setConnectionStatusByInstallation, {
installationId,
status,
});
}
}
// Always return 2xx for verified events (even unhandled ones) so GitHub does
// not retry.
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});