webhooks.ts374 lines · main
1import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
3import { newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, asc, desc, eq, isNull } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import {
8 webhookDeliveries,
9 webhookEndpoints,
10 type WebhookDelivery,
11 type WebhookDeliveryStatus,
12 type WebhookEndpoint,
13} from '../db/schema.js';
14import { decryptValue, encryptValue } from './project-env.js';
15
16/**
17 * Inbound webhook receivers. Each endpoint maps `POST /webhooks/<projectId>/<endpointId>`
18 * to a customer-defined function. Authentication is HMAC-SHA256 over
19 * `${timestamp}.${rawBody}`, sent in two headers:
20 *
21 * X-Briven-Signature: v1=<hex_hmac>
22 * X-Briven-Timestamp: <unix_milliseconds>
23 *
24 * The timestamp prevents replays — anything outside REPLAY_WINDOW_MS gets
25 * rejected even if the signature is otherwise valid. Constant-time
26 * comparison via `timingSafeEqual`.
27 *
28 * Signing secrets are stored AES-256-GCM-encrypted (same KEK + format as
29 * project_env_vars). Plaintext is only revealed once, at creation, and
30 * on explicit "rotate" — never in list/get responses.
31 */
32
33const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
34const FUNCTION_NAME_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]{0,128}$/;
35const REPLAY_WINDOW_MS = 5 * 60 * 1000;
36const SIGNATURE_RE = /^v1=([0-9a-f]{64})$/i;
37
38function generateSigningSecret(): string {
39 // 32 bytes of entropy, hex-encoded → 64 chars. Long enough that a
40 // brute-force search is hopeless; short enough to paste into a CI
41 // secret-manager UI without scrolling.
42 return randomBytes(32).toString('hex');
43}
44
45function validateName(name: string): void {
46 if (!NAME_RE.test(name)) {
47 throw new ValidationError(
48 'webhook name must be 1-64 chars: alphanumerics, underscore, hyphen; must start with alphanumeric',
49 );
50 }
51}
52
53function validateFunctionName(fn: string): void {
54 if (!FUNCTION_NAME_RE.test(fn)) {
55 throw new ValidationError('function name must be a valid javascript identifier');
56 }
57}
58
59export interface CreateWebhookInput {
60 projectId: string;
61 name: string;
62 functionName: string;
63 enabled?: boolean;
64 createdBy: string | null;
65}
66
67export interface CreateWebhookResult {
68 endpoint: PublicWebhookEndpoint;
69 // Plaintext is returned ONCE at create. Per CLAUDE.md §5.4 the caller
70 // must store it immediately — we never log it and the API never
71 // surfaces it again.
72 plaintextSecret: string;
73}
74
75export interface PublicWebhookEndpoint {
76 id: string;
77 projectId: string;
78 name: string;
79 functionName: string;
80 enabled: boolean;
81 lastDeliveryAt: Date | null;
82 lastDeliveryStatus: WebhookDeliveryStatus | null;
83 createdAt: Date;
84 updatedAt: Date;
85}
86
87function redact(row: WebhookEndpoint): PublicWebhookEndpoint {
88 return {
89 id: row.id,
90 projectId: row.projectId,
91 name: row.name,
92 functionName: row.functionName,
93 enabled: row.enabled,
94 lastDeliveryAt: row.lastDeliveryAt,
95 lastDeliveryStatus: row.lastDeliveryStatus,
96 createdAt: row.createdAt,
97 updatedAt: row.updatedAt,
98 };
99}
100
101export async function listWebhooks(projectId: string): Promise<PublicWebhookEndpoint[]> {
102 const db = getDb();
103 const rows = await db
104 .select()
105 .from(webhookEndpoints)
106 .where(and(eq(webhookEndpoints.projectId, projectId), isNull(webhookEndpoints.deletedAt)))
107 .orderBy(asc(webhookEndpoints.name));
108 return rows.map(redact);
109}
110
111export async function getWebhookRaw(
112 endpointId: string,
113 projectId: string,
114): Promise<WebhookEndpoint> {
115 const db = getDb();
116 const rows = await db
117 .select()
118 .from(webhookEndpoints)
119 .where(
120 and(
121 eq(webhookEndpoints.id, endpointId),
122 eq(webhookEndpoints.projectId, projectId),
123 isNull(webhookEndpoints.deletedAt),
124 ),
125 )
126 .limit(1);
127 const row = rows[0];
128 if (!row) throw new NotFoundError('webhook_endpoint', endpointId);
129 return row;
130}
131
132export async function getWebhook(
133 endpointId: string,
134 projectId: string,
135): Promise<PublicWebhookEndpoint> {
136 return redact(await getWebhookRaw(endpointId, projectId));
137}
138
139export async function createWebhook(input: CreateWebhookInput): Promise<CreateWebhookResult> {
140 validateName(input.name);
141 validateFunctionName(input.functionName);
142
143 const plaintextSecret = generateSigningSecret();
144 const db = getDb();
145 const id = newId('whe');
146 try {
147 const inserted = await db
148 .insert(webhookEndpoints)
149 .values({
150 id,
151 projectId: input.projectId,
152 name: input.name,
153 functionName: input.functionName,
154 signingSecretEncrypted: encryptValue(plaintextSecret),
155 enabled: input.enabled ?? true,
156 createdBy: input.createdBy,
157 })
158 .returning();
159 const row = inserted[0];
160 if (!row) throw new Error('insert returned no row');
161 return { endpoint: redact(row), plaintextSecret };
162 } catch (err) {
163 if (err instanceof Error && /unique|duplicate/i.test(err.message)) {
164 throw new ValidationError(
165 `a webhook named "${input.name}" already exists for this project`,
166 );
167 }
168 throw err;
169 }
170}
171
172export interface UpdateWebhookInput {
173 name?: string;
174 functionName?: string;
175 enabled?: boolean;
176}
177
178export async function updateWebhook(
179 endpointId: string,
180 projectId: string,
181 patch: UpdateWebhookInput,
182): Promise<PublicWebhookEndpoint> {
183 const existing = await getWebhookRaw(endpointId, projectId);
184 const updates: Partial<WebhookEndpoint> = { updatedAt: new Date() };
185 if (patch.name !== undefined && patch.name !== existing.name) {
186 validateName(patch.name);
187 updates.name = patch.name;
188 }
189 if (patch.functionName !== undefined && patch.functionName !== existing.functionName) {
190 validateFunctionName(patch.functionName);
191 updates.functionName = patch.functionName;
192 }
193 if (patch.enabled !== undefined && patch.enabled !== existing.enabled) {
194 updates.enabled = patch.enabled;
195 }
196
197 const db = getDb();
198 const result = await db
199 .update(webhookEndpoints)
200 .set(updates)
201 .where(
202 and(eq(webhookEndpoints.id, endpointId), eq(webhookEndpoints.projectId, projectId)),
203 )
204 .returning();
205 if (!result[0]) throw new NotFoundError('webhook_endpoint', endpointId);
206 return redact(result[0]);
207}
208
209export async function rotateWebhookSecret(
210 endpointId: string,
211 projectId: string,
212): Promise<{ endpoint: PublicWebhookEndpoint; plaintextSecret: string }> {
213 await getWebhookRaw(endpointId, projectId);
214 const plaintextSecret = generateSigningSecret();
215 const db = getDb();
216 const result = await db
217 .update(webhookEndpoints)
218 .set({
219 signingSecretEncrypted: encryptValue(plaintextSecret),
220 updatedAt: new Date(),
221 })
222 .where(
223 and(eq(webhookEndpoints.id, endpointId), eq(webhookEndpoints.projectId, projectId)),
224 )
225 .returning();
226 if (!result[0]) throw new NotFoundError('webhook_endpoint', endpointId);
227 return { endpoint: redact(result[0]), plaintextSecret };
228}
229
230export async function deleteWebhook(endpointId: string, projectId: string): Promise<void> {
231 const db = getDb();
232 const result = await db
233 .update(webhookEndpoints)
234 .set({ deletedAt: new Date(), enabled: false, updatedAt: new Date() })
235 .where(
236 and(
237 eq(webhookEndpoints.id, endpointId),
238 eq(webhookEndpoints.projectId, projectId),
239 isNull(webhookEndpoints.deletedAt),
240 ),
241 )
242 .returning({ id: webhookEndpoints.id });
243 if (!result[0]) throw new NotFoundError('webhook_endpoint', endpointId);
244}
245
246export async function listDeliveries(
247 endpointId: string,
248 projectId: string,
249 opts: { limit?: number; status?: WebhookDeliveryStatus } = {},
250): Promise<WebhookDelivery[]> {
251 const db = getDb();
252 const whereClauses = [
253 eq(webhookDeliveries.endpointId, endpointId),
254 eq(webhookDeliveries.projectId, projectId),
255 ];
256 if (opts.status) whereClauses.push(eq(webhookDeliveries.status, opts.status));
257 return db
258 .select()
259 .from(webhookDeliveries)
260 .where(and(...whereClauses))
261 .orderBy(desc(webhookDeliveries.createdAt))
262 .limit(opts.limit ?? 100);
263}
264
265/**
266 * Signature verification. Returns the reason on rejection (so the caller
267 * can record it in the delivery log) or null on success.
268 *
269 * Wire format:
270 * X-Briven-Signature: v1=<64-hex-char hmac_sha256(`${ts}.${rawBody}`)>
271 * X-Briven-Timestamp: <unix-milliseconds>
272 */
273export type VerifyResult =
274 | { ok: true }
275 | { ok: false; status: 'rejected_signature' | 'rejected_replay'; reason: string };
276
277export function verifyWebhookSignature(input: {
278 rawBody: string;
279 signatureHeader: string | null;
280 timestampHeader: string | null;
281 plaintextSecret: string;
282 now: Date;
283}): VerifyResult {
284 if (!input.signatureHeader || !input.timestampHeader) {
285 return {
286 ok: false,
287 status: 'rejected_signature',
288 reason: 'missing X-Briven-Signature or X-Briven-Timestamp header',
289 };
290 }
291
292 const tsMs = Number(input.timestampHeader);
293 if (!Number.isFinite(tsMs) || tsMs <= 0) {
294 return {
295 ok: false,
296 status: 'rejected_signature',
297 reason: 'X-Briven-Timestamp not a positive integer',
298 };
299 }
300
301 const driftMs = Math.abs(input.now.getTime() - tsMs);
302 if (driftMs > REPLAY_WINDOW_MS) {
303 return {
304 ok: false,
305 status: 'rejected_replay',
306 reason: `timestamp drift ${driftMs}ms exceeds ${REPLAY_WINDOW_MS}ms replay window`,
307 };
308 }
309
310 const match = SIGNATURE_RE.exec(input.signatureHeader);
311 if (!match) {
312 return {
313 ok: false,
314 status: 'rejected_signature',
315 reason: 'X-Briven-Signature must be `v1=<64-hex-chars>`',
316 };
317 }
318 const claimed = match[1]!;
319
320 const expected = createHmac('sha256', input.plaintextSecret)
321 .update(`${input.timestampHeader}.${input.rawBody}`)
322 .digest('hex');
323
324 const a = Buffer.from(claimed, 'hex');
325 const b = Buffer.from(expected, 'hex');
326 if (a.length !== b.length) {
327 return { ok: false, status: 'rejected_signature', reason: 'signature length mismatch' };
328 }
329 if (!timingSafeEqual(a, b)) {
330 return { ok: false, status: 'rejected_signature', reason: 'signature mismatch' };
331 }
332
333 return { ok: true };
334}
335
336/** Decrypt an endpoint's stored signing secret. Reveals plaintext. */
337export function decryptEndpointSecret(row: WebhookEndpoint): string {
338 return decryptValue(row.signingSecretEncrypted);
339}
340
341export interface RecordDeliveryInput {
342 endpointId: string;
343 projectId: string;
344 status: WebhookDeliveryStatus;
345 sourceIpHash: string | null;
346 functionName: string | null;
347 durationMs: number | null;
348 errorMessage: string | null;
349}
350
351export async function recordDelivery(input: RecordDeliveryInput): Promise<void> {
352 const db = getDb();
353 await db.insert(webhookDeliveries).values({
354 id: newId('whd'),
355 endpointId: input.endpointId,
356 projectId: input.projectId,
357 status: input.status,
358 sourceIpHash: input.sourceIpHash,
359 functionName: input.functionName,
360 durationMs: input.durationMs == null ? null : String(input.durationMs),
361 errorMessage: input.errorMessage,
362 });
363 // Update the endpoint summary in the same transaction-equivalent — the
364 // dashboard "last delivery" indicator reads from this denormalised
365 // column instead of joining + ordering on every list request.
366 await db
367 .update(webhookEndpoints)
368 .set({
369 lastDeliveryAt: new Date(),
370 lastDeliveryStatus: input.status,
371 updatedAt: new Date(),
372 })
373 .where(eq(webhookEndpoints.id, input.endpointId));
374}