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:
co-authored by
morgan@cal.com
Devin AI
parent
c536445eec
commit
b02470e780
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, VERSION_NEUTRAL, Version } from "@nestjs/common";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@Get("health")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user