feat(webhooks): add signature-verified GitHub webhook route
This commit is contained in:
@@ -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({
|
export const refreshDueSpoons = internalAction({
|
||||||
args: { limit: v.optional(v.number()) },
|
args: { limit: v.optional(v.number()) },
|
||||||
handler: async (
|
handler: async (
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { httpRouter } from 'convex/server';
|
import { httpRouter } from 'convex/server';
|
||||||
|
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
|
import { handleGithubWebhook } from './githubWebhooks';
|
||||||
|
|
||||||
const http = httpRouter();
|
const http = httpRouter();
|
||||||
|
|
||||||
auth.addHttpRoutes(http);
|
auth.addHttpRoutes(http);
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
path: '/webhooks/github',
|
||||||
|
method: 'POST',
|
||||||
|
handler: handleGithubWebhook,
|
||||||
|
});
|
||||||
|
|
||||||
export default http;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user