mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: add github app webhook ingress with signature verification

+153
+3
nuxt.config.ts
··· 11 11 devtools: { enabled: true }, 12 12 runtimeConfig: { 13 13 databaseUrl: '', 14 + githubAppId: '', 15 + githubAppPrivateKey: '', 16 + githubWebhookSecret: '', 14 17 }, 15 18 typescript: { 16 19 nodeTsConfig: {
+2
package.json
··· 36 36 "@nuxt/image": "^2.0.0", 37 37 "@nuxt/scripts": "^1.0.6", 38 38 "@nuxtjs/html-validator": "^2.1.0", 39 + "@octokit/webhooks-methods": "^6.0.0", 39 40 "drizzle-orm": "^0.45.2", 40 41 "nuxt": "^4.4.4", 41 42 "nuxt-og-image": "^6.4.11", ··· 46 47 }, 47 48 "devDependencies": { 48 49 "@nuxt/test-utils": "4.0.3", 50 + "@octokit/webhooks-types": "^7.6.1", 49 51 "@playwright/test": "1.59.1", 50 52 "@stylistic/eslint-plugin": "^5.10.0", 51 53 "@vitest/coverage-v8": "^4.1.5",
+17
pnpm-lock.yaml
··· 27 27 '@nuxtjs/html-validator': 28 28 specifier: ^2.1.0 29 29 version: 2.1.0(@voidzero-dev/vite-plus-test@0.1.20)(magicast@0.5.2) 30 + '@octokit/webhooks-methods': 31 + specifier: ^6.0.0 32 + version: 6.0.0 30 33 drizzle-orm: 31 34 specifier: ^0.45.2 32 35 version: 0.45.2(@neondatabase/serverless@1.1.0) ··· 52 55 '@nuxt/test-utils': 53 56 specifier: 4.0.3 54 57 version: 4.0.3(@playwright/test@1.59.1)(@voidzero-dev/vite-plus-test@0.1.20)(@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)))(crossws@0.4.5(srvx@0.11.15))(happy-dom@20.9.0)(magicast@0.5.2)(playwright-core@1.59.1)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4)) 58 + '@octokit/webhooks-types': 59 + specifier: ^7.6.1 60 + version: 7.6.1 55 61 '@playwright/test': 56 62 specifier: 1.59.1 57 63 version: 1.59.1 ··· 1326 1332 1327 1333 '@nuxtjs/html-validator@2.1.0': 1328 1334 resolution: {integrity: sha512-ldo8ioSsH3OEumtgwDMokTxlhjgO9FxjJWViAxisq5l/wjvaVX8SYTQ02wjtQcQQPSvS6BwgypAp400RlyFHng==} 1335 + 1336 + '@octokit/webhooks-methods@6.0.0': 1337 + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} 1338 + engines: {node: '>= 20'} 1339 + 1340 + '@octokit/webhooks-types@7.6.1': 1341 + resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} 1329 1342 1330 1343 '@one-ini/wasm@0.1.1': 1331 1344 resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} ··· 6919 6932 - jest-snapshot 6920 6933 - magicast 6921 6934 - vitest 6935 + 6936 + '@octokit/webhooks-methods@6.0.0': {} 6937 + 6938 + '@octokit/webhooks-types@7.6.1': {} 6922 6939 6923 6940 '@one-ini/wasm@0.1.1': {} 6924 6941
+104
server/api/github/webhook.post.ts
··· 1 + import type { InstallationEvent } from '@octokit/webhooks-types' 2 + import { verify } from '@octokit/webhooks-methods' 3 + import { sql } from 'drizzle-orm' 4 + import { installation, webhookEvent } from '~~/server/db/schema' 5 + 6 + const RECOGNISED_EVENTS = new Set([ 7 + 'push', 8 + 'create', 9 + 'delete', 10 + 'repository', 11 + 'installation', 12 + 'installation_repositories', 13 + ]) 14 + 15 + export 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 + })
+27
test/unit/github-webhook.spec.ts
··· 1 + import { sign, verify } from '@octokit/webhooks-methods' 2 + import { describe, expect, it } from 'vitest' 3 + 4 + describe('github webhook signature verification', () => { 5 + const secret = 'test-webhook-secret' 6 + const payload = JSON.stringify({ action: 'created', installation: { id: 1 } }) 7 + 8 + it('accepts a valid signature', async () => { 9 + const signature = await sign(secret, payload) 10 + expect(await verify(secret, payload, signature)).toBe(true) 11 + }) 12 + 13 + it('rejects a tampered payload', async () => { 14 + const signature = await sign(secret, payload) 15 + const tampered = payload.replace('"created"', '"deleted"') 16 + expect(await verify(secret, tampered, signature)).toBe(false) 17 + }) 18 + 19 + it('rejects a wrong secret', async () => { 20 + const signature = await sign(secret, payload) 21 + expect(await verify('not-the-secret', payload, signature)).toBe(false) 22 + }) 23 + 24 + it('rejects a malformed signature', async () => { 25 + expect(await verify(secret, payload, 'sha256=garbage')).toBe(false) 26 + }) 27 + })