@open-mercato/webhooks 0.5.1-develop.2912.8d7b1fef24 → 0.5.1-develop.2917.31ee9898e3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,11 @@ import { WebhookDeliveryEntity, WebhookEntity } from "../data/entities.js";
4
4
  import { emitWebhooksEvent } from "../events.js";
5
5
  import { enqueueWebhookDelivery } from "./queue.js";
6
6
  import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from "./integration-state.js";
7
- import { assertSafeWebhookDeliveryUrl, UnsafeWebhookUrlError } from "./url-safety.js";
7
+ import {
8
+ assertSafeWebhookDeliveryUrl,
9
+ safeWebhookFetch,
10
+ UnsafeWebhookUrlError
11
+ } from "./url-safety.js";
8
12
  async function createWebhookDelivery(input) {
9
13
  const bodyPayload = {
10
14
  type: input.eventId,
@@ -84,26 +88,12 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
84
88
  try {
85
89
  await assertSafeWebhookDeliveryUrl(webhook.url);
86
90
  } catch (error) {
87
- const message = error instanceof UnsafeWebhookUrlError ? error.message : "Webhook URL rejected by safety check";
88
- delivery.status = "failed";
89
- delivery.errorMessage = message;
90
- delivery.nextRetryAt = null;
91
- delivery.lastAttemptAt = /* @__PURE__ */ new Date();
92
- delivery.attemptNumber += 1;
93
- webhook.consecutiveFailures += 1;
94
- webhook.lastFailureAt = /* @__PURE__ */ new Date();
95
- await em.flush();
96
- await emitWebhooksEvent("webhooks.delivery.failed", {
97
- deliveryId: delivery.id,
98
- webhookId: webhook.id,
99
- eventType: delivery.eventType,
100
- errorMessage: message,
101
- durationMs: 0,
102
- organizationId: delivery.organizationId,
103
- tenantId: delivery.tenantId,
104
- willRetry: false
91
+ return await recordWebhookSafetyFailure({
92
+ em,
93
+ delivery,
94
+ webhook,
95
+ error
105
96
  });
106
- return { status: delivery.status, deliveryId: delivery.id };
107
97
  }
108
98
  const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload);
109
99
  delivery.payload = bodyPayload;
@@ -124,18 +114,22 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
124
114
  try {
125
115
  const controller = new AbortController();
126
116
  const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs);
127
- const response = await fetch(webhook.url, {
128
- method: webhook.httpMethod,
129
- redirect: "manual",
130
- headers: {
131
- "content-type": "application/json",
132
- ...headers,
133
- ...webhook.customHeaders ?? {}
134
- },
135
- body,
136
- signal: controller.signal
137
- });
138
- clearTimeout(timeoutId);
117
+ let response;
118
+ try {
119
+ response = await safeWebhookFetch(webhook.url, {
120
+ method: webhook.httpMethod,
121
+ redirect: "manual",
122
+ headers: {
123
+ "content-type": "application/json",
124
+ ...headers,
125
+ ...webhook.customHeaders ?? {}
126
+ },
127
+ body,
128
+ signal: controller.signal
129
+ });
130
+ } finally {
131
+ clearTimeout(timeoutId);
132
+ }
139
133
  delivery.attemptNumber += 1;
140
134
  delivery.responseStatus = response.status;
141
135
  delivery.responseBody = (await response.text()).slice(0, 4096);
@@ -179,6 +173,17 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
179
173
  });
180
174
  return { status: delivery.status, deliveryId: delivery.id };
181
175
  } catch (error) {
176
+ if (error instanceof UnsafeWebhookUrlError) {
177
+ const durationMs = Date.now() - delivery.enqueuedAt.getTime();
178
+ delivery.durationMs = durationMs;
179
+ return await recordWebhookSafetyFailure({
180
+ em,
181
+ delivery,
182
+ webhook,
183
+ error,
184
+ durationMs
185
+ });
186
+ }
182
187
  delivery.attemptNumber += 1;
183
188
  delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime();
184
189
  delivery.lastAttemptAt = /* @__PURE__ */ new Date();
@@ -206,6 +211,29 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
206
211
  return { status: delivery.status, deliveryId: delivery.id };
207
212
  }
208
213
  }
214
+ async function recordWebhookSafetyFailure(input) {
215
+ const { em, delivery, webhook, error } = input;
216
+ const message = error instanceof UnsafeWebhookUrlError ? error.message : "Webhook URL rejected by safety check";
217
+ delivery.status = "failed";
218
+ delivery.errorMessage = message;
219
+ delivery.nextRetryAt = null;
220
+ delivery.lastAttemptAt = /* @__PURE__ */ new Date();
221
+ delivery.attemptNumber += 1;
222
+ webhook.consecutiveFailures += 1;
223
+ webhook.lastFailureAt = /* @__PURE__ */ new Date();
224
+ await em.flush();
225
+ await emitWebhooksEvent("webhooks.delivery.failed", {
226
+ deliveryId: delivery.id,
227
+ webhookId: webhook.id,
228
+ eventType: delivery.eventType,
229
+ errorMessage: message,
230
+ durationMs: input.durationMs ?? 0,
231
+ organizationId: delivery.organizationId,
232
+ tenantId: delivery.tenantId,
233
+ willRetry: false
234
+ });
235
+ return { status: delivery.status, deliveryId: delivery.id };
236
+ }
209
237
  async function handleFailedDelivery(input) {
210
238
  const { delivery, webhook } = input;
211
239
  const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/lib/delivery.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { emitWebhooksEvent } from '../events'\nimport { enqueueWebhookDelivery } from './queue'\nimport { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'\nimport { assertSafeWebhookDeliveryUrl, UnsafeWebhookUrlError } from './url-safety'\n\nexport interface WebhookDeliveryJob {\n deliveryId: string\n tenantId: string\n organizationId: string\n}\n\nexport interface CreateWebhookDeliveryInput {\n em: EntityManager\n webhook: WebhookEntity\n eventId: string\n payload: Record<string, unknown>\n}\n\ntype ProcessWebhookDeliveryOptions = {\n scheduleRetries?: boolean\n}\n\ntype WebhookBody = {\n type: string\n timestamp: string\n data: Record<string, unknown>\n}\n\nexport async function createWebhookDelivery(input: CreateWebhookDeliveryInput): Promise<WebhookDeliveryEntity> {\n const bodyPayload: WebhookBody = {\n type: input.eventId,\n timestamp: new Date().toISOString(),\n data: input.payload,\n }\n const now = new Date()\n\n const delivery = input.em.create(WebhookDeliveryEntity, {\n webhookId: input.webhook.id,\n eventType: input.eventId,\n messageId: generateMessageId(),\n payload: bodyPayload,\n status: 'pending',\n attemptNumber: 0,\n maxAttempts: input.webhook.maxRetries,\n targetUrl: input.webhook.url,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n enqueuedAt: now,\n createdAt: now,\n updatedAt: now,\n })\n\n await input.em.flush()\n await emitWebhooksEvent('webhooks.delivery.enqueued', {\n deliveryId: delivery.id,\n webhookId: input.webhook.id,\n eventType: input.eventId,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n })\n return delivery\n}\n\nexport async function processWebhookDeliveryJob(\n em: EntityManager,\n job: WebhookDeliveryJob,\n options: ProcessWebhookDeliveryOptions = {},\n): Promise<{ status: string; deliveryId: string } | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, {\n id: job.deliveryId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n })\n\n if (!delivery) return null\n\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n {\n id: delivery.webhookId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n deletedAt: null,\n },\n {},\n { tenantId: job.tenantId, organizationId: job.organizationId },\n )\n\n if (!webhook) {\n delivery.status = 'failed'\n delivery.errorMessage = 'Webhook not found'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n if (!webhook.isActive) {\n delivery.status = 'expired'\n delivery.errorMessage = 'Webhook is inactive'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const integrationEnabled = await isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n\n if (!integrationEnabled) {\n delivery.status = 'expired'\n delivery.errorMessage = WEBHOOK_INTEGRATION_DISABLED_MESSAGE\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n try {\n await assertSafeWebhookDeliveryUrl(webhook.url)\n } catch (error) {\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL rejected by safety check'\n delivery.status = 'failed'\n delivery.errorMessage = message\n delivery.nextRetryAt = null\n delivery.lastAttemptAt = new Date()\n delivery.attemptNumber += 1\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n await em.flush()\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: message,\n durationMs: 0,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: false,\n })\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)\n delivery.payload = bodyPayload\n delivery.status = 'sending'\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = null\n await em.flush()\n\n const body = JSON.stringify(bodyPayload)\n const timestamp = Math.floor(Date.now() / 1000)\n const headers = buildWebhookHeaders(\n delivery.messageId,\n timestamp,\n body,\n webhook.secret,\n webhook.previousSecret,\n )\n\n const attemptStartedAt = Date.now()\n\n try {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)\n\n const response = await fetch(webhook.url, {\n method: webhook.httpMethod,\n redirect: 'manual',\n headers: {\n 'content-type': 'application/json',\n ...headers,\n ...(webhook.customHeaders ?? {}),\n },\n body,\n signal: controller.signal,\n })\n\n clearTimeout(timeoutId)\n\n delivery.attemptNumber += 1\n delivery.responseStatus = response.status\n delivery.responseBody = (await response.text()).slice(0, 4096)\n delivery.responseHeaders = Object.fromEntries(response.headers.entries())\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n\n if (response.ok) {\n delivery.status = 'delivered'\n delivery.deliveredAt = new Date()\n delivery.nextRetryAt = null\n webhook.consecutiveFailures = 0\n webhook.lastSuccessAt = new Date()\n\n await emitWebhooksEvent('webhooks.delivery.succeeded', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n })\n\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: shouldRetryStatus(response.status),\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: `HTTP ${response.status}`,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n responseStatus: response.status,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n } catch (error) {\n delivery.attemptNumber += 1\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = error instanceof Error ? error.message : 'Unknown delivery error'\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: true,\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: delivery.errorMessage,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: delivery.errorMessage,\n durationMs: Date.now() - attemptStartedAt,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n }\n}\n\ntype HandleFailedDeliveryInput = {\n em: EntityManager\n webhook: WebhookEntity\n delivery: WebhookDeliveryEntity\n canRetry: boolean\n scheduleRetries: boolean\n fallbackMessage: string\n}\n\nasync function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {\n const { delivery, webhook } = input\n const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1)\n const shouldRetry = input.canRetry && retriesRemaining\n\n if (shouldRetry) {\n const nextRetryAt = calculateNextRetry(delivery.attemptNumber)\n delivery.status = 'pending'\n delivery.nextRetryAt = nextRetryAt\n\n await input.em.flush()\n\n if (input.scheduleRetries) {\n try {\n await enqueueWebhookDelivery(\n {\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n },\n Math.max(nextRetryAt.getTime() - Date.now(), 0),\n )\n } catch (error) {\n delivery.status = 'failed'\n delivery.nextRetryAt = null\n delivery.errorMessage = error instanceof Error\n ? `Retry scheduling failed: ${error.message}`\n : 'Retry scheduling failed'\n }\n }\n } else {\n delivery.status = 'expired'\n delivery.nextRetryAt = null\n\n await emitWebhooksEvent('webhooks.delivery.exhausted', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n errorMessage: delivery.errorMessage ?? input.fallbackMessage,\n })\n }\n\n if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {\n webhook.isActive = false\n await emitWebhooksEvent('webhooks.webhook.disabled', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n consecutiveFailures: webhook.consecutiveFailures,\n })\n }\n\n await input.em.flush()\n}\n\nfunction normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {\n const candidateType = typeof payload.type === 'string' ? payload.type : null\n const candidateTimestamp = typeof payload.timestamp === 'string' ? payload.timestamp : null\n const candidateData = isRecord(payload.data) ? payload.data : null\n\n if (candidateType && candidateTimestamp && candidateData) {\n return {\n type: candidateType,\n timestamp: candidateTimestamp,\n data: candidateData,\n }\n }\n\n return {\n type: eventType,\n timestamp: new Date().toISOString(),\n data: payload,\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction shouldRetryStatus(status: number): boolean {\n if (status >= 200 && status < 300) return false\n if (status === 408 || status === 429) return true\n return status >= 500\n}\n\nfunction calculateNextRetry(attemptNumber: number): Date {\n const baseDelayMs = 1000\n const jitterMs = Math.floor(Math.random() * 1000)\n const delayMs = baseDelayMs * Math.pow(2, Math.max(attemptNumber - 1, 0)) + jitterMs\n return new Date(Date.now() + delayMs)\n}\n"],
5
- "mappings": "AACA,SAAS,qBAAqB,yBAAyB;AACvD,SAAS,6BAA6B;AACtC,SAAS,uBAAuB,qBAAqB;AACrD,SAAS,yBAAyB;AAClC,SAAS,8BAA8B;AACvC,SAAS,6BAA6B,4CAA4C;AAClF,SAAS,8BAA8B,6BAA6B;AAyBpE,eAAsB,sBAAsB,OAAmE;AAC7G,QAAM,cAA2B;AAAA,IAC/B,MAAM,MAAM;AAAA,IACZ,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,MAAM,MAAM;AAAA,EACd;AACA,QAAM,MAAM,oBAAI,KAAK;AAErB,QAAM,WAAW,MAAM,GAAG,OAAO,uBAAuB;AAAA,IACtD,WAAW,MAAM,QAAQ;AAAA,IACzB,WAAW,MAAM;AAAA,IACjB,WAAW,kBAAkB;AAAA,IAC7B,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,eAAe;AAAA,IACf,aAAa,MAAM,QAAQ;AAAA,IAC3B,WAAW,MAAM,QAAQ;AAAA,IACzB,gBAAgB,MAAM,QAAQ;AAAA,IAC9B,UAAU,MAAM,QAAQ;AAAA,IACxB,YAAY;AAAA,IACZ,WAAW;AAAA,IACX,WAAW;AAAA,EACb,CAAC;AAED,QAAM,MAAM,GAAG,MAAM;AACrB,QAAM,kBAAkB,8BAA8B;AAAA,IACpD,YAAY,SAAS;AAAA,IACrB,WAAW,MAAM,QAAQ;AAAA,IACzB,WAAW,MAAM;AAAA,IACjB,gBAAgB,MAAM,QAAQ;AAAA,IAC9B,UAAU,MAAM,QAAQ;AAAA,EAC1B,CAAC;AACD,SAAO;AACT;AAEA,eAAsB,0BACpB,IACA,KACA,UAAyC,CAAC,GACc;AACxD,QAAM,WAAW,MAAM,GAAG,QAAQ,uBAAuB;AAAA,IACvD,IAAI,IAAI;AAAA,IACR,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,EACtB,CAAC;AAED,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,UAAU,MAAM;AAAA,IACpB;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI,SAAS;AAAA,MACb,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,MACpB,WAAW;AAAA,IACb;AAAA,IACA,CAAC;AAAA,IACD,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAAA,EAC/D;AAEA,MAAI,CAAC,SAAS;AACZ,aAAS,SAAS;AAClB,aAAS,eAAe;AACxB,aAAS,cAAc;AACvB,UAAM,GAAG,MAAM;AACf,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D;AAEA,MAAI,CAAC,QAAQ,UAAU;AACrB,aAAS,SAAS;AAClB,aAAS,eAAe;AACxB,aAAS,cAAc;AACvB,UAAM,GAAG,MAAM;AACf,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D;AAEA,QAAM,qBAAqB,MAAM,4BAA4B,IAAI;AAAA,IAC/D,UAAU,QAAQ;AAAA,IAClB,gBAAgB,QAAQ;AAAA,EAC1B,CAAC;AAED,MAAI,CAAC,oBAAoB;AACvB,aAAS,SAAS;AAClB,aAAS,eAAe;AACxB,aAAS,cAAc;AACvB,UAAM,GAAG,MAAM;AACf,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D;AAEA,MAAI;AACF,UAAM,6BAA6B,QAAQ,GAAG;AAAA,EAChD,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,wBAC7B,MAAM,UACN;AACJ,aAAS,SAAS;AAClB,aAAS,eAAe;AACxB,aAAS,cAAc;AACvB,aAAS,gBAAgB,oBAAI,KAAK;AAClC,aAAS,iBAAiB;AAC1B,YAAQ,uBAAuB;AAC/B,YAAQ,gBAAgB,oBAAI,KAAK;AACjC,UAAM,GAAG,MAAM;AACf,UAAM,kBAAkB,4BAA4B;AAAA,MAClD,YAAY,SAAS;AAAA,MACrB,WAAW,QAAQ;AAAA,MACnB,WAAW,SAAS;AAAA,MACpB,cAAc;AAAA,MACd,YAAY;AAAA,MACZ,gBAAgB,SAAS;AAAA,MACzB,UAAU,SAAS;AAAA,MACnB,WAAW;AAAA,IACb,CAAC;AACD,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D;AAEA,QAAM,cAAc,qBAAqB,SAAS,WAAW,SAAS,OAAO;AAC7E,WAAS,UAAU;AACnB,WAAS,SAAS;AAClB,WAAS,gBAAgB,oBAAI,KAAK;AAClC,WAAS,eAAe;AACxB,QAAM,GAAG,MAAM;AAEf,QAAM,OAAO,KAAK,UAAU,WAAW;AACvC,QAAM,YAAY,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAC9C,QAAM,UAAU;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR,QAAQ;AAAA,EACV;AAEA,QAAM,mBAAmB,KAAK,IAAI;AAElC,MAAI;AACF,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,QAAQ,SAAS;AAExE,UAAM,WAAW,MAAM,MAAM,QAAQ,KAAK;AAAA,MACxC,QAAQ,QAAQ;AAAA,MAChB,UAAU;AAAA,MACV,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,GAAG;AAAA,QACH,GAAI,QAAQ,iBAAiB,CAAC;AAAA,MAChC;AAAA,MACA;AAAA,MACA,QAAQ,WAAW;AAAA,IACrB,CAAC;AAED,iBAAa,SAAS;AAEtB,aAAS,iBAAiB;AAC1B,aAAS,iBAAiB,SAAS;AACnC,aAAS,gBAAgB,MAAM,SAAS,KAAK,GAAG,MAAM,GAAG,IAAI;AAC7D,aAAS,kBAAkB,OAAO,YAAY,SAAS,QAAQ,QAAQ,CAAC;AACxE,aAAS,aAAa,KAAK,IAAI,IAAI,SAAS,WAAW,QAAQ;AAC/D,aAAS,gBAAgB,oBAAI,KAAK;AAElC,QAAI,SAAS,IAAI;AACf,eAAS,SAAS;AAClB,eAAS,cAAc,oBAAI,KAAK;AAChC,eAAS,cAAc;AACvB,cAAQ,sBAAsB;AAC9B,cAAQ,gBAAgB,oBAAI,KAAK;AAEjC,YAAM,kBAAkB,+BAA+B;AAAA,QACrD,YAAY,SAAS;AAAA,QACrB,WAAW,QAAQ;AAAA,QACnB,WAAW,SAAS;AAAA,QACpB,gBAAgB,SAAS;AAAA,QACzB,UAAU,SAAS;AAAA,MACrB,CAAC;AAED,YAAM,GAAG,MAAM;AACf,aAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,IAC5D;AAEA,YAAQ,uBAAuB;AAC/B,YAAQ,gBAAgB,oBAAI,KAAK;AAEjC,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA;AAAA,MACA,UAAU,kBAAkB,SAAS,MAAM;AAAA,MAC3C,iBAAiB,QAAQ,oBAAoB;AAAA,MAC7C,iBAAiB,QAAQ,SAAS,MAAM;AAAA,IAC1C,CAAC;AAED,UAAM,kBAAkB,4BAA4B;AAAA,MAClD,YAAY,SAAS;AAAA,MACrB,WAAW,QAAQ;AAAA,MACnB,WAAW,SAAS;AAAA,MACpB,gBAAgB,SAAS;AAAA,MACzB,gBAAgB,SAAS;AAAA,MACzB,UAAU,SAAS;AAAA,MACnB,WAAW,SAAS,WAAW;AAAA,IACjC,CAAC;AAED,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D,SAAS,OAAO;AACd,aAAS,iBAAiB;AAC1B,aAAS,aAAa,KAAK,IAAI,IAAI,SAAS,WAAW,QAAQ;AAC/D,aAAS,gBAAgB,oBAAI,KAAK;AAClC,aAAS,eAAe,iBAAiB,QAAQ,MAAM,UAAU;AAEjE,YAAQ,uBAAuB;AAC/B,YAAQ,gBAAgB,oBAAI,KAAK;AAEjC,UAAM,qBAAqB;AAAA,MACzB;AAAA,MACA;AAAA,MACA;AAAA,MACA,UAAU;AAAA,MACV,iBAAiB,QAAQ,oBAAoB;AAAA,MAC7C,iBAAiB,SAAS;AAAA,IAC5B,CAAC;AAED,UAAM,kBAAkB,4BAA4B;AAAA,MAClD,YAAY,SAAS;AAAA,MACrB,WAAW,QAAQ;AAAA,MACnB,WAAW,SAAS;AAAA,MACpB,cAAc,SAAS;AAAA,MACvB,YAAY,KAAK,IAAI,IAAI;AAAA,MACzB,gBAAgB,SAAS;AAAA,MACzB,UAAU,SAAS;AAAA,MACnB,WAAW,SAAS,WAAW;AAAA,IACjC,CAAC;AAED,WAAO,EAAE,QAAQ,SAAS,QAAQ,YAAY,SAAS,GAAG;AAAA,EAC5D;AACF;AAWA,eAAe,qBAAqB,OAAiD;AACnF,QAAM,EAAE,UAAU,QAAQ,IAAI;AAC9B,QAAM,mBAAmB,SAAS,gBAAgB,KAAK,IAAI,SAAS,aAAa,CAAC;AAClF,QAAM,cAAc,MAAM,YAAY;AAEtC,MAAI,aAAa;AACf,UAAM,cAAc,mBAAmB,SAAS,aAAa;AAC7D,aAAS,SAAS;AAClB,aAAS,cAAc;AAEvB,UAAM,MAAM,GAAG,MAAM;AAErB,QAAI,MAAM,iBAAiB;AACzB,UAAI;AACF,cAAM;AAAA,UACJ;AAAA,YACE,YAAY,SAAS;AAAA,YACrB,UAAU,SAAS;AAAA,YACnB,gBAAgB,SAAS;AAAA,UAC3B;AAAA,UACA,KAAK,IAAI,YAAY,QAAQ,IAAI,KAAK,IAAI,GAAG,CAAC;AAAA,QAChD;AAAA,MACF,SAAS,OAAO;AACd,iBAAS,SAAS;AAClB,iBAAS,cAAc;AACvB,iBAAS,eAAe,iBAAiB,QACrC,4BAA4B,MAAM,OAAO,KACzC;AAAA,MACN;AAAA,IACF;AAAA,EACF,OAAO;AACL,aAAS,SAAS;AAClB,aAAS,cAAc;AAEvB,UAAM,kBAAkB,+BAA+B;AAAA,MACrD,YAAY,SAAS;AAAA,MACrB,WAAW,QAAQ;AAAA,MACnB,WAAW,SAAS;AAAA,MACpB,gBAAgB,SAAS;AAAA,MACzB,UAAU,SAAS;AAAA,MACnB,cAAc,SAAS,gBAAgB,MAAM;AAAA,IAC/C,CAAC;AAAA,EACH;AAEA,MAAI,QAAQ,uBAAuB,KAAK,QAAQ,uBAAuB,QAAQ,sBAAsB;AACnG,YAAQ,WAAW;AACnB,UAAM,kBAAkB,6BAA6B;AAAA,MACnD,WAAW,QAAQ;AAAA,MACnB,gBAAgB,QAAQ;AAAA,MACxB,UAAU,QAAQ;AAAA,MAClB,qBAAqB,QAAQ;AAAA,IAC/B,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,GAAG,MAAM;AACvB;AAEA,SAAS,qBAAqB,WAAmB,SAA+C;AAC9F,QAAM,gBAAgB,OAAO,QAAQ,SAAS,WAAW,QAAQ,OAAO;AACxE,QAAM,qBAAqB,OAAO,QAAQ,cAAc,WAAW,QAAQ,YAAY;AACvF,QAAM,gBAAgB,SAAS,QAAQ,IAAI,IAAI,QAAQ,OAAO;AAE9D,MAAI,iBAAiB,sBAAsB,eAAe;AACxD,WAAO;AAAA,MACL,MAAM;AAAA,MACN,WAAW;AAAA,MACX,MAAM;AAAA,IACR;AAAA,EACF;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,MAAM;AAAA,EACR;AACF;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,kBAAkB,QAAyB;AAClD,MAAI,UAAU,OAAO,SAAS,IAAK,QAAO;AAC1C,MAAI,WAAW,OAAO,WAAW,IAAK,QAAO;AAC7C,SAAO,UAAU;AACnB;AAEA,SAAS,mBAAmB,eAA6B;AACvD,QAAM,cAAc;AACpB,QAAM,WAAW,KAAK,MAAM,KAAK,OAAO,IAAI,GAAI;AAChD,QAAM,UAAU,cAAc,KAAK,IAAI,GAAG,KAAK,IAAI,gBAAgB,GAAG,CAAC,CAAC,IAAI;AAC5E,SAAO,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO;AACtC;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { buildWebhookHeaders, generateMessageId } from '@open-mercato/shared/lib/webhooks'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { emitWebhooksEvent } from '../events'\nimport { enqueueWebhookDelivery } from './queue'\nimport { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'\nimport {\n assertSafeWebhookDeliveryUrl,\n safeWebhookFetch,\n UnsafeWebhookUrlError,\n} from './url-safety'\n\nexport interface WebhookDeliveryJob {\n deliveryId: string\n tenantId: string\n organizationId: string\n}\n\nexport interface CreateWebhookDeliveryInput {\n em: EntityManager\n webhook: WebhookEntity\n eventId: string\n payload: Record<string, unknown>\n}\n\ntype ProcessWebhookDeliveryOptions = {\n scheduleRetries?: boolean\n}\n\ntype WebhookBody = {\n type: string\n timestamp: string\n data: Record<string, unknown>\n}\n\nexport async function createWebhookDelivery(input: CreateWebhookDeliveryInput): Promise<WebhookDeliveryEntity> {\n const bodyPayload: WebhookBody = {\n type: input.eventId,\n timestamp: new Date().toISOString(),\n data: input.payload,\n }\n const now = new Date()\n\n const delivery = input.em.create(WebhookDeliveryEntity, {\n webhookId: input.webhook.id,\n eventType: input.eventId,\n messageId: generateMessageId(),\n payload: bodyPayload,\n status: 'pending',\n attemptNumber: 0,\n maxAttempts: input.webhook.maxRetries,\n targetUrl: input.webhook.url,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n enqueuedAt: now,\n createdAt: now,\n updatedAt: now,\n })\n\n await input.em.flush()\n await emitWebhooksEvent('webhooks.delivery.enqueued', {\n deliveryId: delivery.id,\n webhookId: input.webhook.id,\n eventType: input.eventId,\n organizationId: input.webhook.organizationId,\n tenantId: input.webhook.tenantId,\n })\n return delivery\n}\n\nexport async function processWebhookDeliveryJob(\n em: EntityManager,\n job: WebhookDeliveryJob,\n options: ProcessWebhookDeliveryOptions = {},\n): Promise<{ status: string; deliveryId: string } | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, {\n id: job.deliveryId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n })\n\n if (!delivery) return null\n\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n {\n id: delivery.webhookId,\n tenantId: job.tenantId,\n organizationId: job.organizationId,\n deletedAt: null,\n },\n {},\n { tenantId: job.tenantId, organizationId: job.organizationId },\n )\n\n if (!webhook) {\n delivery.status = 'failed'\n delivery.errorMessage = 'Webhook not found'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n if (!webhook.isActive) {\n delivery.status = 'expired'\n delivery.errorMessage = 'Webhook is inactive'\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n const integrationEnabled = await isWebhookIntegrationEnabled(em, {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n\n if (!integrationEnabled) {\n delivery.status = 'expired'\n delivery.errorMessage = WEBHOOK_INTEGRATION_DISABLED_MESSAGE\n delivery.nextRetryAt = null\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n try {\n await assertSafeWebhookDeliveryUrl(webhook.url)\n } catch (error) {\n return await recordWebhookSafetyFailure({\n em,\n delivery,\n webhook,\n error,\n })\n }\n\n const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)\n delivery.payload = bodyPayload\n delivery.status = 'sending'\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = null\n await em.flush()\n\n const body = JSON.stringify(bodyPayload)\n const timestamp = Math.floor(Date.now() / 1000)\n const headers = buildWebhookHeaders(\n delivery.messageId,\n timestamp,\n body,\n webhook.secret,\n webhook.previousSecret,\n )\n\n const attemptStartedAt = Date.now()\n\n try {\n const controller = new AbortController()\n const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)\n\n let response: Response\n try {\n response = await safeWebhookFetch(webhook.url, {\n method: webhook.httpMethod,\n redirect: 'manual',\n headers: {\n 'content-type': 'application/json',\n ...headers,\n ...(webhook.customHeaders ?? {}),\n },\n body,\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timeoutId)\n }\n\n delivery.attemptNumber += 1\n delivery.responseStatus = response.status\n delivery.responseBody = (await response.text()).slice(0, 4096)\n delivery.responseHeaders = Object.fromEntries(response.headers.entries())\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n\n if (response.ok) {\n delivery.status = 'delivered'\n delivery.deliveredAt = new Date()\n delivery.nextRetryAt = null\n webhook.consecutiveFailures = 0\n webhook.lastSuccessAt = new Date()\n\n await emitWebhooksEvent('webhooks.delivery.succeeded', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n })\n\n await em.flush()\n return { status: delivery.status, deliveryId: delivery.id }\n }\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: shouldRetryStatus(response.status),\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: `HTTP ${response.status}`,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n responseStatus: response.status,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n } catch (error) {\n if (error instanceof UnsafeWebhookUrlError) {\n // DNS rebinding caught between the upfront safety check and the pinned connect:\n // the same attacker-controlled DNS would defeat retries too, so fail terminally.\n const durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.durationMs = durationMs\n return await recordWebhookSafetyFailure({\n em,\n delivery,\n webhook,\n error,\n durationMs,\n })\n }\n\n delivery.attemptNumber += 1\n delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()\n delivery.lastAttemptAt = new Date()\n delivery.errorMessage = error instanceof Error ? error.message : 'Unknown delivery error'\n\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n\n await handleFailedDelivery({\n em,\n webhook,\n delivery,\n canRetry: true,\n scheduleRetries: options.scheduleRetries !== false,\n fallbackMessage: delivery.errorMessage,\n })\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: delivery.errorMessage,\n durationMs: Date.now() - attemptStartedAt,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: delivery.status === 'pending',\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n }\n}\n\ntype RecordWebhookSafetyFailureInput = {\n em: EntityManager\n delivery: WebhookDeliveryEntity\n webhook: WebhookEntity\n error: unknown\n durationMs?: number | null\n}\n\nasync function recordWebhookSafetyFailure(\n input: RecordWebhookSafetyFailureInput,\n): Promise<{ status: string; deliveryId: string }> {\n const { em, delivery, webhook, error } = input\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL rejected by safety check'\n\n delivery.status = 'failed'\n delivery.errorMessage = message\n delivery.nextRetryAt = null\n delivery.lastAttemptAt = new Date()\n delivery.attemptNumber += 1\n webhook.consecutiveFailures += 1\n webhook.lastFailureAt = new Date()\n await em.flush()\n\n await emitWebhooksEvent('webhooks.delivery.failed', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n errorMessage: message,\n durationMs: input.durationMs ?? 0,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n willRetry: false,\n })\n\n return { status: delivery.status, deliveryId: delivery.id }\n}\n\ntype HandleFailedDeliveryInput = {\n em: EntityManager\n webhook: WebhookEntity\n delivery: WebhookDeliveryEntity\n canRetry: boolean\n scheduleRetries: boolean\n fallbackMessage: string\n}\n\nasync function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {\n const { delivery, webhook } = input\n const retriesRemaining = delivery.attemptNumber < Math.max(delivery.maxAttempts, 1)\n const shouldRetry = input.canRetry && retriesRemaining\n\n if (shouldRetry) {\n const nextRetryAt = calculateNextRetry(delivery.attemptNumber)\n delivery.status = 'pending'\n delivery.nextRetryAt = nextRetryAt\n\n await input.em.flush()\n\n if (input.scheduleRetries) {\n try {\n await enqueueWebhookDelivery(\n {\n deliveryId: delivery.id,\n tenantId: delivery.tenantId,\n organizationId: delivery.organizationId,\n },\n Math.max(nextRetryAt.getTime() - Date.now(), 0),\n )\n } catch (error) {\n delivery.status = 'failed'\n delivery.nextRetryAt = null\n delivery.errorMessage = error instanceof Error\n ? `Retry scheduling failed: ${error.message}`\n : 'Retry scheduling failed'\n }\n }\n } else {\n delivery.status = 'expired'\n delivery.nextRetryAt = null\n\n await emitWebhooksEvent('webhooks.delivery.exhausted', {\n deliveryId: delivery.id,\n webhookId: webhook.id,\n eventType: delivery.eventType,\n organizationId: delivery.organizationId,\n tenantId: delivery.tenantId,\n errorMessage: delivery.errorMessage ?? input.fallbackMessage,\n })\n }\n\n if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {\n webhook.isActive = false\n await emitWebhooksEvent('webhooks.webhook.disabled', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n consecutiveFailures: webhook.consecutiveFailures,\n })\n }\n\n await input.em.flush()\n}\n\nfunction normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {\n const candidateType = typeof payload.type === 'string' ? payload.type : null\n const candidateTimestamp = typeof payload.timestamp === 'string' ? payload.timestamp : null\n const candidateData = isRecord(payload.data) ? payload.data : null\n\n if (candidateType && candidateTimestamp && candidateData) {\n return {\n type: candidateType,\n timestamp: candidateTimestamp,\n data: candidateData,\n }\n }\n\n return {\n type: eventType,\n timestamp: new Date().toISOString(),\n data: payload,\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction shouldRetryStatus(status: number): boolean {\n if (status >= 200 && status < 300) return false\n if (status === 408 || status === 429) return true\n return status >= 500\n}\n\nfunction calculateNextRetry(attemptNumber: number): Date {\n const baseDelayMs = 1000\n const jitterMs = Math.floor(Math.random() * 1000)\n const delayMs = baseDelayMs * Math.pow(2, Math.max(attemptNumber - 1, 0)) + jitterMs\n return new Date(Date.now() + delayMs)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  assertSafeOutboundUrl,
3
3
  assertStaticallySafeOutboundUrl,
4
- parseOutboundUrl
4
+ parseOutboundUrl,
5
+ safeOutboundFetch
5
6
  } from "@open-mercato/shared/lib/url-safety";
6
7
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
7
8
  import { isPrivateIpAddress } from "@open-mercato/shared/lib/network";
@@ -37,12 +38,23 @@ async function assertSafeWebhookDeliveryUrl(rawUrl, deps = {}) {
37
38
  lookupHost: deps.lookupHost
38
39
  });
39
40
  }
41
+ async function safeWebhookFetch(rawUrl, init = {}, deps = {}) {
42
+ const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled();
43
+ return safeOutboundFetch(rawUrl, init, {
44
+ errorFactory: webhookErrorFactory,
45
+ subject: SUBJECT,
46
+ allowPrivate,
47
+ lookupHost: deps.lookupHost,
48
+ fetchImpl: deps.fetchImpl
49
+ });
50
+ }
40
51
  export {
41
52
  UnsafeWebhookUrlError,
42
53
  assertSafeWebhookDeliveryUrl,
43
54
  assertStaticallySafeWebhookUrl,
44
55
  isAllowPrivateWebhookUrlsEnabled,
45
56
  isPrivateIpAddress,
46
- parseWebhookUrl
57
+ parseWebhookUrl,
58
+ safeWebhookFetch
47
59
  };
48
60
  //# sourceMappingURL=url-safety.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/lib/url-safety.ts"],
4
- "sourcesContent": ["import {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n type HostLookup,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport { isPrivateIpAddress } from '@open-mercato/shared/lib/network'\n\nconst SUBJECT = 'Webhook URL'\n\n// reason: string (not UrlSafetyReason) preserves BC per BACKWARD_COMPATIBILITY.md \u00A72\nexport class UnsafeWebhookUrlError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `Webhook URL rejected: ${reason}`)\n this.name = 'UnsafeWebhookUrlError'\n this.reason = reason\n }\n}\n\nconst webhookErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeWebhookUrlError(reason, message)\n\ntype ParsedWebhookUrl = {\n url: URL\n hostname: string\n}\n\nexport function parseWebhookUrl(rawUrl: string): ParsedWebhookUrl {\n return parseOutboundUrl(rawUrl, { errorFactory: webhookErrorFactory, subject: SUBJECT })\n}\n\nexport type AssertStaticWebhookUrlDeps = {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeWebhookUrl(\n rawUrl: string,\n deps: AssertStaticWebhookUrlDeps = {},\n): void {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n assertStaticallySafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport function isAllowPrivateWebhookUrlsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS, false)\n}\n\nexport type AssertSafeWebhookDeliveryDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n}\n\nexport async function assertSafeWebhookDeliveryUrl(\n rawUrl: string,\n deps: AssertSafeWebhookDeliveryDeps = {},\n): Promise<void> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n await assertSafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n"],
5
- "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP,SAAS,+BAA+B;AAExC,SAAS,0BAA0B;AAEnC,MAAM,UAAU;AAGT,MAAM,8BAA8B,MAAM;AAAA,EAG/C,YAAY,QAAgB,SAAkB;AAC5C,UAAM,WAAW,yBAAyB,MAAM,EAAE;AAClD,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,sBAAsB,CAAC,QAAyB,YACpD,IAAI,sBAAsB,QAAQ,OAAO;AAOpC,SAAS,gBAAgB,QAAkC;AAChE,SAAO,iBAAiB,QAAQ,EAAE,cAAc,qBAAqB,SAAS,QAAQ,CAAC;AACzF;AAMO,SAAS,+BACd,QACA,OAAmC,CAAC,GAC9B;AACN,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,kCAAgC,QAAQ;AAAA,IACtC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,EACF,CAAC;AACH;AAEO,SAAS,mCAA4C;AAC1D,SAAO,wBAAwB,QAAQ,IAAI,gCAAgC,KAAK;AAClF;AAOA,eAAsB,6BACpB,QACA,OAAsC,CAAC,GACxB;AACf,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,QAAM,sBAAsB,QAAQ;AAAA,IAClC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA,YAAY,KAAK;AAAA,EACnB,CAAC;AACH;",
4
+ "sourcesContent": ["import {\n assertSafeOutboundUrl,\n assertStaticallySafeOutboundUrl,\n parseOutboundUrl,\n safeOutboundFetch,\n type HostLookup,\n type SafeOutboundFetchOptions,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport { isPrivateIpAddress } from '@open-mercato/shared/lib/network'\n\nconst SUBJECT = 'Webhook URL'\n\n// reason: string (not UrlSafetyReason) preserves BC per BACKWARD_COMPATIBILITY.md \u00A72\nexport class UnsafeWebhookUrlError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `Webhook URL rejected: ${reason}`)\n this.name = 'UnsafeWebhookUrlError'\n this.reason = reason\n }\n}\n\nconst webhookErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeWebhookUrlError(reason, message)\n\ntype ParsedWebhookUrl = {\n url: URL\n hostname: string\n}\n\nexport function parseWebhookUrl(rawUrl: string): ParsedWebhookUrl {\n return parseOutboundUrl(rawUrl, { errorFactory: webhookErrorFactory, subject: SUBJECT })\n}\n\nexport type AssertStaticWebhookUrlDeps = {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeWebhookUrl(\n rawUrl: string,\n deps: AssertStaticWebhookUrlDeps = {},\n): void {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n assertStaticallySafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nexport function isAllowPrivateWebhookUrlsEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS, false)\n}\n\nexport type AssertSafeWebhookDeliveryDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n}\n\nexport async function assertSafeWebhookDeliveryUrl(\n rawUrl: string,\n deps: AssertSafeWebhookDeliveryDeps = {},\n): Promise<void> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n await assertSafeOutboundUrl(rawUrl, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n })\n}\n\nexport type SafeWebhookFetchDeps = {\n lookupHost?: HostLookup\n allowPrivate?: boolean\n fetchImpl?: SafeOutboundFetchOptions['fetchImpl']\n}\n\n/**\n * Validates the webhook URL and performs a `fetch()` with the connection pinned to a\n * pre-validated address \u2014 defeats DNS rebinding by ensuring the validation lookup and\n * the connect lookup return the same IP. Always sets `redirect: 'manual'` unless the\n * caller overrides it.\n */\nexport async function safeWebhookFetch(\n rawUrl: string,\n init: RequestInit = {},\n deps: SafeWebhookFetchDeps = {},\n): Promise<Response> {\n const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()\n return safeOutboundFetch(rawUrl, init, {\n errorFactory: webhookErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n lookupHost: deps.lookupHost,\n fetchImpl: deps.fetchImpl,\n })\n}\n"],
5
+ "mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,+BAA+B;AAExC,SAAS,0BAA0B;AAEnC,MAAM,UAAU;AAGT,MAAM,8BAA8B,MAAM;AAAA,EAG/C,YAAY,QAAgB,SAAkB;AAC5C,UAAM,WAAW,yBAAyB,MAAM,EAAE;AAClD,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,sBAAsB,CAAC,QAAyB,YACpD,IAAI,sBAAsB,QAAQ,OAAO;AAOpC,SAAS,gBAAgB,QAAkC;AAChE,SAAO,iBAAiB,QAAQ,EAAE,cAAc,qBAAqB,SAAS,QAAQ,CAAC;AACzF;AAMO,SAAS,+BACd,QACA,OAAmC,CAAC,GAC9B;AACN,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,kCAAgC,QAAQ;AAAA,IACtC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,EACF,CAAC;AACH;AAEO,SAAS,mCAA4C;AAC1D,SAAO,wBAAwB,QAAQ,IAAI,gCAAgC,KAAK;AAClF;AAOA,eAAsB,6BACpB,QACA,OAAsC,CAAC,GACxB;AACf,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,QAAM,sBAAsB,QAAQ;AAAA,IAClC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA,YAAY,KAAK;AAAA,EACnB,CAAC;AACH;AAcA,eAAsB,iBACpB,QACA,OAAoB,CAAC,GACrB,OAA6B,CAAC,GACX;AACnB,QAAM,eAAe,KAAK,gBAAgB,iCAAiC;AAC3E,SAAO,kBAAkB,QAAQ,MAAM;AAAA,IACrC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,IACA,YAAY,KAAK;AAAA,IACjB,WAAW,KAAK;AAAA,EAClB,CAAC;AACH;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.5.1-develop.2912.8d7b1fef24",
3
+ "version": "0.5.1-develop.2917.31ee9898e3",
4
4
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -69,18 +69,18 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@open-mercato/core": "0.5.1-develop.2912.8d7b1fef24",
73
- "@open-mercato/queue": "0.5.1-develop.2912.8d7b1fef24",
74
- "@open-mercato/ui": "0.5.1-develop.2912.8d7b1fef24"
72
+ "@open-mercato/core": "0.5.1-develop.2917.31ee9898e3",
73
+ "@open-mercato/queue": "0.5.1-develop.2917.31ee9898e3",
74
+ "@open-mercato/ui": "0.5.1-develop.2917.31ee9898e3"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@mikro-orm/postgresql": "^7.0.10",
78
- "@open-mercato/shared": "0.5.1-develop.2912.8d7b1fef24",
78
+ "@open-mercato/shared": "0.5.1-develop.2917.31ee9898e3",
79
79
  "react": "^19.0.0",
80
80
  "react-dom": "^19.0.0"
81
81
  },
82
82
  "devDependencies": {
83
- "@open-mercato/shared": "0.5.1-develop.2912.8d7b1fef24",
83
+ "@open-mercato/shared": "0.5.1-develop.2917.31ee9898e3",
84
84
  "@types/jest": "^30.0.0",
85
85
  "esbuild": "^0.28.0",
86
86
  "glob": "^13.0.6",
@@ -3,6 +3,7 @@ import {
3
3
  assertStaticallySafeWebhookUrl,
4
4
  isPrivateIpAddress,
5
5
  parseWebhookUrl,
6
+ safeWebhookFetch,
6
7
  UnsafeWebhookUrlError,
7
8
  } from '../url-safety'
8
9
 
@@ -191,3 +192,54 @@ describe('url-safety — assertSafeWebhookDeliveryUrl (DNS rebinding guard)', ()
191
192
  ).rejects.toMatchObject({ reason: 'forbidden_protocol' })
192
193
  })
193
194
  })
195
+
196
+ describe('url-safety — safeWebhookFetch', () => {
197
+ it('rejects DNS-rebinding hosts before any fetch attempt', async () => {
198
+ const fetchImpl = jest.fn() as unknown as typeof fetch
199
+ const lookupHost = async () => [{ address: '10.0.0.5', family: 4 }]
200
+ await expect(
201
+ safeWebhookFetch('https://rebind.evil.example/', {}, {
202
+ fetchImpl,
203
+ lookupHost,
204
+ allowPrivate: false,
205
+ }),
206
+ ).rejects.toBeInstanceOf(UnsafeWebhookUrlError)
207
+ expect(fetchImpl).not.toHaveBeenCalled()
208
+ })
209
+
210
+ it('reports the private resolved address in the error reason', async () => {
211
+ const fetchImpl = jest.fn() as unknown as typeof fetch
212
+ const lookupHost = async () => [{ address: '10.0.0.5', family: 4 }]
213
+ try {
214
+ await safeWebhookFetch('https://rebind.evil.example/', {}, {
215
+ fetchImpl,
216
+ lookupHost,
217
+ allowPrivate: false,
218
+ })
219
+ throw new Error('expected throw')
220
+ } catch (error) {
221
+ expect(error).toBeInstanceOf(UnsafeWebhookUrlError)
222
+ expect((error as UnsafeWebhookUrlError).reason).toBe('private_ip_resolved')
223
+ }
224
+ })
225
+
226
+ it('forwards init through fetchImpl with redirect:"manual"', async () => {
227
+ const fetchImpl = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
228
+ const lookupHost = async () => [{ address: '93.184.216.34', family: 4 }]
229
+ const response = await safeWebhookFetch(
230
+ 'https://hooks.example.com/in',
231
+ { method: 'POST', body: 'payload' },
232
+ { fetchImpl, lookupHost, allowPrivate: false },
233
+ )
234
+ expect(response.status).toBe(200)
235
+ expect(fetchImpl).toHaveBeenCalledTimes(1)
236
+ const [, init] = (fetchImpl as unknown as jest.Mock).mock.calls[0]
237
+ expect(init).toEqual(
238
+ expect.objectContaining({
239
+ method: 'POST',
240
+ body: 'payload',
241
+ redirect: 'manual',
242
+ }),
243
+ )
244
+ })
245
+ })
@@ -5,7 +5,11 @@ import { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'
5
5
  import { emitWebhooksEvent } from '../events'
6
6
  import { enqueueWebhookDelivery } from './queue'
7
7
  import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from './integration-state'
8
- import { assertSafeWebhookDeliveryUrl, UnsafeWebhookUrlError } from './url-safety'
8
+ import {
9
+ assertSafeWebhookDeliveryUrl,
10
+ safeWebhookFetch,
11
+ UnsafeWebhookUrlError,
12
+ } from './url-safety'
9
13
 
10
14
  export interface WebhookDeliveryJob {
11
15
  deliveryId: string
@@ -123,28 +127,12 @@ export async function processWebhookDeliveryJob(
123
127
  try {
124
128
  await assertSafeWebhookDeliveryUrl(webhook.url)
125
129
  } catch (error) {
126
- const message = error instanceof UnsafeWebhookUrlError
127
- ? error.message
128
- : 'Webhook URL rejected by safety check'
129
- delivery.status = 'failed'
130
- delivery.errorMessage = message
131
- delivery.nextRetryAt = null
132
- delivery.lastAttemptAt = new Date()
133
- delivery.attemptNumber += 1
134
- webhook.consecutiveFailures += 1
135
- webhook.lastFailureAt = new Date()
136
- await em.flush()
137
- await emitWebhooksEvent('webhooks.delivery.failed', {
138
- deliveryId: delivery.id,
139
- webhookId: webhook.id,
140
- eventType: delivery.eventType,
141
- errorMessage: message,
142
- durationMs: 0,
143
- organizationId: delivery.organizationId,
144
- tenantId: delivery.tenantId,
145
- willRetry: false,
130
+ return await recordWebhookSafetyFailure({
131
+ em,
132
+ delivery,
133
+ webhook,
134
+ error,
146
135
  })
147
- return { status: delivery.status, deliveryId: delivery.id }
148
136
  }
149
137
 
150
138
  const bodyPayload = normalizeWebhookBody(delivery.eventType, delivery.payload)
@@ -170,19 +158,22 @@ export async function processWebhookDeliveryJob(
170
158
  const controller = new AbortController()
171
159
  const timeoutId = setTimeout(() => controller.abort(), webhook.timeoutMs)
172
160
 
173
- const response = await fetch(webhook.url, {
174
- method: webhook.httpMethod,
175
- redirect: 'manual',
176
- headers: {
177
- 'content-type': 'application/json',
178
- ...headers,
179
- ...(webhook.customHeaders ?? {}),
180
- },
181
- body,
182
- signal: controller.signal,
183
- })
184
-
185
- clearTimeout(timeoutId)
161
+ let response: Response
162
+ try {
163
+ response = await safeWebhookFetch(webhook.url, {
164
+ method: webhook.httpMethod,
165
+ redirect: 'manual',
166
+ headers: {
167
+ 'content-type': 'application/json',
168
+ ...headers,
169
+ ...(webhook.customHeaders ?? {}),
170
+ },
171
+ body,
172
+ signal: controller.signal,
173
+ })
174
+ } finally {
175
+ clearTimeout(timeoutId)
176
+ }
186
177
 
187
178
  delivery.attemptNumber += 1
188
179
  delivery.responseStatus = response.status
@@ -234,6 +225,20 @@ export async function processWebhookDeliveryJob(
234
225
 
235
226
  return { status: delivery.status, deliveryId: delivery.id }
236
227
  } catch (error) {
228
+ if (error instanceof UnsafeWebhookUrlError) {
229
+ // DNS rebinding caught between the upfront safety check and the pinned connect:
230
+ // the same attacker-controlled DNS would defeat retries too, so fail terminally.
231
+ const durationMs = Date.now() - delivery.enqueuedAt.getTime()
232
+ delivery.durationMs = durationMs
233
+ return await recordWebhookSafetyFailure({
234
+ em,
235
+ delivery,
236
+ webhook,
237
+ error,
238
+ durationMs,
239
+ })
240
+ }
241
+
237
242
  delivery.attemptNumber += 1
238
243
  delivery.durationMs = Date.now() - delivery.enqueuedAt.getTime()
239
244
  delivery.lastAttemptAt = new Date()
@@ -266,6 +271,45 @@ export async function processWebhookDeliveryJob(
266
271
  }
267
272
  }
268
273
 
274
+ type RecordWebhookSafetyFailureInput = {
275
+ em: EntityManager
276
+ delivery: WebhookDeliveryEntity
277
+ webhook: WebhookEntity
278
+ error: unknown
279
+ durationMs?: number | null
280
+ }
281
+
282
+ async function recordWebhookSafetyFailure(
283
+ input: RecordWebhookSafetyFailureInput,
284
+ ): Promise<{ status: string; deliveryId: string }> {
285
+ const { em, delivery, webhook, error } = input
286
+ const message = error instanceof UnsafeWebhookUrlError
287
+ ? error.message
288
+ : 'Webhook URL rejected by safety check'
289
+
290
+ delivery.status = 'failed'
291
+ delivery.errorMessage = message
292
+ delivery.nextRetryAt = null
293
+ delivery.lastAttemptAt = new Date()
294
+ delivery.attemptNumber += 1
295
+ webhook.consecutiveFailures += 1
296
+ webhook.lastFailureAt = new Date()
297
+ await em.flush()
298
+
299
+ await emitWebhooksEvent('webhooks.delivery.failed', {
300
+ deliveryId: delivery.id,
301
+ webhookId: webhook.id,
302
+ eventType: delivery.eventType,
303
+ errorMessage: message,
304
+ durationMs: input.durationMs ?? 0,
305
+ organizationId: delivery.organizationId,
306
+ tenantId: delivery.tenantId,
307
+ willRetry: false,
308
+ })
309
+
310
+ return { status: delivery.status, deliveryId: delivery.id }
311
+ }
312
+
269
313
  type HandleFailedDeliveryInput = {
270
314
  em: EntityManager
271
315
  webhook: WebhookEntity
@@ -2,7 +2,9 @@ import {
2
2
  assertSafeOutboundUrl,
3
3
  assertStaticallySafeOutboundUrl,
4
4
  parseOutboundUrl,
5
+ safeOutboundFetch,
5
6
  type HostLookup,
7
+ type SafeOutboundFetchOptions,
6
8
  type UrlSafetyReason,
7
9
  } from '@open-mercato/shared/lib/url-safety'
8
10
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
@@ -71,3 +73,30 @@ export async function assertSafeWebhookDeliveryUrl(
71
73
  lookupHost: deps.lookupHost,
72
74
  })
73
75
  }
76
+
77
+ export type SafeWebhookFetchDeps = {
78
+ lookupHost?: HostLookup
79
+ allowPrivate?: boolean
80
+ fetchImpl?: SafeOutboundFetchOptions['fetchImpl']
81
+ }
82
+
83
+ /**
84
+ * Validates the webhook URL and performs a `fetch()` with the connection pinned to a
85
+ * pre-validated address — defeats DNS rebinding by ensuring the validation lookup and
86
+ * the connect lookup return the same IP. Always sets `redirect: 'manual'` unless the
87
+ * caller overrides it.
88
+ */
89
+ export async function safeWebhookFetch(
90
+ rawUrl: string,
91
+ init: RequestInit = {},
92
+ deps: SafeWebhookFetchDeps = {},
93
+ ): Promise<Response> {
94
+ const allowPrivate = deps.allowPrivate ?? isAllowPrivateWebhookUrlsEnabled()
95
+ return safeOutboundFetch(rawUrl, init, {
96
+ errorFactory: webhookErrorFactory,
97
+ subject: SUBJECT,
98
+ allowPrivate,
99
+ lookupHost: deps.lookupHost,
100
+ fetchImpl: deps.fetchImpl,
101
+ })
102
+ }