1
26import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
27import { generateId } from "./events.js";
28import { aes256gcmDecrypt } from "./crypto.js";
29import { assertPublicUrl, assertPublicUrlString } from "./net.js";
30
31
32
33export { SsrfError } from "./net.js";
34
35export const WEBHOOK_DELIVERY_TIMEOUT_MS = 10_000;
36export const WEBHOOK_MAX_REDIRECTS = 3;
37export const WEBHOOK_USER_AGENT = "factory-webhooks/1.0";
38
39export const WEBHOOK_BACKOFF_BASE_MS = 30_000;
40export const WEBHOOK_BACKOFF_MAX_MS = 15 * 60_000;
41
42
43
44
45
46
47export function signWebhookBody(secret: string, rawBody: string, ts: number): string {
48 return createHmac("sha256", secret).update(`${ts}.` + rawBody).digest("hex");
49}
50
51
52export function webhookSignatureHeader(secret: string, rawBody: string, ts: number): string {
53 return `t=${ts},v1=${signWebhookBody(secret, rawBody, ts)}`;
54}
55
56
61export function parseWebhookSignatureHeader(
62 header: string | undefined
63): { t: number; v1: string } | null {
64 if (!header) return null;
65 const m = header.match(/t=(\d+)[;,] ?v1=([0-9a-f]{64})/);
66 if (!m || m[1] === undefined || m[2] === undefined) return null;
67 return { t: Number(m[1]), v1: m[2] };
68}
69
70
71export function verifyWebhookSignatureValue(
72 secret: string,
73 rawBody: string,
74 ts: number,
75 claimedV1: string
76): boolean {
77 const expected = signWebhookBody(secret, rawBody, ts);
78 if (claimedV1.length !== expected.length) return false;
79 return timingSafeEqual(Buffer.from(expected), Buffer.from(claimedV1));
80}
81
82
83
84
85
86export type SigningSecretSource = "encrypted" | "legacy-ciphertext" | "legacy-hash";
87
88
96export function resolveSigningSecret(
97 masterKey: string,
98 secretEncrypted: Uint8Array | string | null | undefined,
99 secretHash: string
100): { secret: string; source: SigningSecretSource } | null {
101 if (!masterKey) return null;
102 const b64 =
103 typeof secretEncrypted === "string" && secretEncrypted.length > 0
104 ? secretEncrypted
105 : secretEncrypted != null && secretEncrypted.length > 0
106 ? Buffer.from(secretEncrypted).toString("base64")
107 : null;
108 if (b64) {
109 try {
110 return { secret: aes256gcmDecrypt(b64, masterKey), source: "encrypted" };
111 } catch {
112
113 }
114 }
115 try {
116 return { secret: aes256gcmDecrypt(secretHash, masterKey), source: "legacy-ciphertext" };
117 } catch {
118
119 }
120 if (/^[0-9a-f]{64}$/.test(secretHash)) {
121 return { secret: secretHash, source: "legacy-hash" };
122 }
123 return null;
124}
125
126
127
128
129
130export interface DeliveryWorkspace {
131 id: string;
132 name: string;
133 slug: string;
134}
135
136export interface DeliveryEnvelopeInput {
137 id?: string;
138 type: string;
139 workspaceId: string;
140 workspace?: DeliveryWorkspace;
141 occurredAt?: string;
142 data: Record<string, unknown>;
143 dataDelta?: Record<string, unknown>;
144 actor?: { type: "user" | "api_key" | "integration" | "system"; id?: string };
145
146 test?: boolean;
147}
148
149
150export function buildDeliveryEnvelope(input: DeliveryEnvelopeInput): Record<string, unknown> {
151 const env: Record<string, unknown> = {
152 id: input.id ?? generateId("evt"),
153 type: input.type,
154 workspaceId: input.workspaceId,
155 occurredAt: input.occurredAt ?? new Date().toISOString(),
156 data: input.data
157 };
158 if (input.workspace) env.workspace = input.workspace;
159 if (input.dataDelta) env.dataDelta = input.dataDelta;
160 if (input.actor) env.actor = input.actor;
161 if (input.test) env.test = true;
162 return env;
163}
164
165
166
167
168
169export interface BackoffOptions {
170
171 baseMs?: number;
172
173 maxMs?: number;
174
175 jitter?: () => number;
176
177 rand?: () => number;
178}
179
180
183export function webhookBackoffMs(attempt: number, opts: BackoffOptions = {}): number {
184 const baseMs = opts.baseMs ?? WEBHOOK_BACKOFF_BASE_MS;
185 const maxMs = opts.maxMs ?? WEBHOOK_BACKOFF_MAX_MS;
186 const rand = opts.rand ?? Math.random;
187 const jitter = opts.jitter ?? (() => 1 + (rand() * 0.4 - 0.2));
188 const raw = baseMs * 2 ** Math.max(0, attempt - 1);
189 const capped = Math.min(raw, maxMs);
190 return Math.max(1, Math.round(capped * jitter()));
191}
192
193
194
195
196
197
204export async function assertSafeDeliveryUrl(url: string, allowPrivate: boolean): Promise<void> {
205 if (allowPrivate) return;
206 const u = new URL(url);
207 assertPublicUrlString(u.toString());
208 const host = u.hostname;
209
210
211 if (!/^[0-9a-fA-F:.]+$/.test(host)) {
212 await assertPublicUrl(u.toString());
213 }
214}
215
216
217
218
219
220export interface DeliveryTarget {
221 url: string;
222 secret: string;
223 event: string;
224 envelope: Record<string, unknown>;
225 deliveryId: string;
226
227 attempt: number;
228}
229
230export interface DeliveryResult {
231 status: number;
232 latencyMs: number;
233
234 transportError?: string;
235}
236
237
243export async function postWebhookDelivery(
244 target: DeliveryTarget,
245 opts: {
246 allowPrivate?: boolean;
247 timeoutMs?: number;
248 fetchImpl?: typeof fetch;
249 } = {}
250): Promise<DeliveryResult> {
251 const doFetch = opts.fetchImpl ?? fetch;
252 const timeoutMs = opts.timeoutMs ?? WEBHOOK_DELIVERY_TIMEOUT_MS;
253 const allowPrivate = opts.allowPrivate ?? false;
254 const rawBody = JSON.stringify(target.envelope);
255 const ts = Math.floor(Date.now() / 1000);
256 const started = Date.now();
257
258 const controller = new AbortController();
259 const timer = setTimeout(() => controller.abort(), timeoutMs);
260 try {
261 let current = target.url;
262 let last: Response;
263 for (let hop = 0; hop <= WEBHOOK_MAX_REDIRECTS; hop++) {
264 await assertSafeDeliveryUrl(current, allowPrivate);
265 last = await doFetch(current, {
266 method: "POST",
267 headers: {
268 "Content-Type": "application/json",
269 "User-Agent": WEBHOOK_USER_AGENT,
270 "X-Factory-Event": target.event,
271 "X-Factory-Event-Id": String(target.envelope.id ?? ""),
272 "X-Factory-Delivery-Id": target.deliveryId,
273 "X-Factory-Attempt": String(target.attempt),
274 "X-Factory-Signature": webhookSignatureHeader(target.secret, rawBody, ts)
275 },
276 body: rawBody,
277 redirect: "manual",
278 signal: controller.signal
279 });
280 if ([301, 302, 303, 307, 308].includes(last.status)) {
281 const loc = last.headers.get("location");
282 if (!loc) {
283 return { status: 502, latencyMs: Date.now() - started, transportError: "redirect without Location header" };
284 }
285 current = new URL(loc, current).toString();
286 continue;
287 }
288 return { status: last.status, latencyMs: Date.now() - started };
289 }
290 return {
291 status: 502,
292 latencyMs: Date.now() - started,
293 transportError: `too many redirects (>${WEBHOOK_MAX_REDIRECTS})`
294 };
295 } catch (err) {
296 const aborted = (err as Error).name === "AbortError";
297 return {
298 status: 0,
299 latencyMs: Date.now() - started,
300 transportError: aborted
301 ? `timeout after ${timeoutMs}ms`
302 : (err as Error).message
303 };
304 } finally {
305 clearTimeout(timer);
306 }
307}
308
309
310export function classifyDeliveryResult(
311 result: DeliveryResult
312): "DELIVERED" | "REJECTED" | "FAILED" {
313 if (result.transportError) return "FAILED";
314 if (result.status >= 200 && result.status < 300) return "DELIVERED";
315 if (result.status >= 400 && result.status < 500) return "REJECTED";
316 return "FAILED";
317}
318
319
320export function newDeliveryId(): string {
321 return randomUUID();
322}
323