diff --git a/packages/backend/convex/githubSync.ts b/packages/backend/convex/githubSync.ts index 9af2d71..78692be 100644 --- a/packages/backend/convex/githubSync.ts +++ b/packages/backend/convex/githubSync.ts @@ -503,6 +503,25 @@ export const syncForkWithUpstream = action({ }, }); +export const refreshSpoonById = internalAction({ + args: { spoonId: v.id('spoons'), ownerId: v.id('users') }, + handler: async ( + ctx, + { spoonId, ownerId }, + ): Promise<{ success: boolean; error?: string }> => { + try { + await refreshOwnedSpoon(ctx, ownerId, spoonId, 'scheduled_check'); + return { success: true }; + } catch (error) { + // Swallow so webhook fan-out never throws an unhandled rejection. + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); + export const refreshDueSpoons = internalAction({ args: { limit: v.optional(v.number()) }, handler: async ( diff --git a/packages/backend/convex/githubWebhooks.ts b/packages/backend/convex/githubWebhooks.ts new file mode 100644 index 0000000..66233ec --- /dev/null +++ b/packages/backend/convex/githubWebhooks.ts @@ -0,0 +1,91 @@ +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' }, + }); +}); diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts index 3aa3437..3d632c4 100644 --- a/packages/backend/convex/http.ts +++ b/packages/backend/convex/http.ts @@ -1,9 +1,16 @@ import { httpRouter } from 'convex/server'; import { auth } from './auth'; +import { handleGithubWebhook } from './githubWebhooks'; const http = httpRouter(); auth.addHttpRoutes(http); +http.route({ + path: '/webhooks/github', + method: 'POST', + handler: handleGithubWebhook, +}); + export default http; diff --git a/packages/backend/tests/unit/github-webhook-route.test.ts b/packages/backend/tests/unit/github-webhook-route.test.ts new file mode 100644 index 0000000..2cd317d --- /dev/null +++ b/packages/backend/tests/unit/github-webhook-route.test.ts @@ -0,0 +1,205 @@ +import { createHmac } from 'node:crypto'; + +import { convexTest } from 'convex-test'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +import type { Id } from '../../convex/_generated/dataModel.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +const SECRET = 'testsecret'; + +/** Sign a body the same way the Web Crypto verifier does (HMAC-SHA256 hex). */ +const sign = (body: string): string => + `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`; + +const post = ( + t: ReturnType, + event: string, + body: string, + signature: string, +) => + t.fetch('/webhooks/github', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-event': event, + 'x-hub-signature-256': signature, + }, + body, + }); + +const seedConnection = async ( + t: ReturnType, + installationId: string, +) => + await t.run(async (ctx) => { + const now = Date.now(); + const userId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A' }); + return await ctx.db.insert('gitConnections', { + userId, + provider: 'github', + displayName: `GitHub installation ${installationId}`, + installationId, + status: 'active', + connectedAt: now, + updatedAt: now, + }); + }); + +const seedGithubSpoon = async ( + t: ReturnType, + forkOwner: string, + forkRepo: string, +) => + (await t.run(async (ctx) => { + const now = Date.now(); + const ownerId = await ctx.db.insert('users', { + email: 'owner@b.c', + name: 'Owner', + }); + return await ctx.db.insert('spoons', { + ownerId, + name: `${forkOwner}/${forkRepo}`, + provider: 'github', + upstreamOwner: 'up', + upstreamRepo: 'x', + upstreamDefaultBranch: 'main', + upstreamUrl: 'https://github.com/up/x', + forkOwner, + forkRepo, + forkUrl: `https://github.com/${forkOwner}/${forkRepo}`, + visibility: 'public', + maintenanceMode: 'watch', + syncCadence: 'daily', + productionRefStrategy: 'default_branch', + status: 'active', + createdAt: now, + updatedAt: now, + }); + })) as Id<'spoons'>; + +describe('POST /webhooks/github', () => { + let previousSecret: string | undefined; + + beforeAll(() => { + previousSecret = process.env.GITHUB_APP_WEBHOOK_SECRET; + process.env.GITHUB_APP_WEBHOOK_SECRET = SECRET; + }); + + afterAll(() => { + if (previousSecret === undefined) { + delete process.env.GITHUB_APP_WEBHOOK_SECRET; + } else { + process.env.GITHUB_APP_WEBHOOK_SECRET = previousSecret; + } + }); + + test('rejects a bad signature with 401 and does not process the payload', async () => { + const t = convexTest(schema, modules); + const connectionId = await seedConnection(t, '99'); + const body = JSON.stringify({ action: 'deleted', installation: { id: 99 } }); + + const res = await post(t, 'installation', body, 'sha256=deadbeef'); + + expect(res.status).toBe(401); + // The unverified payload must NOT have flipped the connection status. + const connection = await t.run( + async (ctx) => await ctx.db.get(connectionId), + ); + expect(connection?.status).toBe('active'); + }); + + test('returns 503 when the webhook secret is not configured', async () => { + const saved = process.env.GITHUB_APP_WEBHOOK_SECRET; + delete process.env.GITHUB_APP_WEBHOOK_SECRET; + try { + const t = convexTest(schema, modules); + const body = JSON.stringify({ action: 'created', installation: { id: 1 } }); + const res = await post(t, 'installation', body, sign(body)); + expect(res.status).toBe(503); + } finally { + process.env.GITHUB_APP_WEBHOOK_SECRET = saved; + } + }); + + test('flips a connection to revoked on installation "deleted"', async () => { + const t = convexTest(schema, modules); + const connectionId = await seedConnection(t, '99'); + const body = JSON.stringify({ action: 'deleted', installation: { id: 99 } }); + + const res = await post(t, 'installation', body, sign(body)); + + expect(res.status).toBe(200); + const connection = await t.run( + async (ctx) => await ctx.db.get(connectionId), + ); + expect(connection?.status).toBe('revoked'); + }); + + test('flips a connection to active on installation "unsuspend"', async () => { + const t = convexTest(schema, modules); + const connectionId = await t.run(async (ctx) => { + const now = Date.now(); + const userId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A' }); + return await ctx.db.insert('gitConnections', { + userId, + provider: 'github', + displayName: 'GitHub installation 77', + installationId: '77', + status: 'revoked', + connectedAt: now, + updatedAt: now, + }); + }); + const body = JSON.stringify({ action: 'unsuspend', installation: { id: 77 } }); + + const res = await post(t, 'installation', body, sign(body)); + + expect(res.status).toBe(200); + const connection = await t.run( + async (ctx) => await ctx.db.get(connectionId), + ); + expect(connection?.status).toBe('active'); + }); + + test('accepts a valid push and returns 200', async () => { + const t = convexTest(schema, modules); + // Seed a spoon whose fork repo backs the pushed repository. + await seedGithubSpoon(t, 'me', 'x-fork'); + const body = JSON.stringify({ + repository: { full_name: 'me/x-fork' }, + }); + + // NOTE (convex-test limitation): the httpAction schedules + // internal.githubSync.refreshSpoonById, which is a `'use node'` action and + // therefore cannot execute in the convex-test VM. We assert the route + // verified the signature, matched via findForRepo, and returned 200 without + // throwing; we deliberately do NOT call finishInProgressScheduledFunctions + // (the node action would fail to run under the test runtime). + const res = await post(t, 'push', body, sign(body)); + + expect(res.status).toBe(200); + }); + + test('returns 200 for a push to an unknown repo (no matches)', async () => { + const t = convexTest(schema, modules); + const body = JSON.stringify({ + repository: { full_name: 'nobody/nothing' }, + }); + + const res = await post(t, 'push', body, sign(body)); + + expect(res.status).toBe(200); + }); + + test('returns 200 for a verified but unhandled event type', async () => { + const t = convexTest(schema, modules); + const body = JSON.stringify({ zen: 'Keep it logically awesome.' }); + + const res = await post(t, 'ping', body, sign(body)); + + expect(res.status).toBe(200); + }); +});