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>
This commit is contained in:
Morgan
2026-01-29 16:24:42 +09:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent c536445eec
commit b02470e780
5 changed files with 315 additions and 1 deletions
+1
View File
@@ -1,4 +1,5 @@
import { Controller, Get, VERSION_NEUTRAL, Version } from "@nestjs/common";
@Controller()
export class AppController {
@Get("health")
+6 -1
View File
@@ -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)
@@ -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);
});
});
@@ -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<void> {
// 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));
}
}
}
}
+65
View File
@@ -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;
}
}