feat(webhooks): add signature-verified GitHub webhook route

This commit is contained in:
Gabriel Brown
2026-07-11 14:18:55 -04:00
parent 68dc7bb4ee
commit f1dbf8cd50
4 changed files with 322 additions and 0 deletions
+19
View File
@@ -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 (
+91
View File
@@ -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' },
});
});
+7
View File
@@ -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;
@@ -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<typeof convexTest>,
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<typeof convexTest>,
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<typeof convexTest>,
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);
});
});