Files
calendar/apps/api/v2/src/vercel-webhook.guard.ts
T
MorganGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
b02470e780 feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev (#27340)
* feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: rename TRIGGER_API_KEY to TRIGGER_SECRET_KEY

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 16:24:42 +09:00

66 lines
2.4 KiB
TypeScript

import * as crypto from "node:crypto";
import { CanActivate, ExecutionContext, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@Injectable()
export class VercelWebhookGuard implements CanActivate {
private readonly logger = new Logger(VercelWebhookGuard.name);
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const signature = request.headers["x-vercel-signature"];
// biome-ignore lint/style/noProcessEnv: Environment variable access required for webhook secret
const webhookSecret = process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
if (!webhookSecret) {
this.logger.error("VERCEL_PROMOTE_WEBHOOK_SECRET is not defined");
throw new UnauthorizedException("Webhook configuration error");
}
if (!signature) {
throw new UnauthorizedException("Missing x-vercel-signature header");
}
// Get raw body from RawBodyMiddleware (Buffer) or parsed body (object in test env)
const rawBody = request.body;
if (!rawBody) {
this.logger.error("Request body is empty. Ensure RawBodyMiddleware is active.");
throw new UnauthorizedException("Invalid request format");
}
// Determine the body bytes for signature verification
// In production: RawBodyMiddleware provides a Buffer
// In tests: body may be a parsed object, convert back to JSON string
let bodyForSignature: Buffer | string;
if (Buffer.isBuffer(rawBody)) {
bodyForSignature = rawBody;
} else if (typeof rawBody === "string") {
bodyForSignature = rawBody;
} else {
// Object from parsed JSON - convert back to string (test environment)
bodyForSignature = JSON.stringify(rawBody);
}
const expectedSignature = crypto.createHmac("sha1", webhookSecret).update(bodyForSignature).digest("hex");
// Check signature length first (timingSafeEqual throws if lengths differ)
if (signature.length !== expectedSignature.length) {
this.logger.warn("Invalid Vercel webhook signature length");
throw new UnauthorizedException("Invalid signature");
}
// Secure comparison to prevent timing attacks
const isSignatureValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature, "utf8"),
Buffer.from(signature, "utf8")
);
if (!isSignatureValid) {
this.logger.warn("Invalid Vercel webhook signature");
throw new UnauthorizedException("Invalid signature");
}
return true;
}
}