@open-mercato/webhooks 0.6.5-develop.5337.1.534b781eac → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js +10 -6
- package/dist/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.js.map +2 -2
- package/dist/modules/webhooks/data/validators.js +13 -1
- package/dist/modules/webhooks/data/validators.js.map +2 -2
- package/dist/modules/webhooks/lib/custom-headers.js +17 -0
- package/dist/modules/webhooks/lib/custom-headers.js.map +7 -0
- package/dist/modules/webhooks/lib/delivery.js +3 -2
- package/dist/modules/webhooks/lib/delivery.js.map +2 -2
- package/dist/modules/webhooks/widgets/injection/integration-setup/widget.client.js +1 -2
- package/dist/modules/webhooks/widgets/injection/integration-setup/widget.client.js.map +2 -2
- package/package.json +8 -9
- package/src/modules/webhooks/__integration__/TC-LOCK-OSS-043.spec.ts +20 -6
- package/src/modules/webhooks/data/__tests__/validators.test.ts +26 -0
- package/src/modules/webhooks/data/validators.ts +14 -1
- package/src/modules/webhooks/lib/__tests__/delivery-custom-headers.test.ts +169 -0
- package/src/modules/webhooks/lib/custom-headers.ts +16 -0
- package/src/modules/webhooks/lib/delivery.ts +2 -1
- package/src/modules/webhooks/widgets/injection/integration-setup/widget.client.tsx +1 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:webhooks] found
|
|
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(
|
|
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(
|
|
165
|
-
|
|
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:
|
|
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:
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,gCAAgC,6BAA6B;
|
|
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
|
-
...
|
|
125
|
-
...
|
|
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": "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",
|
|
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
|
|
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
|
|
73
|
-
"@open-mercato/queue": "0.6.5
|
|
74
|
-
"@open-mercato/ui": "0.6.5
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
265
|
-
|
|
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:
|
|
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')
|