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' }, }); });