Skip to content

Commit 528759d

Browse files
committed
WIP with webhook SDK function and types
1 parent d622aba commit 528759d

File tree

4 files changed

+310
-0
lines changed

4 files changed

+310
-0
lines changed

apps/webapp/app/models/projectAlert.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.serve
44
export const ProjectAlertWebhookProperties = z.object({
55
secret: EncryptedSecretValueSchema,
66
url: z.string(),
7+
version: z.string().optional().default("v1"),
78
});
89

910
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;

packages/core/src/v3/schemas/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from "./eventFilter.js";
1010
export * from "./openTelemetry.js";
1111
export * from "./config.js";
1212
export * from "./build.js";
13+
export * from "./webhooks.js";
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { z } from "zod";
2+
import { MachinePresetName, TaskRunError } from "./common.js";
3+
import { RunStatus } from "./api.js";
4+
import { RuntimeEnvironmentTypeSchema } from "../../schemas/api.js";
5+
6+
const AlertWebhookRunFailedObject = z.object({
7+
task: z.object({
8+
id: z.string(),
9+
filePath: z.string(),
10+
exportName: z.string(),
11+
version: z.string(),
12+
sdkVersion: z.string(),
13+
cliVersion: z.string(),
14+
}),
15+
run: z.object({
16+
id: z.string(),
17+
number: z.number(),
18+
status: RunStatus,
19+
createdAt: z.coerce.date(),
20+
startedAt: z.coerce.date().optional(),
21+
completedAt: z.coerce.date().optional(),
22+
isTest: z.boolean(),
23+
idempotencyKey: z.string(),
24+
tags: z.array(z.string()),
25+
error: TaskRunError,
26+
machine: MachinePresetName,
27+
dashboardUrl: z.string(),
28+
}),
29+
environment: z.object({
30+
id: z.string(),
31+
type: RuntimeEnvironmentTypeSchema,
32+
slug: z.string(),
33+
}),
34+
organization: z.object({
35+
id: z.string(),
36+
slug: z.string(),
37+
name: z.string(),
38+
}),
39+
project: z.object({
40+
id: z.string(),
41+
ref: z.string(),
42+
slug: z.string(),
43+
name: z.string(),
44+
}),
45+
});
46+
export type AlertWebhookRunFailedObject = z.infer<typeof AlertWebhookRunFailedObject>;
47+
48+
export const DeployError = z.object({
49+
name: z.string(),
50+
message: z.string(),
51+
stack: z.string().optional(),
52+
stderr: z.string().optional(),
53+
});
54+
export type DeployError = z.infer<typeof DeployError>;
55+
56+
export const AlertWebhookDeploymentObject = z.discriminatedUnion("success", [
57+
z.object({
58+
success: z.literal(true),
59+
deployment: z.object({
60+
id: z.string(),
61+
status: z.string(),
62+
version: z.string(),
63+
shortCode: z.string(),
64+
deployedAt: z.coerce.date(),
65+
}),
66+
tasks: z.array(
67+
z.object({
68+
id: z.string(),
69+
filePath: z.string(),
70+
exportName: z.string(),
71+
triggerSource: z.string(),
72+
})
73+
),
74+
environment: z.object({
75+
id: z.string(),
76+
type: RuntimeEnvironmentTypeSchema,
77+
slug: z.string(),
78+
}),
79+
organization: z.object({
80+
id: z.string(),
81+
slug: z.string(),
82+
name: z.string(),
83+
}),
84+
project: z.object({
85+
id: z.string(),
86+
ref: z.string(),
87+
slug: z.string(),
88+
name: z.string(),
89+
}),
90+
}),
91+
z.object({
92+
success: z.literal(false),
93+
deployment: z.object({
94+
id: z.string(),
95+
status: z.string(),
96+
version: z.string(),
97+
shortCode: z.string(),
98+
failedAt: z.coerce.date(),
99+
}),
100+
environment: z.object({
101+
id: z.string(),
102+
type: RuntimeEnvironmentTypeSchema,
103+
slug: z.string(),
104+
}),
105+
organization: z.object({
106+
id: z.string(),
107+
slug: z.string(),
108+
name: z.string(),
109+
}),
110+
project: z.object({
111+
id: z.string(),
112+
ref: z.string(),
113+
slug: z.string(),
114+
name: z.string(),
115+
}),
116+
error: DeployError,
117+
}),
118+
]);
119+
120+
export type AlertWebhookDeploymentObject = z.infer<typeof AlertWebhookDeploymentObject>;
121+
122+
const commonProperties = {
123+
id: z.string(),
124+
created: z.coerce.date(),
125+
webhookVersion: z.string(),
126+
};
127+
128+
export const Webhook = z.discriminatedUnion("type", [
129+
z.object({
130+
...commonProperties,
131+
type: z.literal("alert.run.failed"),
132+
object: AlertWebhookRunFailedObject,
133+
}),
134+
z.object({
135+
...commonProperties,
136+
type: z.literal("alert.deployment.finished"),
137+
object: AlertWebhookDeploymentObject,
138+
}),
139+
]);
140+
141+
export type Webhook = z.infer<typeof Webhook>;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Webhook } from "@trigger.dev/core/v3";
2+
import { subtle } from "crypto";
3+
4+
/**
5+
* The type of error thrown when a webhook fails to parse or verify
6+
*/
7+
export class WebhookError extends Error {
8+
constructor(message: string) {
9+
super(message);
10+
this.name = "WebhookError";
11+
}
12+
}
13+
14+
/** Header name used for webhook signatures */
15+
const SIGNATURE_HEADER_NAME = "x-trigger-signature-hmacsha256";
16+
17+
/**
18+
* Options for constructing a webhook event
19+
*/
20+
type ConstructEventOptions = {
21+
/** Raw payload as string or Buffer */
22+
payload: string | Buffer;
23+
/** Signature header as string, Buffer, or string array */
24+
header: string | Buffer | Array<string>;
25+
};
26+
27+
/**
28+
* Interface describing the webhook utilities
29+
*/
30+
interface Webhooks {
31+
/**
32+
* Constructs and validates a webhook event from an incoming request
33+
* @param request - Either a Request object or ConstructEventOptions containing the payload and signature
34+
* @param secret - Secret key used to verify the webhook signature
35+
* @returns Promise resolving to a validated AlertWebhook object
36+
* @throws {WebhookError} If validation fails or payload can't be parsed
37+
*
38+
* @example
39+
* // Using with Request object
40+
* const event = await webhooks.constructEvent(request, "webhook_secret");
41+
*
42+
* @example
43+
* // Using with manual options
44+
* const event = await webhooks.constructEvent({
45+
* payload: rawBody,
46+
* header: signatureHeader
47+
* }, "webhook_secret");
48+
*/
49+
constructEvent(request: ConstructEventOptions | Request, secret: string): Promise<Webhook>;
50+
51+
/** Header name used for webhook signatures */
52+
SIGNATURE_HEADER_NAME: string;
53+
}
54+
55+
/**
56+
* Webhook utilities for handling incoming webhook requests
57+
*/
58+
export const webhooks: Webhooks = {
59+
constructEvent,
60+
SIGNATURE_HEADER_NAME,
61+
};
62+
63+
async function constructEvent(
64+
request: ConstructEventOptions | Request,
65+
secret: string
66+
): Promise<Webhook> {
67+
let payload: string;
68+
let signature: string;
69+
70+
if (request instanceof Request) {
71+
if (!secret) {
72+
throw new WebhookError("Secret is required when passing a Request object");
73+
}
74+
75+
const signatureHeader = request.headers.get(SIGNATURE_HEADER_NAME);
76+
if (!signatureHeader) {
77+
throw new WebhookError("No signature header found");
78+
}
79+
signature = signatureHeader;
80+
81+
payload = await request.text();
82+
} else {
83+
payload = request.payload.toString();
84+
85+
if (Array.isArray(request.header)) {
86+
throw new WebhookError("Signature header cannot be an array");
87+
}
88+
signature = request.header.toString();
89+
}
90+
91+
// Verify the signature
92+
const isValid = await verifySignature(payload, signature, secret);
93+
94+
if (!isValid) {
95+
throw new WebhookError("Invalid signature");
96+
}
97+
98+
// Parse and validate the payload
99+
try {
100+
const jsonPayload = JSON.parse(payload);
101+
const parsedPayload = Webhook.parse(jsonPayload);
102+
return parsedPayload;
103+
} catch (error) {
104+
if (error instanceof Error) {
105+
throw new WebhookError(`Webhook parsing failed: ${error.message}`);
106+
}
107+
throw new WebhookError("Webhook parsing failed");
108+
}
109+
}
110+
111+
/**
112+
* Verifies the signature of a webhook payload
113+
* @param payload - Raw payload string to verify
114+
* @param signature - Expected signature to check against
115+
* @param secret - Secret key used to generate the signature
116+
* @returns Promise resolving to boolean indicating if signature is valid
117+
* @throws {WebhookError} If signature verification process fails
118+
*
119+
* @example
120+
* const isValid = await verifySignature(
121+
* '{"event": "test"}',
122+
* "abc123signature",
123+
* "webhook_secret"
124+
* );
125+
*/
126+
async function verifySignature(
127+
payload: string,
128+
signature: string,
129+
secret: string
130+
): Promise<boolean> {
131+
try {
132+
// Convert the payload and secret to buffers
133+
const hashPayload = Buffer.from(payload, "utf-8");
134+
const hmacSecret = Buffer.from(secret, "utf-8");
135+
136+
// Import the secret key
137+
const key = await subtle.importKey(
138+
"raw",
139+
hmacSecret,
140+
{ name: "HMAC", hash: "SHA-256" },
141+
false,
142+
["sign", "verify"]
143+
);
144+
145+
// Calculate the expected signature
146+
const actualSignature = await subtle.sign("HMAC", key, hashPayload);
147+
const actualSignatureHex = Buffer.from(actualSignature).toString("hex");
148+
149+
// Compare signatures using timing-safe comparison
150+
return timingSafeEqual(signature, actualSignatureHex);
151+
} catch (error) {
152+
throw new WebhookError("Signature verification failed");
153+
}
154+
}
155+
156+
// Timing-safe comparison to prevent timing attacks
157+
function timingSafeEqual(a: string, b: string): boolean {
158+
if (a.length !== b.length) {
159+
return false;
160+
}
161+
162+
let result = 0;
163+
for (let i = 0; i < a.length; i++) {
164+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
165+
}
166+
return result === 0;
167+
}

0 commit comments

Comments
 (0)