92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
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' },
|
|
});
|
|
});
|