my pkgs monorepo
1/**
2 * Validates and parses an incoming GitHub Sponsors webhook request.
3 *
4 * GitHub signs the JSON body with HMAC-SHA256 and sends it in the
5 * X-Hub-Signature-256 header as `sha256=<hex>`. We verify the signature
6 * before parsing the payload.
7 *
8 * Required environment variable:
9 * GITHUB_WEBHOOK_SECRET — the secret set in your GitHub Sponsors webhook config
10 *
11 * @see https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
12 */
13
14import type { GitHubSponsorshipWebhookPayload } from './github-types.js';
15
16export class GitHubWebhookError extends Error {
17 constructor(
18 message: string,
19 public readonly status: number
20 ) {
21 super(message);
22 }
23}
24
25export interface ParseGitHubWebhookOptions {
26 /** Override GITHUB_WEBHOOK_SECRET from process.env */
27 secret?: string;
28}
29
30/** Constant-time hex comparison to prevent timing attacks. */
31async function verifySignature(secret: string, body: string, signature: string): Promise<boolean> {
32 const enc = new TextEncoder();
33 const key = await crypto.subtle.importKey(
34 'raw',
35 enc.encode(secret),
36 { name: 'HMAC', hash: 'SHA-256' },
37 false,
38 ['sign']
39 );
40 const mac = await crypto.subtle.sign('HMAC', key, enc.encode(body));
41 const expected = 'sha256=' + Array.from(new Uint8Array(mac)).map((b) => b.toString(16).padStart(2, '0')).join('');
42
43 if (expected.length !== signature.length) return false;
44 // Constant-time comparison
45 let mismatch = 0;
46 for (let i = 0; i < expected.length; i++) {
47 mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
48 }
49 return mismatch === 0;
50}
51
52export async function parseGitHubSponsorsWebhook(
53 request: Request,
54 options?: ParseGitHubWebhookOptions
55): Promise<GitHubSponsorshipWebhookPayload> {
56 const secret = options?.secret ?? process.env.GITHUB_WEBHOOK_SECRET;
57 if (!secret) throw new GitHubWebhookError('GITHUB_WEBHOOK_SECRET is not set', 500);
58
59 const event = request.headers.get('x-github-event');
60 if (event !== 'sponsorship') {
61 throw new GitHubWebhookError(`Unexpected event type: ${event}`, 400);
62 }
63
64 const signature = request.headers.get('x-hub-signature-256');
65 if (!signature) throw new GitHubWebhookError('Missing X-Hub-Signature-256 header', 400);
66
67 const body = await request.text();
68
69 const valid = await verifySignature(secret, body, signature);
70 if (!valid) throw new GitHubWebhookError('Signature verification failed', 401);
71
72 let payload: GitHubSponsorshipWebhookPayload;
73 try {
74 payload = JSON.parse(body);
75 } catch {
76 throw new GitHubWebhookError('Invalid JSON body', 400);
77 }
78
79 return payload;
80}