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({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
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 { 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;
|
||||
|
||||
Reference in New Issue
Block a user