@open-mercato/webhooks 0.6.5-develop.5382.1.f542de69af → 0.6.5

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.
@@ -1,2 +1,2 @@
1
- [build:webhooks] found 67 entry points
1
+ [build:webhooks] found 68 entry points
2
2
  [build:webhooks] built successfully
@@ -137,9 +137,9 @@ test.describe("TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
137
137
  }
138
138
  });
139
139
  test("WHK-01 stale webhook DELETE in the list surfaces the conflict bar", async ({ page }) => {
140
+ test.setTimeout(6e4);
140
141
  const token = await getAuthToken(page.request, "admin");
141
142
  const stamp = Date.now();
142
- const webhookName = `QA Lock 043 ${stamp}`;
143
143
  let webhookId = null;
144
144
  try {
145
145
  const webhook = await createWebhook(page.request, token, stamp);
@@ -150,7 +150,7 @@ test.describe("TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
150
150
  (response) => response.url().includes("/api/webhooks") && response.request().method() === "GET" && response.ok(),
151
151
  { timeout: 2e4 }
152
152
  ).catch(() => void 0);
153
- const row = page.getByRole("row", { name: new RegExp(`${webhookName}\\b`) }).first();
153
+ const row = page.getByRole("row", { name: new RegExp(`QA Lock 043 (?:bumped )?${stamp}\\b`) }).first();
154
154
  await expect(row, "created webhook should appear in the list").toBeVisible({ timeout: 2e4 });
155
155
  const bump = await apiRequest(page.request, "PUT", `${WEBHOOKS_API}/${webhookId}`, {
156
156
  token,
@@ -158,11 +158,15 @@ test.describe("TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
158
158
  });
159
159
  expect(bump.status(), "out-of-band webhook PUT should succeed").toBeLessThan(300);
160
160
  const kebab = row.getByRole("button", { name: /open actions/i });
161
- await expect(kebab, "row should expose an Open actions trigger").toBeVisible();
162
- await kebab.click();
163
161
  const deleteItem = page.getByRole("menuitem", { name: /^delete$/i });
164
- await expect(deleteItem, "delete menu item should be visible").toBeVisible({ timeout: 1e4 });
165
- await deleteItem.click();
162
+ await expect(async () => {
163
+ if (!await deleteItem.isVisible().catch(() => false)) {
164
+ await kebab.click({ timeout: 2e3 }).catch(() => {
165
+ });
166
+ await expect(deleteItem).toBeVisible({ timeout: 1500 });
167
+ }
168
+ await deleteItem.click({ timeout: 2e3 });
169
+ }).toPass({ timeout: 3e4 });
166
170
  const dialog = page.getByRole("alertdialog");
167
171
  await expect(dialog, "confirm dialog should open").toBeVisible({ timeout: 1e4 });
168
172
  await dialog.getByRole("button", { name: /^(confirm|delete)$/i }).click();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts"],
4
- "sourcesContent": ["import { test, expect, type APIRequestContext } from '@playwright/test'\nimport { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'\nimport {\n putWithLock,\n expectConflictBody,\n expectConflictBanner,\n resolveApiUrl,\n} from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'\nimport { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * TC-LOCK-OSS-043 \u2014 webhooks + inbox settings + data-sync schedule\n * optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).\n *\n * All three surfaces guard writes with `enforceCommandOptimisticLock` inside\n * pure command/API routes \u2014 none expose a dedicated `data-crud-field-id`\n * CrudForm edit page for the locked record, so the conflict contract is proven\n * at the API level: capture the record's `updated_at`, advance it out-of-band\n * via a header-less write (the strictly-additive path always succeeds and bumps\n * `updated_at`), then replay the now-stale write with the original\n * expected-version header \u2192 409 `optimistic_lock_conflict`.\n *\n * - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`\n * (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,\n * `resourceKind: 'webhooks.endpoint'`).\n * - INB-01: `PATCH /api/inbox_ops/settings`\n * (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,\n * `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant\n * singleton, so the test bumps + restores `workingLanguage` instead of\n * creating/deleting a fixture.\n * - SYNC-01: `POST /api/data_sync/schedules`\n * (`packages/core/src/modules/data_sync/api/schedules/route.ts` \u2192\n * `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).\n * The collection POST reads the expected-version header; the service enforces\n * the lock when a row with the same (integrationId, entityType, direction)\n * key already exists.\n */\n\nconst WEBHOOKS_API = '/api/webhooks'\nconst INBOX_SETTINGS_API = '/api/inbox_ops/settings'\nconst SCHEDULES_API = '/api/data_sync/schedules'\n\ntype InboxSettings = { id: string; workingLanguage: string; updatedAt: string }\n\nasync function createWebhook(\n request: APIRequestContext,\n token: string,\n stamp: number,\n): Promise<{ id: string; updatedAt: string }> {\n const created = await apiRequest(request, 'POST', WEBHOOKS_API, {\n token,\n data: {\n name: `QA Lock 043 ${stamp}`,\n url: `https://example.com/qa-lock-043-${stamp}`,\n subscribedEvents: ['qa.lock.test'],\n },\n })\n expect(created.status(), 'POST webhook should be 201').toBe(201)\n const body = (await created.json()) as { id?: string }\n expect(typeof body.id, 'webhook creation should return an id').toBe('string')\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const detailBody = (await detail.json()) as { updatedAt?: string }\n expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')\n return { id: body.id as string, updatedAt: detailBody.updatedAt as string }\n}\n\nasync function readWebhookUpdatedAt(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<string> {\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const body = (await detail.json()) as { updatedAt?: string }\n return body.updatedAt as string\n}\n\nasync function deleteWebhook(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<void> {\n const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)\n await request\n .fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),\n },\n })\n .catch(() => undefined)\n}\n\nasync function patchInboxSettings(\n request: APIRequestContext,\n token: string,\n body: Record<string, unknown>,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {\n method: 'PATCH',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: body,\n })\n}\n\nasync function readInboxSettings(\n request: APIRequestContext,\n token: string,\n): Promise<InboxSettings> {\n const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })\n expect(response.status(), 'GET inbox settings should be 200').toBe(200)\n const body = (await response.json()) as { settings?: InboxSettings | null }\n expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()\n return body.settings as InboxSettings\n}\n\nasync function postSchedule(\n request: APIRequestContext,\n token: string,\n scheduleValue: string,\n integrationId: string,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(SCHEDULES_API), {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: {\n integrationId,\n entityType: 'products',\n direction: 'import',\n scheduleType: 'cron',\n scheduleValue,\n timezone: 'UTC',\n fullSync: false,\n isEnabled: true,\n },\n })\n}\n\ntest.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {\n test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n // Advance updated_at out-of-band via a header-less PUT.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Replay the now-stale write \u2192 409.\n const conflict = await putWithLock(\n page.request,\n token,\n `${WEBHOOKS_API}/${webhookId}`,\n { name: `QA Lock 043 stale ${stamp}` },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n [OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,\n },\n })\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n // WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified \u2014 the webhooks\n // server DELETE returns the structured 409 (proven by the active \"WHK-01 stale webhook\n // DELETE is refused with a 409\" API test) and the list page sends the lock header\n // (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through\n // surfaceRecordConflict. The RowActions menu opens on CLICK of the \"Open actions\" kebab and\n // renders the items in a portal on document.body (role=\"menuitem\"); the confirm dialog is a\n // role=\"alertdialog\" with a \"Confirm\" button. We drive that choreography robustly here:\n // wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then\n // assert the unified conflict bar (never the success toast).\n test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n const webhookName = `QA Lock 043 ${stamp}`\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n\n await login(page, 'admin')\n await page.goto('/backend/webhooks')\n // Wait for the list GET to settle before interacting. The list reflects the\n // freshly created fixture immediately (newest-first), so a unique name makes\n // the row unambiguous.\n await page\n .waitForResponse(\n (response) =>\n response.url().includes('/api/webhooks') &&\n response.request().method() === 'GET' &&\n response.ok(),\n { timeout: 20_000 },\n )\n .catch(() => undefined)\n\n const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\\\b`) }).first()\n await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })\n\n // Advance updated_at out-of-band \u2192 the in-page row token is now stale.\n // NOTE: bump description (not name) so the row locator stays valid even if\n // the list re-fetches after the PUT.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { description: `bumped-${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Open the row's RowActions kebab (opens on click) and trigger Delete. The\n // menu renders in a portal on document.body, so query it at the page level.\n const kebab = row.getByRole('button', { name: /open actions/i })\n await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()\n await kebab.click()\n const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })\n await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })\n await deleteItem.click()\n\n // Confirm the destructive alertdialog (the row still holds the stale\n // updated_at captured at render time, so the DELETE 409s).\n const dialog = page.getByRole('alertdialog')\n await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })\n await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()\n\n // The client must route the 409 to the unified conflict bar, never the\n // success toast.\n await expectConflictBanner(page)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const before = await readInboxSettings(page.request, token)\n const originalLanguage = before.workingLanguage\n const staleUpdatedAt = before.updatedAt\n const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'\n const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'\n try {\n // Advance updated_at out-of-band via a header-less PATCH.\n const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })\n expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)\n\n // Replay the now-stale write with the original expected-version \u2192 409.\n const conflict = await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: staleLanguage },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n // Restore the original working language with a fresh lock token.\n const current = await readInboxSettings(page.request, token).catch(() => null)\n if (current) {\n await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: originalLanguage },\n current.updatedAt,\n ).catch(() => undefined)\n }\n }\n })\n\n test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n const integrationId = `qa-lock-043-${stamp}`\n let scheduleId: string | null = null\n try {\n const created = await postSchedule(page.request, token, '0 * * * *', integrationId)\n expect(created.status(), 'POST schedule should be 201').toBe(201)\n const createdBody = (await created.json()) as { id?: string; updatedAt?: string }\n scheduleId = createdBody.id ?? null\n const staleUpdatedAt = createdBody.updatedAt\n expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')\n expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')\n\n // Advance updated_at out-of-band via the header-less [id] PUT route.\n const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {\n token,\n data: { scheduleValue: '5 * * * *' },\n })\n expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)\n\n // Replay the save for the same (integrationId, entityType, direction) key\n // with the now-stale expected-version \u2192 409.\n const conflict = await postSchedule(\n page.request,\n token,\n '9 * * * *',\n integrationId,\n staleUpdatedAt as string,\n )\n await expectConflictBody(conflict)\n } finally {\n if (scheduleId) {\n await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(\n () => undefined,\n )\n }\n }\n })\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { test, expect, type APIRequestContext } from '@playwright/test'\nimport { getAuthToken, apiRequest } from '@open-mercato/core/modules/core/__integration__/helpers/api'\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'\nimport {\n putWithLock,\n expectConflictBody,\n expectConflictBanner,\n resolveApiUrl,\n} from '@open-mercato/core/modules/core/__integration__/helpers/optimisticLockUi'\nimport { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * TC-LOCK-OSS-043 \u2014 webhooks + inbox settings + data-sync schedule\n * optimistic-lock conflict contract (WHK-01, INB-01, SYNC-01).\n *\n * All three surfaces guard writes with `enforceCommandOptimisticLock` inside\n * pure command/API routes \u2014 none expose a dedicated `data-crud-field-id`\n * CrudForm edit page for the locked record, so the conflict contract is proven\n * at the API level: capture the record's `updated_at`, advance it out-of-band\n * via a header-less write (the strictly-additive path always succeeds and bumps\n * `updated_at`), then replay the now-stale write with the original\n * expected-version header \u2192 409 `optimistic_lock_conflict`.\n *\n * - WHK-01: `PUT /api/webhooks/<id>` and `DELETE /api/webhooks/<id>`\n * (`packages/webhooks/src/modules/webhooks/api/webhooks/[id]/route.ts`,\n * `resourceKind: 'webhooks.endpoint'`).\n * - INB-01: `PATCH /api/inbox_ops/settings`\n * (`packages/core/src/modules/inbox_ops/api/settings/route.ts`,\n * `resourceKind: 'inbox_ops.settings'`). The settings row is a per-tenant\n * singleton, so the test bumps + restores `workingLanguage` instead of\n * creating/deleting a fixture.\n * - SYNC-01: `POST /api/data_sync/schedules`\n * (`packages/core/src/modules/data_sync/api/schedules/route.ts` \u2192\n * `SyncScheduleService.saveSchedule`, `resourceKind: 'data_sync.schedule'`).\n * The collection POST reads the expected-version header; the service enforces\n * the lock when a row with the same (integrationId, entityType, direction)\n * key already exists.\n */\n\nconst WEBHOOKS_API = '/api/webhooks'\nconst INBOX_SETTINGS_API = '/api/inbox_ops/settings'\nconst SCHEDULES_API = '/api/data_sync/schedules'\n\ntype InboxSettings = { id: string; workingLanguage: string; updatedAt: string }\n\nasync function createWebhook(\n request: APIRequestContext,\n token: string,\n stamp: number,\n): Promise<{ id: string; updatedAt: string }> {\n const created = await apiRequest(request, 'POST', WEBHOOKS_API, {\n token,\n data: {\n name: `QA Lock 043 ${stamp}`,\n url: `https://example.com/qa-lock-043-${stamp}`,\n subscribedEvents: ['qa.lock.test'],\n },\n })\n expect(created.status(), 'POST webhook should be 201').toBe(201)\n const body = (await created.json()) as { id?: string }\n expect(typeof body.id, 'webhook creation should return an id').toBe('string')\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${body.id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const detailBody = (await detail.json()) as { updatedAt?: string }\n expect(typeof detailBody.updatedAt, 'webhook should expose updatedAt').toBe('string')\n return { id: body.id as string, updatedAt: detailBody.updatedAt as string }\n}\n\nasync function readWebhookUpdatedAt(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<string> {\n const detail = await apiRequest(request, 'GET', `${WEBHOOKS_API}/${id}`, { token })\n expect(detail.status(), 'GET webhook detail should be 200').toBe(200)\n const body = (await detail.json()) as { updatedAt?: string }\n return body.updatedAt as string\n}\n\nasync function deleteWebhook(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<void> {\n const current = await readWebhookUpdatedAt(request, token, id).catch(() => undefined)\n await request\n .fetch(resolveApiUrl(`${WEBHOOKS_API}/${id}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(current ? { [OPTIMISTIC_LOCK_HEADER_NAME]: current } : {}),\n },\n })\n .catch(() => undefined)\n}\n\nasync function patchInboxSettings(\n request: APIRequestContext,\n token: string,\n body: Record<string, unknown>,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(INBOX_SETTINGS_API), {\n method: 'PATCH',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: body,\n })\n}\n\nasync function readInboxSettings(\n request: APIRequestContext,\n token: string,\n): Promise<InboxSettings> {\n const response = await apiRequest(request, 'GET', INBOX_SETTINGS_API, { token })\n expect(response.status(), 'GET inbox settings should be 200').toBe(200)\n const body = (await response.json()) as { settings?: InboxSettings | null }\n expect(body.settings, 'tenant inbox settings singleton should exist').toBeTruthy()\n return body.settings as InboxSettings\n}\n\nasync function postSchedule(\n request: APIRequestContext,\n token: string,\n scheduleValue: string,\n integrationId: string,\n lockValue?: string,\n) {\n return request.fetch(resolveApiUrl(SCHEDULES_API), {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n ...(lockValue !== undefined ? { [OPTIMISTIC_LOCK_HEADER_NAME]: lockValue } : {}),\n },\n data: {\n integrationId,\n entityType: 'products',\n direction: 'import',\n scheduleType: 'cron',\n scheduleValue,\n timezone: 'UTC',\n fullSync: false,\n isEnabled: true,\n },\n })\n}\n\ntest.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule optimistic lock', () => {\n test('WHK-01 stale webhook PUT is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n // Advance updated_at out-of-band via a header-less PUT.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Replay the now-stale write \u2192 409.\n const conflict = await putWithLock(\n page.request,\n token,\n `${WEBHOOKS_API}/${webhookId}`,\n { name: `QA Lock 043 stale ${stamp}` },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('WHK-01 stale webhook DELETE is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n const staleUpdatedAt = webhook.updatedAt\n\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { name: `QA Lock 043 bumped ${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n const conflict = await page.request.fetch(resolveApiUrl(`${WEBHOOKS_API}/${webhookId}`), {\n method: 'DELETE',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n [OPTIMISTIC_LOCK_HEADER_NAME]: staleUpdatedAt,\n },\n })\n await expectConflictBody(conflict)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n // WHK-01 (browser UI): now ACTIVE. The product fix is committed/verified \u2014 the webhooks\n // server DELETE returns the structured 409 (proven by the active \"WHK-01 stale webhook\n // DELETE is refused with a 409\" API test) and the list page sends the lock header\n // (`buildOptimisticLockHeader(row.updatedAt)`) + routes the resulting 409 through\n // surfaceRecordConflict. The RowActions menu opens on CLICK of the \"Open actions\" kebab and\n // renders the items in a portal on document.body (role=\"menuitem\"); the confirm dialog is a\n // role=\"alertdialog\" with a \"Confirm\" button. We drive that choreography robustly here:\n // wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then\n // assert the unified conflict bar (never the success toast).\n test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {\n // Heavy browser flow (login + list load + portalled RowActions menu) routinely\n // exceeds Playwright's 20s default on a loaded ephemeral shard; opt into the\n // sanctioned per-test budget (see TC-LOCK-OSS-029). Global bump is disallowed.\n test.setTimeout(60_000)\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n let webhookId: string | null = null\n try {\n const webhook = await createWebhook(page.request, token, stamp)\n webhookId = webhook.id\n\n await login(page, 'admin')\n await page.goto('/backend/webhooks')\n // Wait for the list GET to settle before interacting. The list reflects the\n // freshly created fixture immediately (newest-first), so a unique name makes\n // the row unambiguous.\n await page\n .waitForResponse(\n (response) =>\n response.url().includes('/api/webhooks') &&\n response.request().method() === 'GET' &&\n response.ok(),\n { timeout: 20_000 },\n )\n .catch(() => undefined)\n\n // The out-of-band PUT below renames the fixture to \"QA Lock 043 bumped <stamp>\";\n // tolerate both names so a mid-test list re-render cannot orphan the row locator.\n const row = page\n .getByRole('row', { name: new RegExp(`QA Lock 043 (?:bumped )?${stamp}\\\\b`) })\n .first()\n await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })\n\n // Advance updated_at out-of-band \u2192 the in-page row token is now stale.\n // NOTE: bump description (not name) so the row locator stays valid even if\n // the list re-fetches after the PUT.\n const bump = await apiRequest(page.request, 'PUT', `${WEBHOOKS_API}/${webhookId}`, {\n token,\n data: { description: `bumped-${stamp}` },\n })\n expect(bump.status(), 'out-of-band webhook PUT should succeed').toBeLessThan(300)\n\n // Open the row's RowActions kebab (opens on click) and trigger Delete. The\n // menu renders in a portal on document.body, so query it at the page level.\n // The list re-renders as data settles, so the menu can detach between \"open\"\n // and \"click\" \u2014 retry (re)open-menu + click-Delete atomically (same fix as\n // TC-LOCK-OSS-029); the confirm dialog gates the DELETE, so a repeated\n // click is safe.\n const kebab = row.getByRole('button', { name: /open actions/i })\n const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })\n await expect(async () => {\n if (!(await deleteItem.isVisible().catch(() => false))) {\n await kebab.click({ timeout: 2_000 }).catch(() => {})\n await expect(deleteItem).toBeVisible({ timeout: 1_500 })\n }\n await deleteItem.click({ timeout: 2_000 })\n }).toPass({ timeout: 30_000 })\n\n // Confirm the destructive alertdialog (the row still holds the stale\n // updated_at captured at render time, so the DELETE 409s).\n const dialog = page.getByRole('alertdialog')\n await expect(dialog, 'confirm dialog should open').toBeVisible({ timeout: 10_000 })\n await dialog.getByRole('button', { name: /^(confirm|delete)$/i }).click()\n\n // The client must route the 409 to the unified conflict bar, never the\n // success toast.\n await expectConflictBanner(page)\n } finally {\n if (webhookId) await deleteWebhook(page.request, token, webhookId)\n }\n })\n\n test('INB-01 stale inbox-settings PATCH is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const before = await readInboxSettings(page.request, token)\n const originalLanguage = before.workingLanguage\n const staleUpdatedAt = before.updatedAt\n const bumpLanguage = originalLanguage === 'de' ? 'en' : 'de'\n const staleLanguage = originalLanguage === 'es' ? 'pl' : 'es'\n try {\n // Advance updated_at out-of-band via a header-less PATCH.\n const bump = await patchInboxSettings(page.request, token, { workingLanguage: bumpLanguage })\n expect(bump.status(), 'out-of-band inbox PATCH should succeed').toBeLessThan(300)\n\n // Replay the now-stale write with the original expected-version \u2192 409.\n const conflict = await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: staleLanguage },\n staleUpdatedAt,\n )\n await expectConflictBody(conflict)\n } finally {\n // Restore the original working language with a fresh lock token.\n const current = await readInboxSettings(page.request, token).catch(() => null)\n if (current) {\n await patchInboxSettings(\n page.request,\n token,\n { workingLanguage: originalLanguage },\n current.updatedAt,\n ).catch(() => undefined)\n }\n }\n })\n\n test('SYNC-01 stale data-sync schedule save is refused with a 409 conflict', async ({ page }) => {\n const token = await getAuthToken(page.request, 'admin')\n const stamp = Date.now()\n const integrationId = `qa-lock-043-${stamp}`\n let scheduleId: string | null = null\n try {\n const created = await postSchedule(page.request, token, '0 * * * *', integrationId)\n expect(created.status(), 'POST schedule should be 201').toBe(201)\n const createdBody = (await created.json()) as { id?: string; updatedAt?: string }\n scheduleId = createdBody.id ?? null\n const staleUpdatedAt = createdBody.updatedAt\n expect(typeof scheduleId, 'schedule creation should return an id').toBe('string')\n expect(typeof staleUpdatedAt, 'schedule should expose updatedAt').toBe('string')\n\n // Advance updated_at out-of-band via the header-less [id] PUT route.\n const bump = await apiRequest(page.request, 'PUT', `${SCHEDULES_API}/${scheduleId}`, {\n token,\n data: { scheduleValue: '5 * * * *' },\n })\n expect(bump.status(), 'out-of-band schedule PUT should succeed').toBeLessThan(300)\n\n // Replay the save for the same (integrationId, entityType, direction) key\n // with the now-stale expected-version \u2192 409.\n const conflict = await postSchedule(\n page.request,\n token,\n '9 * * * *',\n integrationId,\n staleUpdatedAt as string,\n )\n await expectConflictBody(conflict)\n } finally {\n if (scheduleId) {\n await apiRequest(page.request, 'DELETE', `${SCHEDULES_API}/${scheduleId}`, { token }).catch(\n () => undefined,\n )\n }\n }\n })\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { assertStaticallySafeWebhookUrl, UnsafeWebhookUrlError } from "../lib/url-safety.js";
3
+ import { isReservedWebhookCustomHeader } from "../lib/custom-headers.js";
3
4
  const safeWebhookUrl = z.string().url().superRefine((value, ctx) => {
4
5
  try {
5
6
  assertStaticallySafeWebhookUrl(value);
@@ -8,13 +9,24 @@ const safeWebhookUrl = z.string().url().superRefine((value, ctx) => {
8
9
  ctx.addIssue({ code: z.ZodIssueCode.custom, message });
9
10
  }
10
11
  });
12
+ const webhookCustomHeaders = z.record(z.string(), z.string()).superRefine((value, ctx) => {
13
+ for (const name of Object.keys(value)) {
14
+ if (isReservedWebhookCustomHeader(name)) {
15
+ ctx.addIssue({
16
+ code: z.ZodIssueCode.custom,
17
+ message: `Header "${name}" is reserved for webhook signing and cannot be overridden`,
18
+ path: [name]
19
+ });
20
+ }
21
+ }
22
+ });
11
23
  const webhookCreateSchema = z.object({
12
24
  name: z.string().min(1).max(255),
13
25
  description: z.string().max(1e3).optional().nullable(),
14
26
  url: safeWebhookUrl,
15
27
  subscribedEvents: z.array(z.string().min(1)).min(1),
16
28
  httpMethod: z.enum(["POST", "PUT", "PATCH"]).default("POST"),
17
- customHeaders: z.record(z.string(), z.string()).optional().nullable(),
29
+ customHeaders: webhookCustomHeaders.optional().nullable(),
18
30
  deliveryStrategy: z.literal("http").default("http"),
19
31
  strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),
20
32
  maxRetries: z.number().int().min(0).max(30).default(10),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/data/validators.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport { assertStaticallySafeWebhookUrl, UnsafeWebhookUrlError } from '../lib/url-safety'\n\nconst safeWebhookUrl = z.string().url().superRefine((value, ctx) => {\n try {\n assertStaticallySafeWebhookUrl(value)\n } catch (error) {\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL is not allowed'\n ctx.addIssue({ code: z.ZodIssueCode.custom, message })\n }\n})\n\nexport const webhookCreateSchema = z.object({\n name: z.string().min(1).max(255),\n description: z.string().max(1000).optional().nullable(),\n url: safeWebhookUrl,\n subscribedEvents: z.array(z.string().min(1)).min(1),\n httpMethod: z.enum(['POST', 'PUT', 'PATCH'] as const).default('POST'),\n customHeaders: z.record(z.string(), z.string()).optional().nullable(),\n deliveryStrategy: z.literal('http').default('http'),\n strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),\n maxRetries: z.number().int().min(0).max(30).default(10),\n timeoutMs: z.number().int().min(1000).max(60000).default(15000),\n rateLimitPerMinute: z.number().int().min(0).max(10000).default(0),\n autoDisableThreshold: z.number().int().min(0).max(1000).default(100),\n integrationId: z.string().optional().nullable(),\n})\n\nexport type WebhookCreateInput = z.infer<typeof webhookCreateSchema>\n\nexport const webhookUpdateSchema = webhookCreateSchema.partial().extend({\n isActive: z.boolean().optional(),\n})\n\nexport type WebhookUpdateInput = z.infer<typeof webhookUpdateSchema>\n\nexport const webhookListQuerySchema = z.object({\n page: z.string().optional(),\n pageSize: z.string().optional(),\n search: z.string().optional(),\n isActive: z.string().optional(),\n})\n\nexport const webhookDeliveryQuerySchema = z.object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n webhookId: z.string().uuid().optional(),\n eventType: z.string().optional(),\n status: z.enum(['pending', 'sending', 'delivered', 'failed', 'expired'] as const).optional(),\n})\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,gCAAgC,6BAA6B;AAEtE,MAAM,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,OAAO,QAAQ;AAClE,MAAI;AACF,mCAA+B,KAAK;AAAA,EACtC,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,wBAC7B,MAAM,UACN;AACJ,QAAI,SAAS,EAAE,MAAM,EAAE,aAAa,QAAQ,QAAQ,CAAC;AAAA,EACvD;AACF,CAAC;AAEM,MAAM,sBAAsB,EAAE,OAAO;AAAA,EAC1C,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG;AAAA,EAC/B,aAAa,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,SAAS;AAAA,EACtD,KAAK;AAAA,EACL,kBAAkB,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC;AAAA,EAClD,YAAY,EAAE,KAAK,CAAC,QAAQ,OAAO,OAAO,CAAU,EAAE,QAAQ,MAAM;AAAA,EACpE,eAAe,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA,EACpE,kBAAkB,EAAE,QAAQ,MAAM,EAAE,QAAQ,MAAM;AAAA,EAClD,gBAAgB,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA,EACtE,YAAY,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE;AAAA,EACtD,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,GAAI,EAAE,IAAI,GAAK,EAAE,QAAQ,IAAK;AAAA,EAC9D,oBAAoB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAK,EAAE,QAAQ,CAAC;AAAA,EAChE,sBAAsB,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAI,EAAE,QAAQ,GAAG;AAAA,EACnE,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAChD,CAAC;AAIM,MAAM,sBAAsB,oBAAoB,QAAQ,EAAE,OAAO;AAAA,EACtE,UAAU,EAAE,QAAQ,EAAE,SAAS;AACjC,CAAC;AAIM,MAAM,yBAAyB,EAAE,OAAO;AAAA,EAC7C,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA,EAC9B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,UAAU,EAAE,OAAO,EAAE,SAAS;AAChC,CAAC;AAEM,MAAM,6BAA6B,EAAE,OAAO;AAAA,EACjD,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACtC,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,QAAQ,EAAE,KAAK,CAAC,WAAW,WAAW,aAAa,UAAU,SAAS,CAAU,EAAE,SAAS;AAC7F,CAAC;",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport { assertStaticallySafeWebhookUrl, UnsafeWebhookUrlError } from '../lib/url-safety'\nimport { isReservedWebhookCustomHeader } from '../lib/custom-headers'\n\nconst safeWebhookUrl = z.string().url().superRefine((value, ctx) => {\n try {\n assertStaticallySafeWebhookUrl(value)\n } catch (error) {\n const message = error instanceof UnsafeWebhookUrlError\n ? error.message\n : 'Webhook URL is not allowed'\n ctx.addIssue({ code: z.ZodIssueCode.custom, message })\n }\n})\n\nconst webhookCustomHeaders = z.record(z.string(), z.string()).superRefine((value, ctx) => {\n for (const name of Object.keys(value)) {\n if (isReservedWebhookCustomHeader(name)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Header \"${name}\" is reserved for webhook signing and cannot be overridden`,\n path: [name],\n })\n }\n }\n})\n\nexport const webhookCreateSchema = z.object({\n name: z.string().min(1).max(255),\n description: z.string().max(1000).optional().nullable(),\n url: safeWebhookUrl,\n subscribedEvents: z.array(z.string().min(1)).min(1),\n httpMethod: z.enum(['POST', 'PUT', 'PATCH'] as const).default('POST'),\n customHeaders: webhookCustomHeaders.optional().nullable(),\n deliveryStrategy: z.literal('http').default('http'),\n strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),\n maxRetries: z.number().int().min(0).max(30).default(10),\n timeoutMs: z.number().int().min(1000).max(60000).default(15000),\n rateLimitPerMinute: z.number().int().min(0).max(10000).default(0),\n autoDisableThreshold: z.number().int().min(0).max(1000).default(100),\n integrationId: z.string().optional().nullable(),\n})\n\nexport type WebhookCreateInput = z.infer<typeof webhookCreateSchema>\n\nexport const webhookUpdateSchema = webhookCreateSchema.partial().extend({\n isActive: z.boolean().optional(),\n})\n\nexport type WebhookUpdateInput = z.infer<typeof webhookUpdateSchema>\n\nexport const webhookListQuerySchema = z.object({\n page: z.string().optional(),\n pageSize: z.string().optional(),\n search: z.string().optional(),\n isActive: z.string().optional(),\n})\n\nexport const webhookDeliveryQuerySchema = z.object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n webhookId: z.string().uuid().optional(),\n eventType: z.string().optional(),\n status: z.enum(['pending', 'sending', 'delivered', 'failed', 'expired'] as const).optional(),\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,17 @@
1
+ const RESERVED_HEADER_PREFIX = "webhook-";
2
+ const RESERVED_HEADER_NAMES = /* @__PURE__ */ new Set(["content-type"]);
3
+ function isReservedWebhookCustomHeader(name) {
4
+ const normalized = name.trim().toLowerCase();
5
+ return normalized.startsWith(RESERVED_HEADER_PREFIX) || RESERVED_HEADER_NAMES.has(normalized);
6
+ }
7
+ function sanitizeWebhookCustomHeaders(customHeaders) {
8
+ if (!customHeaders) return {};
9
+ return Object.fromEntries(
10
+ Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name))
11
+ );
12
+ }
13
+ export {
14
+ isReservedWebhookCustomHeader,
15
+ sanitizeWebhookCustomHeaders
16
+ };
17
+ //# sourceMappingURL=custom-headers.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/webhooks/lib/custom-headers.ts"],
4
+ "sourcesContent": ["const RESERVED_HEADER_PREFIX = 'webhook-'\nconst RESERVED_HEADER_NAMES = new Set(['content-type'])\n\nexport function isReservedWebhookCustomHeader(name: string): boolean {\n const normalized = name.trim().toLowerCase()\n return normalized.startsWith(RESERVED_HEADER_PREFIX) || RESERVED_HEADER_NAMES.has(normalized)\n}\n\nexport function sanitizeWebhookCustomHeaders(\n customHeaders: Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!customHeaders) return {}\n return Object.fromEntries(\n Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name)),\n )\n}\n"],
5
+ "mappings": "AAAA,MAAM,yBAAyB;AAC/B,MAAM,wBAAwB,oBAAI,IAAI,CAAC,cAAc,CAAC;AAE/C,SAAS,8BAA8B,MAAuB;AACnE,QAAM,aAAa,KAAK,KAAK,EAAE,YAAY;AAC3C,SAAO,WAAW,WAAW,sBAAsB,KAAK,sBAAsB,IAAI,UAAU;AAC9F;AAEO,SAAS,6BACd,eACwB;AACxB,MAAI,CAAC,cAAe,QAAO,CAAC;AAC5B,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,8BAA8B,IAAI,CAAC;AAAA,EACvF;AACF;",
6
+ "names": []
7
+ }
@@ -4,6 +4,7 @@ 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 { sanitizeWebhookCustomHeaders } from "./custom-headers.js";
7
8
  import {
8
9
  assertSafeWebhookDeliveryUrl,
9
10
  safeWebhookFetch,
@@ -121,8 +122,8 @@ async function processWebhookDeliveryJob(em, job, options = {}) {
121
122
  redirect: "manual",
122
123
  headers: {
123
124
  "content-type": "application/json",
124
- ...headers,
125
- ...webhook.customHeaders ?? {}
125
+ ...sanitizeWebhookCustomHeaders(webhook.customHeaders),
126
+ ...headers
126
127
  },
127
128
  body,
128
129
  signal: controller.signal
@@ -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 {\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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",
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 { sanitizeWebhookCustomHeaders } from './custom-headers'\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 ...sanitizeWebhookCustomHeaders(webhook.customHeaders),\n ...headers,\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
  }
@@ -110,8 +110,7 @@ function IntegrationSetupWidget({ context }) {
110
110
  "webhooks.integrationSetup.summary",
111
111
  "Manage Custom Webhooks from the dedicated webhook screen. Turning this integration off blocks outbound deliveries, test sends, retries, and inbound webhook receives."
112
112
  ) }),
113
- /* @__PURE__ */ jsxs(Alert, { children: [
114
- /* @__PURE__ */ jsx(Webhook, { className: "h-4 w-4" }),
113
+ /* @__PURE__ */ jsxs(Alert, { icon: /* @__PURE__ */ jsx(Webhook, { "aria-hidden": "true" }), children: [
115
114
  /* @__PURE__ */ jsx(AlertTitle, { children: isEnabled ? t("webhooks.integrationSetup.enabledTitle", "Delivery processing is enabled") : t("webhooks.integrationSetup.disabledTitle", "Delivery processing is disabled") }),
116
115
  /* @__PURE__ */ jsx(AlertDescription, { children: isEnabled ? t("webhooks.integrationSetup.enabledBody", "Your saved webhooks can send and receive traffic. Use the links below to review endpoints and open the delivery log on each webhook detail page.") : t("webhooks.integrationSetup.disabledBody", "The integration switch is off, so webhook sends, retries, test deliveries, and inbound receives are blocked until you enable it again.") })
117
116
  ] }),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/webhooks/widgets/injection/integration-setup/widget.client.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ExternalLink, PencilLine, Plus, Webhook } from 'lucide-react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { Switch } from '@open-mercato/ui/primitives/switch'\nimport { webhookCustomIntegrationId } from '../../../integration'\n\ntype WebhookIntegrationContext = {\n state?: {\n isEnabled?: boolean\n } | null\n}\n\ntype WebhookListItem = {\n id: string\n name: string\n url: string\n isActive: boolean\n subscribedEvents: string[]\n}\n\ntype WebhookListResponse = {\n items: WebhookListItem[]\n total: number\n}\n\ntype WebhookIntegrationCredentials = Record<string, string | number | boolean | null> & {\n notifyOnFailedDelivery?: boolean\n}\n\ntype WebhookCredentialsResponse = {\n credentials: WebhookIntegrationCredentials\n}\n\nexport default function IntegrationSetupWidget({ context }: InjectionWidgetComponentProps) {\n const t = useT()\n const typedContext = context as WebhookIntegrationContext | undefined\n const [items, setItems] = React.useState<WebhookListItem[]>([])\n const [total, setTotal] = React.useState(0)\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [credentials, setCredentials] = React.useState<WebhookIntegrationCredentials>({})\n const [settingsError, setSettingsError] = React.useState<string | null>(null)\n const [isSavingSettings, setIsSavingSettings] = React.useState(false)\n\n React.useEffect(() => {\n let mounted = true\n\n const load = async () => {\n setIsLoading(true)\n setError(null)\n\n try {\n const [listCall, credentialsCall] = await Promise.all([\n apiCall<WebhookListResponse>(\n '/api/webhooks?page=1&pageSize=6',\n undefined,\n { fallback: { items: [], total: 0 } },\n ),\n apiCall<WebhookCredentialsResponse>(\n `/api/integrations/${encodeURIComponent(webhookCustomIntegrationId)}/credentials`,\n undefined,\n { fallback: null },\n ),\n ])\n\n if (!mounted) return\n\n if (!listCall.ok || !listCall.result) {\n setError(t('webhooks.integrationSetup.loadError', 'Failed to load configured webhooks.'))\n setItems([])\n setTotal(0)\n } else {\n setItems(listCall.result.items)\n setTotal(listCall.result.total)\n }\n\n if (credentialsCall.ok && credentialsCall.result?.credentials) {\n setCredentials(credentialsCall.result.credentials)\n setSettingsError(null)\n } else {\n setSettingsError(t('webhooks.integrationSetup.settingsLoadError', 'Failed to load webhook integration settings.'))\n }\n } catch {\n if (!mounted) return\n setError(t('webhooks.integrationSetup.loadError', 'Failed to load configured webhooks.'))\n setItems([])\n setTotal(0)\n setSettingsError(t('webhooks.integrationSetup.settingsLoadError', 'Failed to load webhook integration settings.'))\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n\n void load()\n\n return () => {\n mounted = false\n }\n }, [t])\n\n const isEnabled = typedContext?.state?.isEnabled !== false\n const notifyOnFailedDelivery = credentials.notifyOnFailedDelivery === true\n\n const handleToggleFailedDeliveryNotifications = React.useCallback(async (nextValue: boolean) => {\n const nextCredentials: WebhookIntegrationCredentials = {\n ...credentials,\n notifyOnFailedDelivery: nextValue,\n }\n\n setCredentials(nextCredentials)\n setIsSavingSettings(true)\n setSettingsError(null)\n\n try {\n const call = await apiCall(\n `/api/integrations/${encodeURIComponent(webhookCustomIntegrationId)}/credentials`,\n {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ credentials: nextCredentials }),\n },\n { fallback: null },\n )\n\n if (!call.ok) {\n setCredentials(credentials)\n setSettingsError(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'))\n flash(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'), 'error')\n return\n }\n\n flash(t('webhooks.integrationSetup.settingsSaveSuccess', 'Webhook integration settings saved.'), 'success')\n } catch {\n setCredentials(credentials)\n setSettingsError(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'))\n flash(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'), 'error')\n } finally {\n setIsSavingSettings(false)\n }\n }, [credentials, t])\n\n return (\n <div className=\"space-y-4\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'webhooks.integrationSetup.summary',\n 'Manage Custom Webhooks from the dedicated webhook screen. Turning this integration off blocks outbound deliveries, test sends, retries, and inbound webhook receives.',\n )}\n </p>\n\n <Alert>\n <Webhook className=\"h-4 w-4\" />\n <AlertTitle>\n {isEnabled\n ? t('webhooks.integrationSetup.enabledTitle', 'Delivery processing is enabled')\n : t('webhooks.integrationSetup.disabledTitle', 'Delivery processing is disabled')}\n </AlertTitle>\n <AlertDescription>\n {isEnabled\n ? t('webhooks.integrationSetup.enabledBody', 'Your saved webhooks can send and receive traffic. Use the links below to review endpoints and open the delivery log on each webhook detail page.')\n : t('webhooks.integrationSetup.disabledBody', 'The integration switch is off, so webhook sends, retries, test deliveries, and inbound receives are blocked until you enable it again.')}\n </AlertDescription>\n </Alert>\n\n <div className=\"flex flex-wrap gap-3\">\n <Button asChild>\n <Link href=\"/backend/webhooks\">\n <ExternalLink className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.openList', 'Open webhook list')}\n </Link>\n </Button>\n <Button asChild variant=\"outline\">\n <Link href=\"/backend/webhooks/create\">\n <Plus className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.create', 'Create webhook')}\n </Link>\n </Button>\n </div>\n\n <div className=\"rounded-lg border bg-muted/30 p-4\">\n <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between\">\n <div className=\"space-y-1\">\n <h4 className=\"text-sm font-semibold\">\n {t('webhooks.integrationSetup.notificationsTitle', 'Failed delivery notifications')}\n </h4>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'webhooks.integrationSetup.notificationsBody',\n 'Notify admin users when a webhook delivery finally fails after retries are exhausted.',\n )}\n </p>\n </div>\n <div className=\"flex items-center gap-3\">\n <Badge variant={notifyOnFailedDelivery ? 'default' : 'outline'}>\n {notifyOnFailedDelivery\n ? t('webhooks.integrationSetup.notificationsEnabled', 'Enabled')\n : t('webhooks.integrationSetup.notificationsDisabled', 'Disabled')}\n </Badge>\n <Switch\n checked={notifyOnFailedDelivery}\n disabled={isSavingSettings}\n onCheckedChange={(checked) => { void handleToggleFailedDeliveryNotifications(checked) }}\n />\n </div>\n </div>\n {settingsError ? (\n <div className=\"mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive\">\n {settingsError}\n </div>\n ) : null}\n </div>\n\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between gap-3\">\n <h4 className=\"text-sm font-semibold\">\n {t('webhooks.integrationSetup.configuredTitle', 'Configured webhooks')}\n </h4>\n {total > items.length ? (\n <span className=\"text-xs text-muted-foreground\">\n {t('webhooks.integrationSetup.more', 'Showing {shown} of {total}.', {\n shown: String(items.length),\n total: String(total),\n })}\n </span>\n ) : null}\n </div>\n\n {isLoading ? (\n <div className=\"flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-3 text-sm text-muted-foreground\">\n <Spinner className=\"h-4 w-4\" />\n <span>{t('webhooks.integrationSetup.loading', 'Loading configured webhooks...')}</span>\n </div>\n ) : null}\n\n {!isLoading && error ? (\n <div className=\"rounded-md border border-destructive/30 bg-destructive/5 px-3 py-3 text-sm text-destructive\">\n {error}\n </div>\n ) : null}\n\n {!isLoading && !error && items.length === 0 ? (\n <div className=\"rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground\">\n {t('webhooks.integrationSetup.empty', 'No webhooks have been configured yet. Create one to start sending or receiving events.')}\n </div>\n ) : null}\n\n {!isLoading && !error && items.length > 0 ? (\n <div className=\"space-y-2\">\n {items.map((item) => (\n <div\n key={item.id}\n className=\"flex flex-col gap-3 rounded-lg border bg-muted/30 px-3 py-3 lg:flex-row lg:items-center lg:justify-between\"\n >\n <div className=\"min-w-0 space-y-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-medium text-foreground\">{item.name}</span>\n <Badge variant={item.isActive ? 'default' : 'outline'}>\n {item.isActive\n ? t('webhooks.list.status.active', 'Active')\n : t('webhooks.list.status.inactive', 'Inactive')}\n </Badge>\n <span className=\"text-xs text-muted-foreground\">\n {t('webhooks.integrationSetup.eventCount', '{count} event patterns', {\n count: String(item.subscribedEvents.length),\n })}\n </span>\n </div>\n <code className=\"block truncate text-xs text-muted-foreground\">{item.url}</code>\n </div>\n\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"w-fit\">\n <Link href={`/backend/webhooks/${encodeURIComponent(item.id)}`}>\n <PencilLine className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.edit', 'Edit webhook')}\n </Link>\n </Button>\n </div>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ExternalLink, PencilLine, Plus, Webhook } from 'lucide-react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\nimport { Switch } from '@open-mercato/ui/primitives/switch'\nimport { webhookCustomIntegrationId } from '../../../integration'\n\ntype WebhookIntegrationContext = {\n state?: {\n isEnabled?: boolean\n } | null\n}\n\ntype WebhookListItem = {\n id: string\n name: string\n url: string\n isActive: boolean\n subscribedEvents: string[]\n}\n\ntype WebhookListResponse = {\n items: WebhookListItem[]\n total: number\n}\n\ntype WebhookIntegrationCredentials = Record<string, string | number | boolean | null> & {\n notifyOnFailedDelivery?: boolean\n}\n\ntype WebhookCredentialsResponse = {\n credentials: WebhookIntegrationCredentials\n}\n\nexport default function IntegrationSetupWidget({ context }: InjectionWidgetComponentProps) {\n const t = useT()\n const typedContext = context as WebhookIntegrationContext | undefined\n const [items, setItems] = React.useState<WebhookListItem[]>([])\n const [total, setTotal] = React.useState(0)\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [credentials, setCredentials] = React.useState<WebhookIntegrationCredentials>({})\n const [settingsError, setSettingsError] = React.useState<string | null>(null)\n const [isSavingSettings, setIsSavingSettings] = React.useState(false)\n\n React.useEffect(() => {\n let mounted = true\n\n const load = async () => {\n setIsLoading(true)\n setError(null)\n\n try {\n const [listCall, credentialsCall] = await Promise.all([\n apiCall<WebhookListResponse>(\n '/api/webhooks?page=1&pageSize=6',\n undefined,\n { fallback: { items: [], total: 0 } },\n ),\n apiCall<WebhookCredentialsResponse>(\n `/api/integrations/${encodeURIComponent(webhookCustomIntegrationId)}/credentials`,\n undefined,\n { fallback: null },\n ),\n ])\n\n if (!mounted) return\n\n if (!listCall.ok || !listCall.result) {\n setError(t('webhooks.integrationSetup.loadError', 'Failed to load configured webhooks.'))\n setItems([])\n setTotal(0)\n } else {\n setItems(listCall.result.items)\n setTotal(listCall.result.total)\n }\n\n if (credentialsCall.ok && credentialsCall.result?.credentials) {\n setCredentials(credentialsCall.result.credentials)\n setSettingsError(null)\n } else {\n setSettingsError(t('webhooks.integrationSetup.settingsLoadError', 'Failed to load webhook integration settings.'))\n }\n } catch {\n if (!mounted) return\n setError(t('webhooks.integrationSetup.loadError', 'Failed to load configured webhooks.'))\n setItems([])\n setTotal(0)\n setSettingsError(t('webhooks.integrationSetup.settingsLoadError', 'Failed to load webhook integration settings.'))\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n\n void load()\n\n return () => {\n mounted = false\n }\n }, [t])\n\n const isEnabled = typedContext?.state?.isEnabled !== false\n const notifyOnFailedDelivery = credentials.notifyOnFailedDelivery === true\n\n const handleToggleFailedDeliveryNotifications = React.useCallback(async (nextValue: boolean) => {\n const nextCredentials: WebhookIntegrationCredentials = {\n ...credentials,\n notifyOnFailedDelivery: nextValue,\n }\n\n setCredentials(nextCredentials)\n setIsSavingSettings(true)\n setSettingsError(null)\n\n try {\n const call = await apiCall(\n `/api/integrations/${encodeURIComponent(webhookCustomIntegrationId)}/credentials`,\n {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ credentials: nextCredentials }),\n },\n { fallback: null },\n )\n\n if (!call.ok) {\n setCredentials(credentials)\n setSettingsError(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'))\n flash(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'), 'error')\n return\n }\n\n flash(t('webhooks.integrationSetup.settingsSaveSuccess', 'Webhook integration settings saved.'), 'success')\n } catch {\n setCredentials(credentials)\n setSettingsError(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'))\n flash(t('webhooks.integrationSetup.settingsSaveError', 'Failed to save webhook integration settings.'), 'error')\n } finally {\n setIsSavingSettings(false)\n }\n }, [credentials, t])\n\n return (\n <div className=\"space-y-4\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'webhooks.integrationSetup.summary',\n 'Manage Custom Webhooks from the dedicated webhook screen. Turning this integration off blocks outbound deliveries, test sends, retries, and inbound webhook receives.',\n )}\n </p>\n\n <Alert icon={<Webhook aria-hidden=\"true\" />}>\n <AlertTitle>\n {isEnabled\n ? t('webhooks.integrationSetup.enabledTitle', 'Delivery processing is enabled')\n : t('webhooks.integrationSetup.disabledTitle', 'Delivery processing is disabled')}\n </AlertTitle>\n <AlertDescription>\n {isEnabled\n ? t('webhooks.integrationSetup.enabledBody', 'Your saved webhooks can send and receive traffic. Use the links below to review endpoints and open the delivery log on each webhook detail page.')\n : t('webhooks.integrationSetup.disabledBody', 'The integration switch is off, so webhook sends, retries, test deliveries, and inbound receives are blocked until you enable it again.')}\n </AlertDescription>\n </Alert>\n\n <div className=\"flex flex-wrap gap-3\">\n <Button asChild>\n <Link href=\"/backend/webhooks\">\n <ExternalLink className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.openList', 'Open webhook list')}\n </Link>\n </Button>\n <Button asChild variant=\"outline\">\n <Link href=\"/backend/webhooks/create\">\n <Plus className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.create', 'Create webhook')}\n </Link>\n </Button>\n </div>\n\n <div className=\"rounded-lg border bg-muted/30 p-4\">\n <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between\">\n <div className=\"space-y-1\">\n <h4 className=\"text-sm font-semibold\">\n {t('webhooks.integrationSetup.notificationsTitle', 'Failed delivery notifications')}\n </h4>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'webhooks.integrationSetup.notificationsBody',\n 'Notify admin users when a webhook delivery finally fails after retries are exhausted.',\n )}\n </p>\n </div>\n <div className=\"flex items-center gap-3\">\n <Badge variant={notifyOnFailedDelivery ? 'default' : 'outline'}>\n {notifyOnFailedDelivery\n ? t('webhooks.integrationSetup.notificationsEnabled', 'Enabled')\n : t('webhooks.integrationSetup.notificationsDisabled', 'Disabled')}\n </Badge>\n <Switch\n checked={notifyOnFailedDelivery}\n disabled={isSavingSettings}\n onCheckedChange={(checked) => { void handleToggleFailedDeliveryNotifications(checked) }}\n />\n </div>\n </div>\n {settingsError ? (\n <div className=\"mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive\">\n {settingsError}\n </div>\n ) : null}\n </div>\n\n <div className=\"space-y-3\">\n <div className=\"flex items-center justify-between gap-3\">\n <h4 className=\"text-sm font-semibold\">\n {t('webhooks.integrationSetup.configuredTitle', 'Configured webhooks')}\n </h4>\n {total > items.length ? (\n <span className=\"text-xs text-muted-foreground\">\n {t('webhooks.integrationSetup.more', 'Showing {shown} of {total}.', {\n shown: String(items.length),\n total: String(total),\n })}\n </span>\n ) : null}\n </div>\n\n {isLoading ? (\n <div className=\"flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-3 text-sm text-muted-foreground\">\n <Spinner className=\"h-4 w-4\" />\n <span>{t('webhooks.integrationSetup.loading', 'Loading configured webhooks...')}</span>\n </div>\n ) : null}\n\n {!isLoading && error ? (\n <div className=\"rounded-md border border-destructive/30 bg-destructive/5 px-3 py-3 text-sm text-destructive\">\n {error}\n </div>\n ) : null}\n\n {!isLoading && !error && items.length === 0 ? (\n <div className=\"rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground\">\n {t('webhooks.integrationSetup.empty', 'No webhooks have been configured yet. Create one to start sending or receiving events.')}\n </div>\n ) : null}\n\n {!isLoading && !error && items.length > 0 ? (\n <div className=\"space-y-2\">\n {items.map((item) => (\n <div\n key={item.id}\n className=\"flex flex-col gap-3 rounded-lg border bg-muted/30 px-3 py-3 lg:flex-row lg:items-center lg:justify-between\"\n >\n <div className=\"min-w-0 space-y-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-medium text-foreground\">{item.name}</span>\n <Badge variant={item.isActive ? 'default' : 'outline'}>\n {item.isActive\n ? t('webhooks.list.status.active', 'Active')\n : t('webhooks.list.status.inactive', 'Inactive')}\n </Badge>\n <span className=\"text-xs text-muted-foreground\">\n {t('webhooks.integrationSetup.eventCount', '{count} event patterns', {\n count: String(item.subscribedEvents.length),\n })}\n </span>\n </div>\n <code className=\"block truncate text-xs text-muted-foreground\">{item.url}</code>\n </div>\n\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"w-fit\">\n <Link href={`/backend/webhooks/${encodeURIComponent(item.id)}`}>\n <PencilLine className=\"mr-2 h-4 w-4\" />\n {t('webhooks.integrationSetup.actions.edit', 'Edit webhook')}\n </Link>\n </Button>\n </div>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],
5
+ "mappings": ";AAyJM,cAOA,YAPA;AAvJN,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,cAAc,YAAY,MAAM,eAAe;AAExD,SAAS,YAAY;AACrB,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,OAAO,kBAAkB,kBAAkB;AACpD,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,cAAc;AACvB,SAAS,kCAAkC;AA6B5B,SAAR,uBAAwC,EAAE,QAAQ,GAAkC;AACzF,QAAM,IAAI,KAAK;AACf,QAAM,eAAe;AACrB,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAA4B,CAAC,CAAC;AAC9D,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,CAAC;AAC1C,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAwC,CAAC,CAAC;AACtF,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAwB,IAAI;AAC5E,QAAM,CAAC,kBAAkB,mBAAmB,IAAI,MAAM,SAAS,KAAK;AAEpE,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU;AAEd,UAAM,OAAO,YAAY;AACvB,mBAAa,IAAI;AACjB,eAAS,IAAI;AAEb,UAAI;AACF,cAAM,CAAC,UAAU,eAAe,IAAI,MAAM,QAAQ,IAAI;AAAA,UACpD;AAAA,YACE;AAAA,YACA;AAAA,YACA,EAAE,UAAU,EAAE,OAAO,CAAC,GAAG,OAAO,EAAE,EAAE;AAAA,UACtC;AAAA,UACA;AAAA,YACE,qBAAqB,mBAAmB,0BAA0B,CAAC;AAAA,YACnE;AAAA,YACA,EAAE,UAAU,KAAK;AAAA,UACnB;AAAA,QACF,CAAC;AAED,YAAI,CAAC,QAAS;AAEd,YAAI,CAAC,SAAS,MAAM,CAAC,SAAS,QAAQ;AACpC,mBAAS,EAAE,uCAAuC,qCAAqC,CAAC;AACxF,mBAAS,CAAC,CAAC;AACX,mBAAS,CAAC;AAAA,QACZ,OAAO;AACL,mBAAS,SAAS,OAAO,KAAK;AAC9B,mBAAS,SAAS,OAAO,KAAK;AAAA,QAChC;AAEA,YAAI,gBAAgB,MAAM,gBAAgB,QAAQ,aAAa;AAC7D,yBAAe,gBAAgB,OAAO,WAAW;AACjD,2BAAiB,IAAI;AAAA,QACvB,OAAO;AACL,2BAAiB,EAAE,+CAA+C,8CAA8C,CAAC;AAAA,QACnH;AAAA,MACF,QAAQ;AACN,YAAI,CAAC,QAAS;AACd,iBAAS,EAAE,uCAAuC,qCAAqC,CAAC;AACxF,iBAAS,CAAC,CAAC;AACX,iBAAS,CAAC;AACV,yBAAiB,EAAE,+CAA+C,8CAA8C,CAAC;AAAA,MACnH,UAAE;AACA,YAAI,QAAS,cAAa,KAAK;AAAA,MACjC;AAAA,IACF;AAEA,SAAK,KAAK;AAEV,WAAO,MAAM;AACX,gBAAU;AAAA,IACZ;AAAA,EACF,GAAG,CAAC,CAAC,CAAC;AAEN,QAAM,YAAY,cAAc,OAAO,cAAc;AACrD,QAAM,yBAAyB,YAAY,2BAA2B;AAEtE,QAAM,0CAA0C,MAAM,YAAY,OAAO,cAAuB;AAC9F,UAAM,kBAAiD;AAAA,MACrD,GAAG;AAAA,MACH,wBAAwB;AAAA,IAC1B;AAEA,mBAAe,eAAe;AAC9B,wBAAoB,IAAI;AACxB,qBAAiB,IAAI;AAErB,QAAI;AACF,YAAM,OAAO,MAAM;AAAA,QACjB,qBAAqB,mBAAmB,0BAA0B,CAAC;AAAA,QACnE;AAAA,UACE,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,aAAa,gBAAgB,CAAC;AAAA,QACvD;AAAA,QACA,EAAE,UAAU,KAAK;AAAA,MACnB;AAEA,UAAI,CAAC,KAAK,IAAI;AACZ,uBAAe,WAAW;AAC1B,yBAAiB,EAAE,+CAA+C,8CAA8C,CAAC;AACjH,cAAM,EAAE,+CAA+C,8CAA8C,GAAG,OAAO;AAC/G;AAAA,MACF;AAEA,YAAM,EAAE,iDAAiD,qCAAqC,GAAG,SAAS;AAAA,IAC5G,QAAQ;AACN,qBAAe,WAAW;AAC1B,uBAAiB,EAAE,+CAA+C,8CAA8C,CAAC;AACjH,YAAM,EAAE,+CAA+C,8CAA8C,GAAG,OAAO;AAAA,IACjH,UAAE;AACA,0BAAoB,KAAK;AAAA,IAC3B;AAAA,EACF,GAAG,CAAC,aAAa,CAAC,CAAC;AAEnB,SACE,qBAAC,SAAI,WAAU,aACb;AAAA,wBAAC,OAAE,WAAU,iCACV;AAAA,MACC;AAAA,MACA;AAAA,IACF,GACF;AAAA,IAEA,qBAAC,SAAM,MAAM,oBAAC,WAAQ,eAAY,QAAO,GACvC;AAAA,0BAAC,cACE,sBACG,EAAE,0CAA0C,gCAAgC,IAC5E,EAAE,2CAA2C,iCAAiC,GACpF;AAAA,MACA,oBAAC,oBACE,sBACG,EAAE,yCAAyC,kJAAkJ,IAC7L,EAAE,0CAA0C,wIAAwI,GAC1L;AAAA,OACF;AAAA,IAEA,qBAAC,SAAI,WAAU,wBACb;AAAA,0BAAC,UAAO,SAAO,MACb,+BAAC,QAAK,MAAK,qBACT;AAAA,4BAAC,gBAAa,WAAU,gBAAe;AAAA,QACtC,EAAE,8CAA8C,mBAAmB;AAAA,SACtE,GACF;AAAA,MACA,oBAAC,UAAO,SAAO,MAAC,SAAQ,WACtB,+BAAC,QAAK,MAAK,4BACT;AAAA,4BAAC,QAAK,WAAU,gBAAe;AAAA,QAC9B,EAAE,4CAA4C,gBAAgB;AAAA,SACjE,GACF;AAAA,OACF;AAAA,IAEA,qBAAC,SAAI,WAAU,qCACb;AAAA,2BAAC,SAAI,WAAU,sEACb;AAAA,6BAAC,SAAI,WAAU,aACb;AAAA,8BAAC,QAAG,WAAU,yBACX,YAAE,gDAAgD,+BAA+B,GACpF;AAAA,UACA,oBAAC,OAAE,WAAU,iCACV;AAAA,YACC;AAAA,YACA;AAAA,UACF,GACF;AAAA,WACF;AAAA,QACA,qBAAC,SAAI,WAAU,2BACb;AAAA,8BAAC,SAAM,SAAS,yBAAyB,YAAY,WAClD,mCACG,EAAE,kDAAkD,SAAS,IAC7D,EAAE,mDAAmD,UAAU,GACrE;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,SAAS;AAAA,cACT,UAAU;AAAA,cACV,iBAAiB,CAAC,YAAY;AAAE,qBAAK,wCAAwC,OAAO;AAAA,cAAE;AAAA;AAAA,UACxF;AAAA,WACF;AAAA,SACF;AAAA,MACC,gBACC,oBAAC,SAAI,WAAU,oGACZ,yBACH,IACE;AAAA,OACN;AAAA,IAEA,qBAAC,SAAI,WAAU,aACb;AAAA,2BAAC,SAAI,WAAU,2CACb;AAAA,4BAAC,QAAG,WAAU,yBACX,YAAE,6CAA6C,qBAAqB,GACvE;AAAA,QACC,QAAQ,MAAM,SACb,oBAAC,UAAK,WAAU,iCACb,YAAE,kCAAkC,+BAA+B;AAAA,UAClE,OAAO,OAAO,MAAM,MAAM;AAAA,UAC1B,OAAO,OAAO,KAAK;AAAA,QACrB,CAAC,GACH,IACE;AAAA,SACN;AAAA,MAEC,YACC,qBAAC,SAAI,WAAU,iGACb;AAAA,4BAAC,WAAQ,WAAU,WAAU;AAAA,QAC7B,oBAAC,UAAM,YAAE,qCAAqC,gCAAgC,GAAE;AAAA,SAClF,IACE;AAAA,MAEH,CAAC,aAAa,QACb,oBAAC,SAAI,WAAU,+FACZ,iBACH,IACE;AAAA,MAEH,CAAC,aAAa,CAAC,SAAS,MAAM,WAAW,IACxC,oBAAC,SAAI,WAAU,2EACZ,YAAE,mCAAmC,wFAAwF,GAChI,IACE;AAAA,MAEH,CAAC,aAAa,CAAC,SAAS,MAAM,SAAS,IACtC,oBAAC,SAAI,WAAU,aACZ,gBAAM,IAAI,CAAC,SACV;AAAA,QAAC;AAAA;AAAA,UAEC,WAAU;AAAA,UAEV;AAAA,iCAAC,SAAI,WAAU,qBACb;AAAA,mCAAC,SAAI,WAAU,qCACb;AAAA,oCAAC,UAAK,WAAU,+BAA+B,eAAK,MAAK;AAAA,gBACzD,oBAAC,SAAM,SAAS,KAAK,WAAW,YAAY,WACzC,eAAK,WACF,EAAE,+BAA+B,QAAQ,IACzC,EAAE,iCAAiC,UAAU,GACnD;AAAA,gBACA,oBAAC,UAAK,WAAU,iCACb,YAAE,wCAAwC,0BAA0B;AAAA,kBACnE,OAAO,OAAO,KAAK,iBAAiB,MAAM;AAAA,gBAC5C,CAAC,GACH;AAAA,iBACF;AAAA,cACA,oBAAC,UAAK,WAAU,gDAAgD,eAAK,KAAI;AAAA,eAC3E;AAAA,YAEA,oBAAC,UAAO,SAAO,MAAC,SAAQ,SAAQ,MAAK,MAAK,WAAU,SAClD,+BAAC,QAAK,MAAM,qBAAqB,mBAAmB,KAAK,EAAE,CAAC,IAC1D;AAAA,kCAAC,cAAW,WAAU,gBAAe;AAAA,cACpC,EAAE,0CAA0C,cAAc;AAAA,eAC7D,GACF;AAAA;AAAA;AAAA,QAzBK,KAAK;AAAA,MA0BZ,CACD,GACH,IACE;AAAA,OACN;AAAA,KACF;AAEJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.6.5-develop.5382.1.f542de69af",
3
+ "version": "0.6.5",
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,23 +69,23 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@open-mercato/core": "0.6.5-develop.5382.1.f542de69af",
73
- "@open-mercato/queue": "0.6.5-develop.5382.1.f542de69af",
74
- "@open-mercato/ui": "0.6.5-develop.5382.1.f542de69af",
72
+ "@open-mercato/core": "0.6.5",
73
+ "@open-mercato/queue": "0.6.5",
74
+ "@open-mercato/ui": "0.6.5",
75
75
  "svix": "^1.95.2"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@mikro-orm/postgresql": "^7.0.14",
79
- "@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
79
+ "@open-mercato/shared": "0.6.5",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
84
+ "@open-mercato/shared": "0.6.5",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.17",
87
87
  "@types/react-dom": "^19.2.3",
88
- "esbuild": "^0.28.0",
88
+ "esbuild": "^0.28.1",
89
89
  "glob": "^13.0.6",
90
90
  "jest": "^30.4.2",
91
91
  "react": "19.2.7",
@@ -99,6 +99,5 @@
99
99
  "type": "git",
100
100
  "url": "https://github.com/open-mercato/open-mercato",
101
101
  "directory": "packages/webhooks"
102
- },
103
- "stableVersion": "0.6.4"
102
+ }
104
103
  }
@@ -220,9 +220,12 @@ test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
220
220
  // wait for the filtered list GET to settle, open the kebab, click Delete, confirm, then
221
221
  // assert the unified conflict bar (never the success toast).
222
222
  test('WHK-01 stale webhook DELETE in the list surfaces the conflict bar', async ({ page }) => {
223
+ // Heavy browser flow (login + list load + portalled RowActions menu) routinely
224
+ // exceeds Playwright's 20s default on a loaded ephemeral shard; opt into the
225
+ // sanctioned per-test budget (see TC-LOCK-OSS-029). Global bump is disallowed.
226
+ test.setTimeout(60_000)
223
227
  const token = await getAuthToken(page.request, 'admin')
224
228
  const stamp = Date.now()
225
- const webhookName = `QA Lock 043 ${stamp}`
226
229
  let webhookId: string | null = null
227
230
  try {
228
231
  const webhook = await createWebhook(page.request, token, stamp)
@@ -243,7 +246,11 @@ test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
243
246
  )
244
247
  .catch(() => undefined)
245
248
 
246
- const row = page.getByRole('row', { name: new RegExp(`${webhookName}\\b`) }).first()
249
+ // The out-of-band PUT below renames the fixture to "QA Lock 043 bumped <stamp>";
250
+ // tolerate both names so a mid-test list re-render cannot orphan the row locator.
251
+ const row = page
252
+ .getByRole('row', { name: new RegExp(`QA Lock 043 (?:bumped )?${stamp}\\b`) })
253
+ .first()
247
254
  await expect(row, 'created webhook should appear in the list').toBeVisible({ timeout: 20_000 })
248
255
 
249
256
  // Advance updated_at out-of-band → the in-page row token is now stale.
@@ -257,12 +264,19 @@ test.describe('TC-LOCK-OSS-043: webhooks + inbox settings + data-sync schedule o
257
264
 
258
265
  // Open the row's RowActions kebab (opens on click) and trigger Delete. The
259
266
  // menu renders in a portal on document.body, so query it at the page level.
267
+ // The list re-renders as data settles, so the menu can detach between "open"
268
+ // and "click" — retry (re)open-menu + click-Delete atomically (same fix as
269
+ // TC-LOCK-OSS-029); the confirm dialog gates the DELETE, so a repeated
270
+ // click is safe.
260
271
  const kebab = row.getByRole('button', { name: /open actions/i })
261
- await expect(kebab, 'row should expose an Open actions trigger').toBeVisible()
262
- await kebab.click()
263
272
  const deleteItem = page.getByRole('menuitem', { name: /^delete$/i })
264
- await expect(deleteItem, 'delete menu item should be visible').toBeVisible({ timeout: 10_000 })
265
- await deleteItem.click()
273
+ await expect(async () => {
274
+ if (!(await deleteItem.isVisible().catch(() => false))) {
275
+ await kebab.click({ timeout: 2_000 }).catch(() => {})
276
+ await expect(deleteItem).toBeVisible({ timeout: 1_500 })
277
+ }
278
+ await deleteItem.click({ timeout: 2_000 })
279
+ }).toPass({ timeout: 30_000 })
266
280
 
267
281
  // Confirm the destructive alertdialog (the row still holds the stale
268
282
  // updated_at captured at render time, so the DELETE 409s).
@@ -87,3 +87,29 @@ describe('webhookCreateSchema — URL safety', () => {
87
87
  expect(webhookCreateSchema.safeParse(baseInput({ url: 'https://user:pass@localhost/webhooks' })).success).toBe(false)
88
88
  })
89
89
  })
90
+
91
+ describe('webhookCreateSchema — reserved custom headers', () => {
92
+ it('rejects custom headers that shadow Standard Webhooks signature headers', () => {
93
+ const result = webhookCreateSchema.safeParse(baseInput({ customHeaders: { 'webhook-signature': 'forged' } }))
94
+ expect(result.success).toBe(false)
95
+ if (!result.success) {
96
+ expect(result.error.issues[0].message).toMatch(/reserved/i)
97
+ }
98
+ })
99
+
100
+ it('rejects reserved header names case-insensitively', () => {
101
+ expect(webhookCreateSchema.safeParse(baseInput({ customHeaders: { 'Webhook-Id': 'constant' } })).success).toBe(false)
102
+ expect(webhookCreateSchema.safeParse(baseInput({ customHeaders: { 'WEBHOOK-TIMESTAMP': '0' } })).success).toBe(false)
103
+ expect(webhookCreateSchema.safeParse(baseInput({ customHeaders: { 'Content-Type': 'text/plain' } })).success).toBe(false)
104
+ })
105
+
106
+ it('rejects reserved headers on update', () => {
107
+ const result = webhookUpdateSchema.safeParse({ customHeaders: { 'webhook-signature': 'forged' } })
108
+ expect(result.success).toBe(false)
109
+ })
110
+
111
+ it('accepts non-reserved custom headers', () => {
112
+ expect(webhookCreateSchema.safeParse(baseInput({ customHeaders: { 'x-api-key': 'value', authorization: 'Bearer token' } })).success).toBe(true)
113
+ expect(webhookUpdateSchema.safeParse({ customHeaders: { 'x-api-key': 'value' } }).success).toBe(true)
114
+ })
115
+ })
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { assertStaticallySafeWebhookUrl, UnsafeWebhookUrlError } from '../lib/url-safety'
3
+ import { isReservedWebhookCustomHeader } from '../lib/custom-headers'
3
4
 
4
5
  const safeWebhookUrl = z.string().url().superRefine((value, ctx) => {
5
6
  try {
@@ -12,13 +13,25 @@ const safeWebhookUrl = z.string().url().superRefine((value, ctx) => {
12
13
  }
13
14
  })
14
15
 
16
+ const webhookCustomHeaders = z.record(z.string(), z.string()).superRefine((value, ctx) => {
17
+ for (const name of Object.keys(value)) {
18
+ if (isReservedWebhookCustomHeader(name)) {
19
+ ctx.addIssue({
20
+ code: z.ZodIssueCode.custom,
21
+ message: `Header "${name}" is reserved for webhook signing and cannot be overridden`,
22
+ path: [name],
23
+ })
24
+ }
25
+ }
26
+ })
27
+
15
28
  export const webhookCreateSchema = z.object({
16
29
  name: z.string().min(1).max(255),
17
30
  description: z.string().max(1000).optional().nullable(),
18
31
  url: safeWebhookUrl,
19
32
  subscribedEvents: z.array(z.string().min(1)).min(1),
20
33
  httpMethod: z.enum(['POST', 'PUT', 'PATCH'] as const).default('POST'),
21
- customHeaders: z.record(z.string(), z.string()).optional().nullable(),
34
+ customHeaders: webhookCustomHeaders.optional().nullable(),
22
35
  deliveryStrategy: z.literal('http').default('http'),
23
36
  strategyConfig: z.record(z.string(), z.unknown()).optional().nullable(),
24
37
  maxRetries: z.number().int().min(0).max(30).default(10),
@@ -0,0 +1,169 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { processWebhookDeliveryJob } from '../delivery'
3
+ import { isReservedWebhookCustomHeader, sanitizeWebhookCustomHeaders } from '../custom-headers'
4
+
5
+ jest.mock('../../events', () => ({
6
+ emitWebhooksEvent: jest.fn(async () => undefined),
7
+ }))
8
+
9
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
10
+ findOneWithDecryption: jest.fn(),
11
+ }))
12
+
13
+ jest.mock('../integration-state', () => ({
14
+ isWebhookIntegrationEnabled: jest.fn(async () => true),
15
+ WEBHOOK_INTEGRATION_DISABLED_MESSAGE: 'disabled',
16
+ }))
17
+
18
+ jest.mock('@open-mercato/shared/lib/webhooks', () => ({
19
+ buildWebhookHeaders: jest.fn(() => ({
20
+ 'webhook-id': 'msg-1',
21
+ 'webhook-timestamp': '1700000000',
22
+ 'webhook-signature': 'v1,legitimate-signature',
23
+ })),
24
+ generateMessageId: jest.fn(() => 'msg-1'),
25
+ }))
26
+
27
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
28
+
29
+ const findOneWithDecryptionMock = findOneWithDecryption as jest.MockedFunction<typeof findOneWithDecryption>
30
+
31
+ describe('isReservedWebhookCustomHeader', () => {
32
+ it('reserves webhook-* and content-type case-insensitively', () => {
33
+ expect(isReservedWebhookCustomHeader('webhook-signature')).toBe(true)
34
+ expect(isReservedWebhookCustomHeader('Webhook-Id')).toBe(true)
35
+ expect(isReservedWebhookCustomHeader('WEBHOOK-TIMESTAMP')).toBe(true)
36
+ expect(isReservedWebhookCustomHeader(' webhook-signature ')).toBe(true)
37
+ expect(isReservedWebhookCustomHeader('Content-Type')).toBe(true)
38
+ expect(isReservedWebhookCustomHeader('x-custom-header')).toBe(false)
39
+ expect(isReservedWebhookCustomHeader('authorization')).toBe(false)
40
+ })
41
+ })
42
+
43
+ describe('sanitizeWebhookCustomHeaders', () => {
44
+ it('strips reserved headers and keeps the rest', () => {
45
+ expect(sanitizeWebhookCustomHeaders({
46
+ 'Webhook-Signature': 'forged',
47
+ 'webhook-id': 'constant',
48
+ 'Content-Type': 'text/plain',
49
+ 'x-api-key': 'value',
50
+ })).toEqual({ 'x-api-key': 'value' })
51
+ })
52
+
53
+ it('returns an empty object for null or undefined', () => {
54
+ expect(sanitizeWebhookCustomHeaders(null)).toEqual({})
55
+ expect(sanitizeWebhookCustomHeaders(undefined)).toEqual({})
56
+ })
57
+ })
58
+
59
+ describe('processWebhookDeliveryJob — custom headers cannot override signed headers', () => {
60
+ const originalFetch = globalThis.fetch
61
+
62
+ beforeEach(() => {
63
+ jest.clearAllMocks()
64
+ process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS = '1'
65
+ globalThis.fetch = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
66
+ })
67
+
68
+ afterEach(() => {
69
+ globalThis.fetch = originalFetch
70
+ delete process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS
71
+ })
72
+
73
+ function buildDelivery() {
74
+ return {
75
+ id: 'delivery-1',
76
+ tenantId: 't',
77
+ organizationId: 'o',
78
+ webhookId: 'w-1',
79
+ status: 'pending',
80
+ payload: { type: 'x', timestamp: new Date().toISOString(), data: {} },
81
+ enqueuedAt: new Date(),
82
+ eventType: 'x',
83
+ maxAttempts: 3,
84
+ attemptNumber: 0,
85
+ messageId: 'msg-1',
86
+ responseBody: null,
87
+ responseHeaders: null,
88
+ responseStatus: null,
89
+ nextRetryAt: null,
90
+ errorMessage: null,
91
+ lastAttemptAt: null,
92
+ deliveredAt: null,
93
+ durationMs: null,
94
+ }
95
+ }
96
+
97
+ function buildWebhook(customHeaders: Record<string, string> | null) {
98
+ return {
99
+ id: 'w-1',
100
+ url: 'http://127.0.0.1:3001/hook',
101
+ isActive: true,
102
+ timeoutMs: 1000,
103
+ httpMethod: 'POST',
104
+ customHeaders,
105
+ consecutiveFailures: 0,
106
+ lastFailureAt: null,
107
+ lastSuccessAt: null,
108
+ autoDisableThreshold: 0,
109
+ maxRetries: 3,
110
+ secret: 'secret',
111
+ previousSecret: null,
112
+ tenantId: 't',
113
+ organizationId: 'o',
114
+ }
115
+ }
116
+
117
+ function buildEm(delivery: Record<string, unknown>) {
118
+ return {
119
+ findOne: jest.fn(async () => delivery),
120
+ flush: jest.fn(async () => undefined),
121
+ } as unknown as EntityManager
122
+ }
123
+
124
+ it('sends signed headers even when customHeaders tries to override them', async () => {
125
+ const delivery = buildDelivery()
126
+ const webhook = buildWebhook({
127
+ 'webhook-signature': 'forged-signature',
128
+ 'Webhook-Id': 'pinned-id',
129
+ 'WEBHOOK-TIMESTAMP': '0',
130
+ 'content-type': 'text/plain',
131
+ 'x-api-key': 'tenant-value',
132
+ })
133
+ findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
134
+
135
+ const em = buildEm(delivery)
136
+ const result = await processWebhookDeliveryJob(em, {
137
+ deliveryId: 'delivery-1',
138
+ tenantId: 't',
139
+ organizationId: 'o',
140
+ }, { scheduleRetries: false })
141
+
142
+ expect(result?.status).toBe('delivered')
143
+ expect((globalThis.fetch as jest.Mock).mock.calls).toHaveLength(1)
144
+ const sentHeaders = new Headers((globalThis.fetch as jest.Mock).mock.calls[0][1].headers)
145
+ expect(sentHeaders.get('webhook-signature')).toBe('v1,legitimate-signature')
146
+ expect(sentHeaders.get('webhook-id')).toBe('msg-1')
147
+ expect(sentHeaders.get('webhook-timestamp')).toBe('1700000000')
148
+ expect(sentHeaders.get('content-type')).toBe('application/json')
149
+ expect(sentHeaders.get('x-api-key')).toBe('tenant-value')
150
+ })
151
+
152
+ it('still applies non-reserved custom headers', async () => {
153
+ const delivery = buildDelivery()
154
+ const webhook = buildWebhook({ authorization: 'Bearer receiver-token' })
155
+ findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
156
+
157
+ const em = buildEm(delivery)
158
+ const result = await processWebhookDeliveryJob(em, {
159
+ deliveryId: 'delivery-1',
160
+ tenantId: 't',
161
+ organizationId: 'o',
162
+ }, { scheduleRetries: false })
163
+
164
+ expect(result?.status).toBe('delivered')
165
+ const sentHeaders = new Headers((globalThis.fetch as jest.Mock).mock.calls[0][1].headers)
166
+ expect(sentHeaders.get('authorization')).toBe('Bearer receiver-token')
167
+ expect(sentHeaders.get('webhook-signature')).toBe('v1,legitimate-signature')
168
+ })
169
+ })
@@ -0,0 +1,16 @@
1
+ const RESERVED_HEADER_PREFIX = 'webhook-'
2
+ const RESERVED_HEADER_NAMES = new Set(['content-type'])
3
+
4
+ export function isReservedWebhookCustomHeader(name: string): boolean {
5
+ const normalized = name.trim().toLowerCase()
6
+ return normalized.startsWith(RESERVED_HEADER_PREFIX) || RESERVED_HEADER_NAMES.has(normalized)
7
+ }
8
+
9
+ export function sanitizeWebhookCustomHeaders(
10
+ customHeaders: Record<string, string> | null | undefined,
11
+ ): Record<string, string> {
12
+ if (!customHeaders) return {}
13
+ return Object.fromEntries(
14
+ Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name)),
15
+ )
16
+ }
@@ -5,6 +5,7 @@ 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 { sanitizeWebhookCustomHeaders } from './custom-headers'
8
9
  import {
9
10
  assertSafeWebhookDeliveryUrl,
10
11
  safeWebhookFetch,
@@ -165,8 +166,8 @@ export async function processWebhookDeliveryJob(
165
166
  redirect: 'manual',
166
167
  headers: {
167
168
  'content-type': 'application/json',
169
+ ...sanitizeWebhookCustomHeaders(webhook.customHeaders),
168
170
  ...headers,
169
- ...(webhook.customHeaders ?? {}),
170
171
  },
171
172
  body,
172
173
  signal: controller.signal,
@@ -158,8 +158,7 @@ export default function IntegrationSetupWidget({ context }: InjectionWidgetCompo
158
158
  )}
159
159
  </p>
160
160
 
161
- <Alert>
162
- <Webhook className="h-4 w-4" />
161
+ <Alert icon={<Webhook aria-hidden="true" />}>
163
162
  <AlertTitle>
164
163
  {isEnabled
165
164
  ? t('webhooks.integrationSetup.enabledTitle', 'Delivery processing is enabled')