From b02470e7806236c6e1082a35ba75d152648562e0 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:24:42 +0200 Subject: [PATCH] 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 * refactor: rename TRIGGER_API_KEY to TRIGGER_SECRET_KEY Co-Authored-By: morgan@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/api/v2/src/app.controller.ts | 1 + apps/api/v2/src/app.module.ts | 7 +- .../src/vercel-webhook.controller.e2e-spec.ts | 157 ++++++++++++++++++ apps/api/v2/src/vercel-webhook.controller.ts | 86 ++++++++++ apps/api/v2/src/vercel-webhook.guard.ts | 65 ++++++++ 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 apps/api/v2/src/vercel-webhook.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/vercel-webhook.controller.ts create mode 100644 apps/api/v2/src/vercel-webhook.guard.ts diff --git a/apps/api/v2/src/app.controller.ts b/apps/api/v2/src/app.controller.ts index 354dd66441..6c95151f12 100644 --- a/apps/api/v2/src/app.controller.ts +++ b/apps/api/v2/src/app.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, VERSION_NEUTRAL, Version } from "@nestjs/common"; + @Controller() export class AppController { @Get("health") diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 5cd62b2545..71f05f6cd4 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -21,6 +21,7 @@ import { JwtModule } from "@/modules/jwt/jwt.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { RedisService } from "@/modules/redis/redis.service"; +import { VercelWebhookController } from "@/vercel-webhook.controller"; @Module({ imports: [ @@ -58,7 +59,7 @@ import { RedisService } from "@/modules/redis/redis.service"; AuthModule, JwtModule, ], - controllers: [AppController], + controllers: [AppController, VercelWebhookController], providers: [ { provide: APP_FILTER, @@ -93,6 +94,10 @@ export class AppModule implements NestModule { { path: "/v2/billing/webhook", method: RequestMethod.POST, + }, + { + path: "/v2/webhooks/vercel/deployment-promoted", + method: RequestMethod.POST, } ) .apply(JsonBodyMiddleware) diff --git a/apps/api/v2/src/vercel-webhook.controller.e2e-spec.ts b/apps/api/v2/src/vercel-webhook.controller.e2e-spec.ts new file mode 100644 index 0000000000..8c7e76c267 --- /dev/null +++ b/apps/api/v2/src/vercel-webhook.controller.e2e-spec.ts @@ -0,0 +1,157 @@ +import * as crypto from "node:crypto"; +import type { INestApplication } from "@nestjs/common"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; + +describe("Vercel Webhook Controller (e2e)", () => { + let app: INestApplication; + const WEBHOOK_SECRET = "test-webhook-secret"; + const TRIGGER_VERSION = "test-version-123"; + const TRIGGER_SECRET_KEY = "test-trigger-secret-key"; + + beforeAll(async () => { + // biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables + process.env.VERCEL_PROMOTE_WEBHOOK_SECRET = WEBHOOK_SECRET; + // biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables + process.env.TRIGGER_VERSION = TRIGGER_VERSION; + // biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables + process.env.TRIGGER_SECRET_KEY = TRIGGER_SECRET_KEY; + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + // biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables + delete process.env.VERCEL_PROMOTE_WEBHOOK_SECRET; + // biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables + delete process.env.TRIGGER_VERSION; + // biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables + delete process.env.TRIGGER_SECRET_KEY; + await app.close(); + }); + + function generateSignature(payload: object): string { + // Compute signature from the JSON stringified payload + // This matches how the guard will re-stringify the parsed object + return crypto.createHmac("sha1", WEBHOOK_SECRET).update(JSON.stringify(payload)).digest("hex"); + } + + describe("POST /webhooks/vercel/deployment-promoted", () => { + it("should reject requests without x-vercel-signature header", async () => { + await request(app.getHttpServer()) + .post("/v2/webhooks/vercel/deployment-promoted") + .send({ type: "deployment.promoted" }) + .expect(401); + }); + + it("should reject requests with invalid signature", async () => { + await request(app.getHttpServer()) + .post("/v2/webhooks/vercel/deployment-promoted") + .set("x-vercel-signature", "0000000000000000000000000000000000000000") + .send({ type: "deployment.promoted" }) + .expect(401); + }); + + it("should ignore non-deployment.promoted events with valid signature", async () => { + const payload = { type: "deployment.created" }; + const signature = generateSignature(payload); + + const response = await request(app.getHttpServer()) + .post("/v2/webhooks/vercel/deployment-promoted") + .set("x-vercel-signature", signature) + .send(payload) + .expect(200); + + expect(response.body.status).toBe("ignored"); + expect(response.body.version).toBe(""); + }); + + it("should process deployment.promoted events with valid signature", async () => { + const payload = { type: "deployment.promoted" }; + const signature = generateSignature(payload); + + const originalFetch = global.fetch; + let fetchCalled = false; + let fetchUrl = ""; + let fetchOptions: RequestInit | undefined; + + global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => { + fetchCalled = true; + fetchUrl = url; + fetchOptions = options; + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(""), + }); + }); + + try { + const response = await request(app.getHttpServer()) + .post("/v2/webhooks/vercel/deployment-promoted") + .set("x-vercel-signature", signature) + .send(payload) + .expect(200); + + expect(response.body.status).toBe("success"); + expect(response.body.version).toBe(TRIGGER_VERSION); + expect(fetchCalled).toBe(true); + expect(fetchUrl).toBe(`https://api.trigger.dev/api/v1/deployments/${TRIGGER_VERSION}/promote`); + expect(fetchOptions?.method).toBe("POST"); + expect(fetchOptions?.headers).toEqual( + expect.objectContaining({ + Authorization: `Bearer ${TRIGGER_SECRET_KEY}`, + }) + ); + } finally { + global.fetch = originalFetch; + } + }); + + it("should retry on trigger.dev API failure", async () => { + const payload = { type: "deployment.promoted" }; + const signature = generateSignature(payload); + + const originalFetch = global.fetch; + let fetchCallCount = 0; + + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++; + if (fetchCallCount < 3) { + return Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal Server Error"), + }); + } + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(""), + }); + }); + + try { + const response = await request(app.getHttpServer()) + .post("/v2/webhooks/vercel/deployment-promoted") + .set("x-vercel-signature", signature) + .send(payload) + .expect(200); + + expect(response.body.status).toBe("success"); + expect(fetchCallCount).toBe(3); + } finally { + global.fetch = originalFetch; + } + }, 30000); + }); +}); diff --git a/apps/api/v2/src/vercel-webhook.controller.ts b/apps/api/v2/src/vercel-webhook.controller.ts new file mode 100644 index 0000000000..ee7385e02a --- /dev/null +++ b/apps/api/v2/src/vercel-webhook.controller.ts @@ -0,0 +1,86 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { VercelWebhookGuard } from "./vercel-webhook.guard"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; + +interface VercelWebhookPayload { + type?: string; +} + +@Controller({ + path: "/v2/webhooks/vercel", + version: API_VERSIONS_VALUES, +}) +@ApiExcludeController(true) +export class VercelWebhookController { + private readonly logger = new Logger(VercelWebhookController.name); + + @Post("deployment-promoted") + @UseGuards(VercelWebhookGuard) + @HttpCode(HttpStatus.OK) + async handlePromotion(@Body() payload: VercelWebhookPayload): Promise<{ status: string; version: string }> { + if (payload.type !== "deployment.promoted") { + return { status: "ignored", version: "" }; + } + + // biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API + const TRIGGER_VERSION = process.env.TRIGGER_VERSION; + + if (!TRIGGER_VERSION) { + this.logger.error("TRIGGER_VERSION is not defined"); + throw new UnauthorizedException("Trigger.dev configuration error"); + } + + this.logger.log(`Vercel Promoted! Promoting Trigger.dev to: ${TRIGGER_VERSION}`); + + await this.promoteTriggerDeployment(TRIGGER_VERSION); + + return { status: "success", version: TRIGGER_VERSION }; + } + + private async promoteTriggerDeployment(version: string, maxRetries: number = 3): Promise { + // biome-ignore lint/style/noProcessEnv: Environment variable access required for trigger.dev API + const triggerSecretKey = process.env.TRIGGER_SECRET_KEY; + + const url = `https://api.trigger.dev/api/v1/deployments/${version}/promote`; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${triggerSecretKey}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Trigger.dev API Error (${response.status}): ${errorText}`); + } + + this.logger.log(`Successfully promoted Trigger.dev deployment: ${version}`); + return; + } catch (error) { + let errorMessage = "Unknown error"; + if (error instanceof Error) { + errorMessage = error.message; + } + this.logger.error(`Promotion attempt ${attempt} failed: ${errorMessage}`); + if (attempt === maxRetries) throw error; + + // Exponential backoff: 2s, 4s, 8s + await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000)); + } + } + } +} diff --git a/apps/api/v2/src/vercel-webhook.guard.ts b/apps/api/v2/src/vercel-webhook.guard.ts new file mode 100644 index 0000000000..18868f03ce --- /dev/null +++ b/apps/api/v2/src/vercel-webhook.guard.ts @@ -0,0 +1,65 @@ +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; + } +}