|
1 | 1 | import crypto from 'crypto' |
2 | 2 | import { createLogger } from '@sim/logger' |
| 3 | +import { NextResponse } from 'next/server' |
3 | 4 | import { safeCompare } from '@/lib/core/security/encryption' |
4 | 5 | import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' |
5 | 6 | import type { |
| 7 | + AuthContext, |
6 | 8 | DeleteSubscriptionContext, |
| 9 | + EventMatchContext, |
7 | 10 | FormatInputContext, |
8 | 11 | FormatInputResult, |
9 | 12 | SubscriptionContext, |
10 | 13 | SubscriptionResult, |
11 | 14 | WebhookProviderHandler, |
12 | 15 | } from '@/lib/webhooks/providers/types' |
13 | | -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' |
14 | 16 |
|
15 | 17 | const logger = createLogger('WebhookProvider:Vercel') |
16 | 18 |
|
| 19 | +function verifyVercelSignature(secret: string, signature: string, rawBody: string): boolean { |
| 20 | + const hash = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex') |
| 21 | + return safeCompare(hash, signature) |
| 22 | +} |
| 23 | + |
17 | 24 | export const vercelHandler: WebhookProviderHandler = { |
18 | | - verifyAuth: createHmacVerifier({ |
19 | | - configKey: 'webhookSecret', |
20 | | - headerName: 'x-vercel-signature', |
21 | | - validateFn: (secret, signature, body) => { |
22 | | - const hash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex') |
23 | | - return safeCompare(hash, signature) |
24 | | - }, |
25 | | - providerLabel: 'Vercel', |
26 | | - }), |
| 25 | + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { |
| 26 | + const secret = (providerConfig.webhookSecret as string | undefined)?.trim() |
| 27 | + if (!secret) { |
| 28 | + logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`) |
| 29 | + return new NextResponse( |
| 30 | + 'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', |
| 31 | + { status: 401 } |
| 32 | + ) |
| 33 | + } |
| 34 | + |
| 35 | + const signature = request.headers.get('x-vercel-signature') |
| 36 | + if (!signature) { |
| 37 | + logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`) |
| 38 | + return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 }) |
| 39 | + } |
| 40 | + |
| 41 | + if (!verifyVercelSignature(secret, signature, rawBody)) { |
| 42 | + logger.warn(`[${requestId}] Vercel signature verification failed`) |
| 43 | + return new NextResponse('Unauthorized - Invalid Vercel signature', { status: 401 }) |
| 44 | + } |
| 45 | + |
| 46 | + return null |
| 47 | + }, |
| 48 | + |
| 49 | + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { |
| 50 | + const triggerId = providerConfig.triggerId as string | undefined |
| 51 | + const obj = body as Record<string, unknown> |
| 52 | + const eventType = obj.type as string | undefined |
| 53 | + |
| 54 | + if (triggerId && triggerId !== 'vercel_webhook') { |
| 55 | + const { isVercelEventMatch } = await import('@/triggers/vercel/utils') |
| 56 | + if (!isVercelEventMatch(triggerId, eventType)) { |
| 57 | + logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, { |
| 58 | + webhookId: webhook.id, |
| 59 | + workflowId: workflow.id, |
| 60 | + triggerId, |
| 61 | + eventType, |
| 62 | + }) |
| 63 | + return false |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + return true |
| 68 | + }, |
| 69 | + |
| 70 | + extractIdempotencyId(body: unknown) { |
| 71 | + const id = (body as Record<string, unknown>)?.id |
| 72 | + if (id === undefined || id === null || id === '') { |
| 73 | + return null |
| 74 | + } |
| 75 | + return `vercel:${String(id)}` |
| 76 | + }, |
27 | 77 |
|
28 | 78 | async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> { |
29 | 79 | const { webhook, requestId } = ctx |
@@ -52,9 +102,8 @@ export const vercelHandler: WebhookProviderHandler = { |
52 | 102 | } |
53 | 103 |
|
54 | 104 | if (triggerId && !(triggerId in eventTypeMap)) { |
55 | | - logger.warn( |
56 | | - `[${requestId}] Unknown triggerId for Vercel: ${triggerId}, defaulting to all events`, |
57 | | - { triggerId, webhookId: webhook.id } |
| 105 | + throw new Error( |
| 106 | + `Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.` |
58 | 107 | ) |
59 | 108 | } |
60 | 109 |
|
@@ -147,10 +196,17 @@ export const vercelHandler: WebhookProviderHandler = { |
147 | 196 | { vercelWebhookId: externalId } |
148 | 197 | ) |
149 | 198 |
|
| 199 | + const signingSecret = responseBody.secret as string | undefined |
| 200 | + if (!signingSecret) { |
| 201 | + throw new Error( |
| 202 | + 'Vercel webhook was created but no signing secret was returned. Delete the webhook in Vercel and save this trigger again.' |
| 203 | + ) |
| 204 | + } |
| 205 | + |
150 | 206 | return { |
151 | 207 | providerConfigUpdates: { |
152 | 208 | externalId, |
153 | | - webhookSecret: (responseBody.secret as string) || '', |
| 209 | + webhookSecret: signingSecret, |
154 | 210 | }, |
155 | 211 | } |
156 | 212 | } catch (error: unknown) { |
@@ -206,20 +262,77 @@ export const vercelHandler: WebhookProviderHandler = { |
206 | 262 | const body = ctx.body as Record<string, unknown> |
207 | 263 | const payload = (body.payload || {}) as Record<string, unknown> |
208 | 264 |
|
| 265 | + const deployment = payload.deployment ?? null |
| 266 | + const project = payload.project ?? null |
| 267 | + const team = payload.team ?? null |
| 268 | + const user = payload.user ?? null |
| 269 | + const domain = payload.domain ?? null |
| 270 | + |
209 | 271 | return { |
210 | 272 | input: { |
211 | | - type: body.type || '', |
212 | | - id: body.id || '', |
213 | | - createdAt: body.createdAt || 0, |
214 | | - region: body.region || null, |
| 273 | + type: body.type ?? '', |
| 274 | + id: body.id != null ? String(body.id) : '', |
| 275 | + createdAt: (() => { |
| 276 | + const v = body.createdAt |
| 277 | + if (typeof v === 'number' && !Number.isNaN(v)) { |
| 278 | + return v |
| 279 | + } |
| 280 | + if (typeof v === 'string') { |
| 281 | + const parsed = Date.parse(v) |
| 282 | + return Number.isNaN(parsed) ? 0 : parsed |
| 283 | + } |
| 284 | + const n = Number(v) |
| 285 | + return Number.isNaN(n) ? 0 : n |
| 286 | + })(), |
| 287 | + region: body.region != null ? String(body.region) : null, |
215 | 288 | payload, |
216 | | - deployment: payload.deployment || null, |
217 | | - project: payload.project || null, |
218 | | - team: payload.team || null, |
219 | | - user: payload.user || null, |
220 | | - target: payload.target || null, |
221 | | - plan: payload.plan || null, |
222 | | - domain: payload.domain || null, |
| 289 | + deployment: |
| 290 | + deployment && typeof deployment === 'object' |
| 291 | + ? { |
| 292 | + id: |
| 293 | + (deployment as Record<string, unknown>).id != null |
| 294 | + ? String((deployment as Record<string, unknown>).id) |
| 295 | + : '', |
| 296 | + url: ((deployment as Record<string, unknown>).url as string) ?? '', |
| 297 | + name: ((deployment as Record<string, unknown>).name as string) ?? '', |
| 298 | + } |
| 299 | + : null, |
| 300 | + project: |
| 301 | + project && typeof project === 'object' |
| 302 | + ? { |
| 303 | + id: |
| 304 | + (project as Record<string, unknown>).id != null |
| 305 | + ? String((project as Record<string, unknown>).id) |
| 306 | + : '', |
| 307 | + name: ((project as Record<string, unknown>).name as string) ?? '', |
| 308 | + } |
| 309 | + : null, |
| 310 | + team: |
| 311 | + team && typeof team === 'object' |
| 312 | + ? { |
| 313 | + id: |
| 314 | + (team as Record<string, unknown>).id != null |
| 315 | + ? String((team as Record<string, unknown>).id) |
| 316 | + : '', |
| 317 | + } |
| 318 | + : null, |
| 319 | + user: |
| 320 | + user && typeof user === 'object' |
| 321 | + ? { |
| 322 | + id: |
| 323 | + (user as Record<string, unknown>).id != null |
| 324 | + ? String((user as Record<string, unknown>).id) |
| 325 | + : '', |
| 326 | + } |
| 327 | + : null, |
| 328 | + target: payload.target != null ? String(payload.target) : null, |
| 329 | + plan: payload.plan != null ? String(payload.plan) : null, |
| 330 | + domain: |
| 331 | + domain && typeof domain === 'object' |
| 332 | + ? { |
| 333 | + name: ((domain as Record<string, unknown>).name as string) ?? '', |
| 334 | + } |
| 335 | + : null, |
223 | 336 | }, |
224 | 337 | } |
225 | 338 | }, |
|
0 commit comments