webhooks.ts374 lines · main
| 1 | import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, asc, desc, eq, isNull } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { |
| 8 | webhookDeliveries, |
| 9 | webhookEndpoints, |
| 10 | type WebhookDelivery, |
| 11 | type WebhookDeliveryStatus, |
| 12 | type WebhookEndpoint, |
| 13 | } from '../db/schema.js'; |
| 14 | import { 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 | |
| 33 | const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; |
| 34 | const FUNCTION_NAME_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]{0,128}$/; |
| 35 | const REPLAY_WINDOW_MS = 5 * 60 * 1000; |
| 36 | const SIGNATURE_RE = /^v1=([0-9a-f]{64})$/i; |
| 37 | |
| 38 | function 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 | |
| 45 | function 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 | |
| 53 | function 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 | |
| 59 | export interface CreateWebhookInput { |
| 60 | projectId: string; |
| 61 | name: string; |
| 62 | functionName: string; |
| 63 | enabled?: boolean; |
| 64 | createdBy: string | null; |
| 65 | } |
| 66 | |
| 67 | export 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 | |
| 75 | export 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 | |
| 87 | function 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 | |
| 101 | export 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 | |
| 111 | export 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 | |
| 132 | export async function getWebhook( |
| 133 | endpointId: string, |
| 134 | projectId: string, |
| 135 | ): Promise<PublicWebhookEndpoint> { |
| 136 | return redact(await getWebhookRaw(endpointId, projectId)); |
| 137 | } |
| 138 | |
| 139 | export 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 | |
| 172 | export interface UpdateWebhookInput { |
| 173 | name?: string; |
| 174 | functionName?: string; |
| 175 | enabled?: boolean; |
| 176 | } |
| 177 | |
| 178 | export 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 | |
| 209 | export 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 | |
| 230 | export 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 | |
| 246 | export 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 | */ |
| 273 | export type VerifyResult = |
| 274 | | { ok: true } |
| 275 | | { ok: false; status: 'rejected_signature' | 'rejected_replay'; reason: string }; |
| 276 | |
| 277 | export 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. */ |
| 337 | export function decryptEndpointSecret(row: WebhookEndpoint): string { |
| 338 | return decryptValue(row.signingSecretEncrypted); |
| 339 | } |
| 340 | |
| 341 | export 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 | |
| 351 | export 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 | } |