import type { CreateEvent, DeleteEvent, InstallationEvent, InstallationRepositoriesEvent, PushEvent, RepositoryEvent, } from '@octokit/webhooks-types' import { verify } from '@octokit/webhooks-methods' import { sql } from 'drizzle-orm' import { installation, job, webhookEvent } from '#server/db/schema' import { kickWorker } from '#server/utils/kick-worker' import { enqueue } from '#server/utils/queue' import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey' const RECOGNISED_EVENTS = new Set([ 'push', 'create', 'delete', 'repository', 'installation', 'installation_repositories', ]) export default defineEventHandler(async event => { const { githubWebhookSecret } = useRuntimeConfig() if (!githubWebhookSecret) { throw createError({ statusCode: 500, statusMessage: 'webhook secret not configured' }) } const signature = getRequestHeader(event, 'x-hub-signature-256') const deliveryHeader = getRequestHeader(event, 'x-github-delivery') const eventName = getRequestHeader(event, 'x-github-event') if (!signature || !deliveryHeader || !eventName) { throw createError({ statusCode: 400, statusMessage: 'missing required headers' }) } const rawBody = await readRawBody(event, 'utf8') if (!rawBody) { throw createError({ statusCode: 400, statusMessage: 'empty body' }) } const valid = await verify(githubWebhookSecret, rawBody, signature) if (!valid) { throw createError({ statusCode: 401, statusMessage: 'invalid signature' }) } const deliveryId = deliveryHeader.toLowerCase() // Idempotent insert. If this delivery has already been seen, the row is not // re-inserted and `inserted` is empty; we 200 and skip downstream work. const db = useDb() const inserted = await db .insert(webhookEvent) .values({ deliveryId, source: 'github', event: eventName, }) .onConflictDoNothing({ target: webhookEvent.deliveryId }) .returning({ deliveryId: webhookEvent.deliveryId }) if (inserted.length === 0) { return { ok: true, duplicate: true } } // Bookkeeping for installation lifecycle events. Sync work itself is enqueued // by later commits; for now we just keep the `installation` table in step so // those commits can FK-reference rows that already exist. if (eventName === 'installation') { const body = await readBody(event) const action = body.action if (action === 'created') { const account = body.installation.account // GitHub's `installation.account` is `User | Enterprise | null`, but the // ones we accept are user/org accounts; bail loudly on anything else. if (!account || !('login' in account) || !('type' in account)) { throw createError({ statusCode: 400, statusMessage: 'unsupported installation account' }) } const accountType = account.type === 'Organization' ? 'Organization' : 'User' await db.insert(installation).values({ id: body.installation.id, accountLogin: account.login, accountId: account.id, accountType, }).onConflictDoNothing({ target: installation.id }) } else if (action === 'deleted') { // Revoke the user's sh.tangled.publicKey PDS records first; the cascade // below drops the local ssh_key rows that hold the rkeys we need. await revokeKeysForInstallation(body.installation.id) // Cancel any still-pending work for this install. The `job` table has no // FK to `installation` (payload carries the id as JSON), so nothing // cascades; left alone, these jobs retry to exhaustion failing on the // now-missing FK target (e.g. the ssh_key insert). Drop only unstarted // work; a running job finishes and no-ops on its own guards. await db.delete(job).where(sql` ${job.status} in ('queued', 'failed') AND (${job.payload}->>'installationId')::bigint = ${body.installation.id} `) // installation row deletion cascades to user_identity, ssh_key, repo_mapping. await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`) } else if (action === 'suspend') { await db.update(installation) .set({ suspendedAt: new Date() }) .where(sql`${installation.id} = ${body.installation.id}`) } else if (action === 'unsuspend') { await db.update(installation) .set({ suspendedAt: null }) .where(sql`${installation.id} = ${body.installation.id}`) } } // Enqueue work for events we care about. Envelope shape is the minimum the // handler needs to re-fetch fresh data via the GitHub API; we never persist // the raw webhook body. See PLAN.md "Deferred / follow-ups". if (RECOGNISED_EVENTS.has(eventName)) { await enqueueForEvent(event, eventName, deliveryId) // Nudge the worker to drain the just-enqueued job now rather than on the // next cron tick. Fire-and-forget via waitUntil; cron is the safety net. kickWorker(event) } return { ok: true, deliveryId } }) async function enqueueForEvent(event: Parameters[0], eventName: string, deliveryId: string) { if (eventName === 'push') { const body = await readBody(event) if (!body.installation) return await enqueue('github.push', { deliveryId, installationId: body.installation.id, githubRepoId: body.repository.id, ref: body.ref, before: body.before, after: body.after, }) } else if (eventName === 'create') { const body = await readBody(event) if (!body.installation) return await enqueue('github.create', { deliveryId, installationId: body.installation.id, githubRepoId: body.repository.id, refType: body.ref_type, ref: body.ref, }) } else if (eventName === 'delete') { const body = await readBody(event) if (!body.installation) return await enqueue('github.delete', { deliveryId, installationId: body.installation.id, githubRepoId: body.repository.id, refType: body.ref_type, ref: body.ref, }) } else if (eventName === 'repository') { const body = await readBody(event) if (!body.installation) return await enqueue('github.repository', { deliveryId, installationId: body.installation.id, githubRepoId: body.repository.id, action: body.action, }) } else if (eventName === 'installation_repositories') { const body = await readBody(event) await enqueue('github.installation_repositories', { deliveryId, installationId: body.installation.id, action: body.action, addedRepoIds: 'repositories_added' in body ? body.repositories_added.map(r => r.id) : [], removedRepoIds: 'repositories_removed' in body ? body.repositories_removed.map(r => r.id) : [], }) } // 'installation' is handled inline above for bookkeeping; no job enqueued. }