mirror your GitHub repos to tangled.org automatically
1import type { InstallationEvent } from '@octokit/webhooks-types'
2import { verify } from '@octokit/webhooks-methods'
3import { sql } from 'drizzle-orm'
4import { installation, webhookEvent } from '~~/server/db/schema'
5
6const RECOGNISED_EVENTS = new Set([
7 'push',
8 'create',
9 'delete',
10 'repository',
11 'installation',
12 'installation_repositories',
13])
14
15export default defineEventHandler(async event => {
16 const { githubWebhookSecret } = useRuntimeConfig()
17 if (!githubWebhookSecret) {
18 throw createError({ statusCode: 500, statusMessage: 'webhook secret not configured' })
19 }
20
21 const signature = getRequestHeader(event, 'x-hub-signature-256')
22 const deliveryHeader = getRequestHeader(event, 'x-github-delivery')
23 const eventName = getRequestHeader(event, 'x-github-event')
24
25 if (!signature || !deliveryHeader || !eventName) {
26 throw createError({ statusCode: 400, statusMessage: 'missing required headers' })
27 }
28
29 const rawBody = await readRawBody(event, 'utf8')
30 if (!rawBody) {
31 throw createError({ statusCode: 400, statusMessage: 'empty body' })
32 }
33
34 const valid = await verify(githubWebhookSecret, rawBody, signature)
35 if (!valid) {
36 throw createError({ statusCode: 401, statusMessage: 'invalid signature' })
37 }
38
39 const deliveryId = deliveryHeader.toLowerCase()
40
41 // Idempotent insert. If this delivery has already been seen, the row is not
42 // re-inserted and `inserted` is empty; we 200 and skip downstream work.
43 const db = useDb()
44 const inserted = await db
45 .insert(webhookEvent)
46 .values({
47 deliveryId,
48 source: 'github',
49 event: eventName,
50 })
51 .onConflictDoNothing({ target: webhookEvent.deliveryId })
52 .returning({ deliveryId: webhookEvent.deliveryId })
53
54 if (inserted.length === 0) {
55 return { ok: true, duplicate: true }
56 }
57
58 // Bookkeeping for installation lifecycle events. Sync work itself is enqueued
59 // by later commits; for now we just keep the `installation` table in step so
60 // those commits can FK-reference rows that already exist.
61 if (eventName === 'installation') {
62 const body = await readBody<InstallationEvent>(event)
63 const action = body.action
64
65 if (action === 'created') {
66 const account = body.installation.account
67 // GitHub's `installation.account` is `User | Enterprise | null`, but the
68 // ones we accept are user/org accounts; bail loudly on anything else.
69 if (!account || !('login' in account) || !('type' in account)) {
70 throw createError({ statusCode: 400, statusMessage: 'unsupported installation account' })
71 }
72 const accountType = account.type === 'Organization' ? 'Organization' : 'User'
73 await db.insert(installation).values({
74 id: body.installation.id,
75 accountLogin: account.login,
76 accountId: account.id,
77 accountType,
78 }).onConflictDoNothing({ target: installation.id })
79 }
80 else if (action === 'deleted') {
81 // installation row deletion cascades to user_identity, ssh_key, repo_mapping.
82 // The corresponding sh.tangled.publicKey record on the user's PDS is revoked
83 // in commit 15.
84 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`)
85 }
86 else if (action === 'suspend') {
87 await db.update(installation)
88 .set({ suspendedAt: new Date() })
89 .where(sql`${installation.id} = ${body.installation.id}`)
90 }
91 else if (action === 'unsuspend') {
92 await db.update(installation)
93 .set({ suspendedAt: null })
94 .where(sql`${installation.id} = ${body.installation.id}`)
95 }
96 }
97
98 // TODO(commit 7): enqueue a job for recognised event types.
99 if (RECOGNISED_EVENTS.has(eventName)) {
100 // Will become: await enqueue({ kind: eventName, payload: <envelope> })
101 }
102
103 return { ok: true, deliveryId }
104})