outbound-webhooks.ts554 lines · main
| 1 | import { randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError, ValidationError } from '@briven/shared'; |
| 4 | import { and, asc, desc, eq, isNull, lte } from 'drizzle-orm'; |
| 5 | |
| 6 | import { getDb } from '../db/client.js'; |
| 7 | import { |
| 8 | webhookOutboundDeliveries, |
| 9 | webhookSubscribers, |
| 10 | type WebhookOutboundDelivery, |
| 11 | type WebhookOutboundStatus, |
| 12 | type WebhookSubscriber, |
| 13 | } from '../db/schema.js'; |
| 14 | import { decryptValue, encryptValue } from './project-env.js'; |
| 15 | |
| 16 | /** |
| 17 | * Platform → customer outbound webhooks. Each subscriber declares a target |
| 18 | * URL + which event types they care about (`*` matches everything). When |
| 19 | * the platform emits an event via `publishEvent`, we fan out to every |
| 20 | * matching subscriber by inserting a `pending` delivery row. A separate |
| 21 | * worker (workers/outbound-webhook-dispatcher.ts) drains the queue, |
| 22 | * POSTs the payload + HMAC signature, and retries failures with |
| 23 | * exponential backoff up to MAX_ATTEMPTS. |
| 24 | * |
| 25 | * The signing scheme matches the inbound webhook surface — customers |
| 26 | * verify our requests the same way external services verify theirs: |
| 27 | * X-Briven-Signature: v1=<hex_hmac_sha256> |
| 28 | * X-Briven-Timestamp: <unix-milliseconds> |
| 29 | * X-Briven-Event: <event-type> |
| 30 | * X-Briven-Event-Id: <stable-event-id-for-dedupe> |
| 31 | * |
| 32 | * Idempotency: customers should dedupe on `event_id`. Retries reuse the |
| 33 | * same id; a successful function that processes the same event twice |
| 34 | * after a network blip must be a no-op on the customer's side. |
| 35 | */ |
| 36 | |
| 37 | const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; |
| 38 | const EVENT_TYPE_RE = /^\*$|^[a-z][a-z0-9._-]{0,63}(,[a-z][a-z0-9._-]{0,63})*$/; |
| 39 | |
| 40 | /** |
| 41 | * Canonical event types the platform emits. Subscribers either list |
| 42 | * specific types (`abuse.report.opened,deploy.failed`) or use `*` to |
| 43 | * receive everything. New types are added here as they're wired up by |
| 44 | * the call sites that emit them — keep this list narrow on purpose. |
| 45 | */ |
| 46 | export const KNOWN_EVENT_TYPES = [ |
| 47 | 'abuse.report.opened', |
| 48 | 'deploy.succeeded', |
| 49 | 'deploy.failed', |
| 50 | 'tier.changed', |
| 51 | 'project.suspended', |
| 52 | 'project.resumed', |
| 53 | // briven auth — fan-out of authentication lifecycle events to customer |
| 54 | // endpoints. Emitted from `apps/api/src/services/auth-audit.ts` when the |
| 55 | // matching action row lands; subscribers opt in by listing the type or |
| 56 | // by using `*`. |
| 57 | 'auth.signup', |
| 58 | 'auth.signin', |
| 59 | 'auth.signout', |
| 60 | 'auth.session.revoked', |
| 61 | 'auth.account.linked', |
| 62 | 'auth.account.unlinked', |
| 63 | 'auth.user.deleted', |
| 64 | // Phase 1 — security foundation events. |
| 65 | 'auth.user.banned', |
| 66 | 'auth.user.unbanned', |
| 67 | 'auth.user.suspended', |
| 68 | 'auth.user.unsuspended', |
| 69 | 'auth.waitlist.approved', |
| 70 | 'auth.waitlist.rejected', |
| 71 | ] as const; |
| 72 | export type KnownEventType = (typeof KNOWN_EVENT_TYPES)[number]; |
| 73 | |
| 74 | /** |
| 75 | * The subset of `KNOWN_EVENT_TYPES` produced by briven auth. The dashboard |
| 76 | * Auth → Webhooks panel exposes only these as toggle checkboxes; the |
| 77 | * general project webhooks panel surfaces every type. |
| 78 | */ |
| 79 | export const AUTH_EVENT_TYPES = [ |
| 80 | 'auth.signup', |
| 81 | 'auth.signin', |
| 82 | 'auth.signout', |
| 83 | 'auth.session.revoked', |
| 84 | 'auth.account.linked', |
| 85 | 'auth.account.unlinked', |
| 86 | 'auth.user.deleted', |
| 87 | 'auth.user.banned', |
| 88 | 'auth.user.unbanned', |
| 89 | 'auth.user.suspended', |
| 90 | 'auth.user.unsuspended', |
| 91 | 'auth.waitlist.approved', |
| 92 | 'auth.waitlist.rejected', |
| 93 | ] as const; |
| 94 | export type AuthEventType = (typeof AUTH_EVENT_TYPES)[number]; |
| 95 | |
| 96 | function generateSigningSecret(): string { |
| 97 | return randomBytes(32).toString('hex'); |
| 98 | } |
| 99 | |
| 100 | function validateName(name: string): void { |
| 101 | if (!NAME_RE.test(name)) { |
| 102 | throw new ValidationError( |
| 103 | 'subscriber name must be 1-64 chars: alphanumerics, underscore, hyphen; must start with alphanumeric', |
| 104 | ); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | function validateTargetUrl(url: string): void { |
| 109 | let parsed: URL; |
| 110 | try { |
| 111 | parsed = new URL(url); |
| 112 | } catch { |
| 113 | throw new ValidationError('target_url must be a valid URL'); |
| 114 | } |
| 115 | if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { |
| 116 | throw new ValidationError('target_url must use http or https'); |
| 117 | } |
| 118 | // Refuse private-network targets up front. The runtime also has IP |
| 119 | // deny lists for customer code; we apply the same shape here so a |
| 120 | // misconfigured subscriber can't be used to scan our internal infra. |
| 121 | const host = parsed.hostname.toLowerCase(); |
| 122 | if ( |
| 123 | host === 'localhost' || |
| 124 | host === '127.0.0.1' || |
| 125 | host === '0.0.0.0' || |
| 126 | host.endsWith('.local') || |
| 127 | host.endsWith('.internal') || |
| 128 | /^10\./.test(host) || |
| 129 | /^192\.168\./.test(host) || |
| 130 | /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host) || |
| 131 | /^169\.254\./.test(host) |
| 132 | ) { |
| 133 | throw new ValidationError('target_url cannot point at a private network'); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | function validateEventTypes(eventTypes: string): void { |
| 138 | if (!EVENT_TYPE_RE.test(eventTypes)) { |
| 139 | throw new ValidationError( |
| 140 | 'event_types must be `*` or a comma-separated list of types (e.g. abuse.report.opened,deploy.failed)', |
| 141 | ); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | export interface PublicSubscriber { |
| 146 | id: string; |
| 147 | projectId: string; |
| 148 | name: string; |
| 149 | targetUrl: string; |
| 150 | eventTypes: string; |
| 151 | enabled: boolean; |
| 152 | allowedIps: string; |
| 153 | lastDeliveryAt: Date | null; |
| 154 | lastDeliveryStatus: WebhookOutboundStatus | null; |
| 155 | createdAt: Date; |
| 156 | updatedAt: Date; |
| 157 | } |
| 158 | |
| 159 | function redact(row: WebhookSubscriber): PublicSubscriber { |
| 160 | return { |
| 161 | id: row.id, |
| 162 | projectId: row.projectId, |
| 163 | name: row.name, |
| 164 | targetUrl: row.targetUrl, |
| 165 | eventTypes: row.eventTypes, |
| 166 | enabled: row.enabled, |
| 167 | allowedIps: row.allowedIps, |
| 168 | lastDeliveryAt: row.lastDeliveryAt, |
| 169 | lastDeliveryStatus: row.lastDeliveryStatus, |
| 170 | createdAt: row.createdAt, |
| 171 | updatedAt: row.updatedAt, |
| 172 | }; |
| 173 | } |
| 174 | |
| 175 | export interface CreateSubscriberInput { |
| 176 | projectId: string; |
| 177 | name: string; |
| 178 | targetUrl: string; |
| 179 | eventTypes?: string; |
| 180 | enabled?: boolean; |
| 181 | allowedIps?: string; |
| 182 | createdBy: string | null; |
| 183 | } |
| 184 | |
| 185 | export async function listSubscribers(projectId: string): Promise<PublicSubscriber[]> { |
| 186 | const db = getDb(); |
| 187 | const rows = await db |
| 188 | .select() |
| 189 | .from(webhookSubscribers) |
| 190 | .where(and(eq(webhookSubscribers.projectId, projectId), isNull(webhookSubscribers.deletedAt))) |
| 191 | .orderBy(asc(webhookSubscribers.name)); |
| 192 | return rows.map(redact); |
| 193 | } |
| 194 | |
| 195 | export async function getSubscriberRaw( |
| 196 | subscriberId: string, |
| 197 | projectId: string, |
| 198 | ): Promise<WebhookSubscriber> { |
| 199 | const db = getDb(); |
| 200 | const rows = await db |
| 201 | .select() |
| 202 | .from(webhookSubscribers) |
| 203 | .where( |
| 204 | and( |
| 205 | eq(webhookSubscribers.id, subscriberId), |
| 206 | eq(webhookSubscribers.projectId, projectId), |
| 207 | isNull(webhookSubscribers.deletedAt), |
| 208 | ), |
| 209 | ) |
| 210 | .limit(1); |
| 211 | const row = rows[0]; |
| 212 | if (!row) throw new NotFoundError('webhook_subscriber', subscriberId); |
| 213 | return row; |
| 214 | } |
| 215 | |
| 216 | const IP_RE = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; |
| 217 | |
| 218 | function validateAllowedIps(raw: string): void { |
| 219 | if (!raw) return; |
| 220 | const parts = raw.split(',').map((s) => s.trim()).filter(Boolean); |
| 221 | for (const ip of parts) { |
| 222 | if (!IP_RE.test(ip)) { |
| 223 | throw new ValidationError( |
| 224 | `allowed_ips must be comma-separated IPv4 addresses or CIDR blocks (e.g. 1.2.3.4, 10.0.0.0/8). Invalid: ${ip}`, |
| 225 | ); |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | export async function createSubscriber( |
| 231 | input: CreateSubscriberInput, |
| 232 | ): Promise<{ subscriber: PublicSubscriber; plaintextSecret: string }> { |
| 233 | validateName(input.name); |
| 234 | validateTargetUrl(input.targetUrl); |
| 235 | const eventTypes = input.eventTypes ?? '*'; |
| 236 | validateEventTypes(eventTypes); |
| 237 | if (input.allowedIps) validateAllowedIps(input.allowedIps); |
| 238 | const plaintextSecret = generateSigningSecret(); |
| 239 | const db = getDb(); |
| 240 | const id = newId('whs'); |
| 241 | try { |
| 242 | const inserted = await db |
| 243 | .insert(webhookSubscribers) |
| 244 | .values({ |
| 245 | id, |
| 246 | projectId: input.projectId, |
| 247 | name: input.name, |
| 248 | targetUrl: input.targetUrl, |
| 249 | eventTypes, |
| 250 | signingSecretEncrypted: encryptValue(plaintextSecret), |
| 251 | enabled: input.enabled ?? true, |
| 252 | allowedIps: input.allowedIps ?? '', |
| 253 | createdBy: input.createdBy, |
| 254 | }) |
| 255 | .returning(); |
| 256 | const row = inserted[0]; |
| 257 | if (!row) throw new Error('insert returned no row'); |
| 258 | return { subscriber: redact(row), plaintextSecret }; |
| 259 | } catch (err) { |
| 260 | if (err instanceof Error && /unique|duplicate/i.test(err.message)) { |
| 261 | throw new ValidationError( |
| 262 | `a subscriber named "${input.name}" already exists for this project`, |
| 263 | ); |
| 264 | } |
| 265 | throw err; |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | export interface UpdateSubscriberInput { |
| 270 | name?: string; |
| 271 | targetUrl?: string; |
| 272 | eventTypes?: string; |
| 273 | enabled?: boolean; |
| 274 | allowedIps?: string; |
| 275 | } |
| 276 | |
| 277 | export async function updateSubscriber( |
| 278 | subscriberId: string, |
| 279 | projectId: string, |
| 280 | patch: UpdateSubscriberInput, |
| 281 | ): Promise<PublicSubscriber> { |
| 282 | const existing = await getSubscriberRaw(subscriberId, projectId); |
| 283 | const updates: Partial<WebhookSubscriber> = { updatedAt: new Date() }; |
| 284 | if (patch.name !== undefined && patch.name !== existing.name) { |
| 285 | validateName(patch.name); |
| 286 | updates.name = patch.name; |
| 287 | } |
| 288 | if (patch.targetUrl !== undefined && patch.targetUrl !== existing.targetUrl) { |
| 289 | validateTargetUrl(patch.targetUrl); |
| 290 | updates.targetUrl = patch.targetUrl; |
| 291 | } |
| 292 | if (patch.eventTypes !== undefined && patch.eventTypes !== existing.eventTypes) { |
| 293 | validateEventTypes(patch.eventTypes); |
| 294 | updates.eventTypes = patch.eventTypes; |
| 295 | } |
| 296 | if (patch.enabled !== undefined && patch.enabled !== existing.enabled) { |
| 297 | updates.enabled = patch.enabled; |
| 298 | } |
| 299 | if (patch.allowedIps !== undefined && patch.allowedIps !== existing.allowedIps) { |
| 300 | validateAllowedIps(patch.allowedIps); |
| 301 | updates.allowedIps = patch.allowedIps; |
| 302 | } |
| 303 | const db = getDb(); |
| 304 | const result = await db |
| 305 | .update(webhookSubscribers) |
| 306 | .set(updates) |
| 307 | .where( |
| 308 | and(eq(webhookSubscribers.id, subscriberId), eq(webhookSubscribers.projectId, projectId)), |
| 309 | ) |
| 310 | .returning(); |
| 311 | if (!result[0]) throw new NotFoundError('webhook_subscriber', subscriberId); |
| 312 | return redact(result[0]); |
| 313 | } |
| 314 | |
| 315 | export async function rotateSubscriberSecret( |
| 316 | subscriberId: string, |
| 317 | projectId: string, |
| 318 | ): Promise<{ subscriber: PublicSubscriber; plaintextSecret: string }> { |
| 319 | await getSubscriberRaw(subscriberId, projectId); |
| 320 | const plaintextSecret = generateSigningSecret(); |
| 321 | const db = getDb(); |
| 322 | const result = await db |
| 323 | .update(webhookSubscribers) |
| 324 | .set({ |
| 325 | signingSecretEncrypted: encryptValue(plaintextSecret), |
| 326 | updatedAt: new Date(), |
| 327 | }) |
| 328 | .where( |
| 329 | and(eq(webhookSubscribers.id, subscriberId), eq(webhookSubscribers.projectId, projectId)), |
| 330 | ) |
| 331 | .returning(); |
| 332 | if (!result[0]) throw new NotFoundError('webhook_subscriber', subscriberId); |
| 333 | return { subscriber: redact(result[0]), plaintextSecret }; |
| 334 | } |
| 335 | |
| 336 | export async function deleteSubscriber( |
| 337 | subscriberId: string, |
| 338 | projectId: string, |
| 339 | ): Promise<void> { |
| 340 | const db = getDb(); |
| 341 | const result = await db |
| 342 | .update(webhookSubscribers) |
| 343 | .set({ deletedAt: new Date(), enabled: false, updatedAt: new Date() }) |
| 344 | .where( |
| 345 | and( |
| 346 | eq(webhookSubscribers.id, subscriberId), |
| 347 | eq(webhookSubscribers.projectId, projectId), |
| 348 | isNull(webhookSubscribers.deletedAt), |
| 349 | ), |
| 350 | ) |
| 351 | .returning({ id: webhookSubscribers.id }); |
| 352 | if (!result[0]) throw new NotFoundError('webhook_subscriber', subscriberId); |
| 353 | } |
| 354 | |
| 355 | export async function listOutboundDeliveries( |
| 356 | subscriberId: string, |
| 357 | projectId: string, |
| 358 | opts: { limit?: number; status?: WebhookOutboundStatus } = {}, |
| 359 | ): Promise<WebhookOutboundDelivery[]> { |
| 360 | const db = getDb(); |
| 361 | const whereClauses = [ |
| 362 | eq(webhookOutboundDeliveries.subscriberId, subscriberId), |
| 363 | eq(webhookOutboundDeliveries.projectId, projectId), |
| 364 | ]; |
| 365 | if (opts.status) whereClauses.push(eq(webhookOutboundDeliveries.status, opts.status)); |
| 366 | return db |
| 367 | .select() |
| 368 | .from(webhookOutboundDeliveries) |
| 369 | .where(and(...whereClauses)) |
| 370 | .orderBy(desc(webhookOutboundDeliveries.createdAt)) |
| 371 | .limit(opts.limit ?? 100); |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Fan an event out to every matching subscriber on a project. Inserts one |
| 376 | * `pending` delivery row per match; the dispatcher worker drains them. |
| 377 | * |
| 378 | * Idempotency: callers pass a stable `eventId` if they want retries on the |
| 379 | * caller's side to dedupe on briven's side. Without one, every call |
| 380 | * inserts fresh rows — fine for fire-and-forget events. |
| 381 | */ |
| 382 | export async function publishEvent(input: { |
| 383 | projectId: string; |
| 384 | eventType: KnownEventType; |
| 385 | payload: Record<string, unknown>; |
| 386 | eventId?: string; |
| 387 | }): Promise<{ deliveriesQueued: number }> { |
| 388 | const db = getDb(); |
| 389 | const subscribers = await db |
| 390 | .select() |
| 391 | .from(webhookSubscribers) |
| 392 | .where( |
| 393 | and( |
| 394 | eq(webhookSubscribers.projectId, input.projectId), |
| 395 | eq(webhookSubscribers.enabled, true), |
| 396 | isNull(webhookSubscribers.deletedAt), |
| 397 | ), |
| 398 | ); |
| 399 | const matching = subscribers.filter((s) => matchesEventType(s.eventTypes, input.eventType)); |
| 400 | if (matching.length === 0) return { deliveriesQueued: 0 }; |
| 401 | |
| 402 | const eventId = input.eventId ?? newId('wev'); |
| 403 | const now = new Date(); |
| 404 | await db.insert(webhookOutboundDeliveries).values( |
| 405 | matching.map((s) => ({ |
| 406 | id: newId('whod'), |
| 407 | subscriberId: s.id, |
| 408 | projectId: input.projectId, |
| 409 | eventId, |
| 410 | eventType: input.eventType, |
| 411 | payload: input.payload, |
| 412 | status: 'pending' as const, |
| 413 | attemptCount: '0', |
| 414 | nextAttemptAt: now, |
| 415 | })), |
| 416 | ); |
| 417 | return { deliveriesQueued: matching.length }; |
| 418 | } |
| 419 | |
| 420 | export function matchesEventType(filter: string, eventType: string): boolean { |
| 421 | if (filter === '*') return true; |
| 422 | return filter.split(',').some((t) => t.trim() === eventType); |
| 423 | } |
| 424 | |
| 425 | /** |
| 426 | * Dispatcher claim path. Selects up to `limit` deliveries whose |
| 427 | * next_attempt_at is in the past and which are still pending. The |
| 428 | * caller does the POST + records the result via recordDeliveryResult. |
| 429 | */ |
| 430 | export async function claimDueDeliveries( |
| 431 | now: Date, |
| 432 | limit: number, |
| 433 | ): Promise<WebhookOutboundDelivery[]> { |
| 434 | const db = getDb(); |
| 435 | return db |
| 436 | .select() |
| 437 | .from(webhookOutboundDeliveries) |
| 438 | .where( |
| 439 | and( |
| 440 | eq(webhookOutboundDeliveries.status, 'pending'), |
| 441 | lte(webhookOutboundDeliveries.nextAttemptAt, now), |
| 442 | ), |
| 443 | ) |
| 444 | .orderBy(asc(webhookOutboundDeliveries.nextAttemptAt)) |
| 445 | .limit(limit); |
| 446 | } |
| 447 | |
| 448 | export const MAX_ATTEMPTS = 5; |
| 449 | |
| 450 | /** |
| 451 | * Exponential backoff schedule (in ms) for the next retry attempt |
| 452 | * AFTER the n-th failure. Attempt 0 retries 30s later, attempt 4 |
| 453 | * (the 5th try) is the terminal state — record it as 'failed' instead |
| 454 | * of scheduling a 6th. Cap kept short: a webhook that hasn't recovered |
| 455 | * in ~30 minutes isn't going to. |
| 456 | */ |
| 457 | export function retryDelayMs(attemptCount: number): number { |
| 458 | const schedule = [30_000, 60_000, 180_000, 600_000, 1_800_000]; |
| 459 | return schedule[Math.min(attemptCount, schedule.length - 1)] ?? 1_800_000; |
| 460 | } |
| 461 | |
| 462 | export async function recordDeliveryResult(input: { |
| 463 | deliveryId: string; |
| 464 | attemptCount: number; |
| 465 | statusCode: number | null; |
| 466 | durationMs: number; |
| 467 | errorMessage: string | null; |
| 468 | ok: boolean; |
| 469 | ranAt: Date; |
| 470 | }): Promise<void> { |
| 471 | const db = getDb(); |
| 472 | const nextAttempt = input.attemptCount + 1; |
| 473 | const terminal = input.ok || nextAttempt >= MAX_ATTEMPTS; |
| 474 | const nextStatus: WebhookOutboundStatus = input.ok |
| 475 | ? 'ok' |
| 476 | : terminal |
| 477 | ? 'failed' |
| 478 | : 'pending'; |
| 479 | const nextAttemptAt = terminal |
| 480 | ? input.ranAt |
| 481 | : new Date(input.ranAt.getTime() + retryDelayMs(input.attemptCount)); |
| 482 | |
| 483 | await db |
| 484 | .update(webhookOutboundDeliveries) |
| 485 | .set({ |
| 486 | status: nextStatus, |
| 487 | attemptCount: String(nextAttempt), |
| 488 | lastAttemptAt: input.ranAt, |
| 489 | statusCode: input.statusCode == null ? null : String(input.statusCode), |
| 490 | durationMs: String(input.durationMs), |
| 491 | errorMessage: input.errorMessage, |
| 492 | nextAttemptAt, |
| 493 | }) |
| 494 | .where(eq(webhookOutboundDeliveries.id, input.deliveryId)); |
| 495 | |
| 496 | // Roll the subscriber's last_delivery_* summary forward so the |
| 497 | // dashboard list view stays cheap (no aggregate needed). |
| 498 | const delivery = await db |
| 499 | .select({ subscriberId: webhookOutboundDeliveries.subscriberId }) |
| 500 | .from(webhookOutboundDeliveries) |
| 501 | .where(eq(webhookOutboundDeliveries.id, input.deliveryId)) |
| 502 | .limit(1); |
| 503 | const subscriberId = delivery[0]?.subscriberId; |
| 504 | if (subscriberId) { |
| 505 | await db |
| 506 | .update(webhookSubscribers) |
| 507 | .set({ |
| 508 | lastDeliveryAt: input.ranAt, |
| 509 | lastDeliveryStatus: nextStatus, |
| 510 | updatedAt: new Date(), |
| 511 | }) |
| 512 | .where(eq(webhookSubscribers.id, subscriberId)); |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | /** |
| 517 | * Replay a delivery — reset it to pending with attemptCount=0 so the |
| 518 | * dispatcher will retry it on the next tick. |
| 519 | */ |
| 520 | export async function replayDelivery( |
| 521 | deliveryId: string, |
| 522 | projectId: string, |
| 523 | ): Promise<void> { |
| 524 | const db = getDb(); |
| 525 | const delivery = await db |
| 526 | .select({ id: webhookOutboundDeliveries.id }) |
| 527 | .from(webhookOutboundDeliveries) |
| 528 | .where( |
| 529 | and( |
| 530 | eq(webhookOutboundDeliveries.id, deliveryId), |
| 531 | eq(webhookOutboundDeliveries.projectId, projectId), |
| 532 | ), |
| 533 | ) |
| 534 | .limit(1); |
| 535 | if (!delivery[0]) throw new NotFoundError('webhook_delivery', deliveryId); |
| 536 | |
| 537 | await db |
| 538 | .update(webhookOutboundDeliveries) |
| 539 | .set({ |
| 540 | status: 'pending', |
| 541 | attemptCount: '0', |
| 542 | nextAttemptAt: new Date(), |
| 543 | lastAttemptAt: null, |
| 544 | statusCode: null, |
| 545 | durationMs: null, |
| 546 | errorMessage: null, |
| 547 | }) |
| 548 | .where(eq(webhookOutboundDeliveries.id, deliveryId)); |
| 549 | } |
| 550 | |
| 551 | /** Decrypt a subscriber's stored signing secret. Reveals plaintext. */ |
| 552 | export function decryptSubscriberSecret(row: WebhookSubscriber): string { |
| 553 | return decryptValue(row.signingSecretEncrypted); |
| 554 | } |