@open-mercato/webhooks 0.10.1-develop.7618.1.ba9531c18e → 0.10.1-develop.7622.1.0584ba2a04
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-WEBHOOK-013.spec.js +191 -0
- package/dist/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.js.map +7 -0
- package/dist/modules/webhooks/api/helpers.js +31 -4
- package/dist/modules/webhooks/api/helpers.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/[id]/route.js +30 -6
- package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
- package/dist/modules/webhooks/api/webhooks/route.js +21 -4
- package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
- package/dist/modules/webhooks/lib/custom-headers.js +60 -0
- package/dist/modules/webhooks/lib/custom-headers.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.ts +231 -0
- package/src/modules/webhooks/api/helpers.ts +43 -2
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/credential-headers.test.ts +358 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/reactivation.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +2 -0
- package/src/modules/webhooks/api/webhooks/[id]/route.ts +31 -6
- package/src/modules/webhooks/api/webhooks/__tests__/credential-headers.test.ts +96 -0
- package/src/modules/webhooks/api/webhooks/route.ts +21 -4
- package/src/modules/webhooks/i18n/de.json +3 -1
- package/src/modules/webhooks/i18n/en.json +3 -1
- package/src/modules/webhooks/i18n/es.json +3 -1
- package/src/modules/webhooks/i18n/ko.json +3 -1
- package/src/modules/webhooks/i18n/pl.json +3 -1
- package/src/modules/webhooks/lib/__tests__/custom-headers.test.ts +144 -0
- package/src/modules/webhooks/lib/custom-headers.ts +84 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:webhooks] found
|
|
1
|
+
[build:webhooks] found 78 entry points
|
|
2
2
|
[build:webhooks] built successfully
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
cleanupWebhooksUser,
|
|
6
|
+
createWebhookFixture,
|
|
7
|
+
deleteWebhookIfExists,
|
|
8
|
+
provisionWebhooksUser
|
|
9
|
+
} from "./helpers/fixtures.js";
|
|
10
|
+
const REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
11
|
+
const DESTINATION_TOKEN = "Bearer tc-webhook-013-destination-token";
|
|
12
|
+
async function readDetail(request, token, id) {
|
|
13
|
+
const response = await apiRequest(request, "GET", `/api/webhooks/${id}`, { token });
|
|
14
|
+
expect(response.status()).toBe(200);
|
|
15
|
+
return await response.json();
|
|
16
|
+
}
|
|
17
|
+
async function loginWithCredentials(page, email, password) {
|
|
18
|
+
const baseUrl = process.env.BASE_URL || "http://localhost:3000";
|
|
19
|
+
await page.context().addCookies([
|
|
20
|
+
{ name: "om_demo_notice_ack", value: "ack", url: baseUrl, sameSite: "Lax" },
|
|
21
|
+
{ name: "om_cookie_notice_ack", value: "ack", url: baseUrl, sameSite: "Lax" },
|
|
22
|
+
{ name: "om_feedback_suppress", value: "1", url: baseUrl, sameSite: "Lax" }
|
|
23
|
+
]);
|
|
24
|
+
await page.goto("/login");
|
|
25
|
+
await page.waitForSelector('form[data-auth-ready="1"]', { state: "visible", timeout: 3e4 });
|
|
26
|
+
await page.getByLabel("Email").fill(email);
|
|
27
|
+
const passwordInput = page.getByLabel("Password", { exact: true });
|
|
28
|
+
await passwordInput.fill(password);
|
|
29
|
+
await passwordInput.press("Enter");
|
|
30
|
+
await expect(page).toHaveURL(/\/backend(?:\/.*)?$/, { timeout: 3e4 });
|
|
31
|
+
}
|
|
32
|
+
test.describe("TC-WEBHOOK-013: credential custom headers are hidden without webhooks.secrets", () => {
|
|
33
|
+
test("redacts on read, keeps stored values on write, and refuses unsafe writes", async ({ request }) => {
|
|
34
|
+
test.setTimeout(6e4);
|
|
35
|
+
const adminToken = await getAuthToken(request);
|
|
36
|
+
const scope = getTokenScope(adminToken);
|
|
37
|
+
let webhookId = null;
|
|
38
|
+
let viewer = null;
|
|
39
|
+
let manager = null;
|
|
40
|
+
try {
|
|
41
|
+
viewer = await provisionWebhooksUser(request, adminToken, {
|
|
42
|
+
tenantId: scope.tenantId,
|
|
43
|
+
organizationId: scope.organizationId,
|
|
44
|
+
features: ["webhooks.view"],
|
|
45
|
+
slug: "cred-viewer"
|
|
46
|
+
});
|
|
47
|
+
manager = await provisionWebhooksUser(request, adminToken, {
|
|
48
|
+
tenantId: scope.tenantId,
|
|
49
|
+
organizationId: scope.organizationId,
|
|
50
|
+
features: ["webhooks.view", "webhooks.manage"],
|
|
51
|
+
slug: "cred-manager"
|
|
52
|
+
});
|
|
53
|
+
const created = await createWebhookFixture(request, adminToken, {
|
|
54
|
+
name: `Webhook credential headers ${Date.now()}`,
|
|
55
|
+
url: "https://example.com/tc-webhook-013",
|
|
56
|
+
subscribedEvents: ["catalog.product.created"],
|
|
57
|
+
customHeaders: { Authorization: DESTINATION_TOKEN, "x-api-key": "tc-013-api-key", "x-source": "qa" }
|
|
58
|
+
});
|
|
59
|
+
webhookId = created.id;
|
|
60
|
+
const viewerDetail = await readDetail(request, viewer.token, created.id);
|
|
61
|
+
expect(viewerDetail.customHeaders).toEqual({ Authorization: REDACTED, "x-api-key": REDACTED, "x-source": "qa" });
|
|
62
|
+
const aliasResponse = await apiRequest(request, "GET", `/api/webhooks/webhooks/${created.id}`, { token: viewer.token });
|
|
63
|
+
expect(aliasResponse.status()).toBe(200);
|
|
64
|
+
expect((await aliasResponse.json()).customHeaders?.Authorization).toBe(REDACTED);
|
|
65
|
+
const managerDetail = await readDetail(request, manager.token, created.id);
|
|
66
|
+
expect(managerDetail.customHeaders?.Authorization).toBe(REDACTED);
|
|
67
|
+
const adminDetail = await readDetail(request, adminToken, created.id);
|
|
68
|
+
expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, "x-api-key": "tc-013-api-key", "x-source": "qa" });
|
|
69
|
+
const renamed = `Webhook credential headers renamed ${Date.now()}`;
|
|
70
|
+
const echoUpdate = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
71
|
+
token: manager.token,
|
|
72
|
+
data: { name: renamed, customHeaders: { ...managerDetail.customHeaders, "x-source": "erp" } }
|
|
73
|
+
});
|
|
74
|
+
expect(echoUpdate.status()).toBe(200);
|
|
75
|
+
expect((await echoUpdate.json()).customHeaders?.Authorization).toBe(REDACTED);
|
|
76
|
+
const afterEcho = await readDetail(request, adminToken, created.id);
|
|
77
|
+
expect(afterEcho.name).toBe(renamed);
|
|
78
|
+
expect(afterEcho.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, "x-api-key": "tc-013-api-key", "x-source": "erp" });
|
|
79
|
+
const orphanMarker = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
80
|
+
token: manager.token,
|
|
81
|
+
data: { customHeaders: { "x-new-token": REDACTED } }
|
|
82
|
+
});
|
|
83
|
+
expect(orphanMarker.status()).toBe(400);
|
|
84
|
+
expect((await orphanMarker.json()).error).toContain("x-new-token");
|
|
85
|
+
const moveWithHidden = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
86
|
+
token: manager.token,
|
|
87
|
+
data: { url: "https://other.example.net/tc-webhook-013" }
|
|
88
|
+
});
|
|
89
|
+
expect(moveWithHidden.status()).toBe(400);
|
|
90
|
+
const moveEchoingMarker = await apiRequest(request, "PUT", `/api/webhooks/webhooks/${created.id}`, {
|
|
91
|
+
token: manager.token,
|
|
92
|
+
data: { url: "https://other.example.net/tc-webhook-013", customHeaders: managerDetail.customHeaders }
|
|
93
|
+
});
|
|
94
|
+
expect(moveEchoingMarker.status()).toBe(400);
|
|
95
|
+
const samePathMove = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
96
|
+
token: manager.token,
|
|
97
|
+
data: { url: "https://example.com/tc-webhook-013-other-bin", customHeaders: managerDetail.customHeaders }
|
|
98
|
+
});
|
|
99
|
+
expect(samePathMove.status()).toBe(400);
|
|
100
|
+
const unchanged = await readDetail(request, adminToken, created.id);
|
|
101
|
+
expect(unchanged.url).toBe("https://example.com/tc-webhook-013");
|
|
102
|
+
expect(unchanged.customHeaders).toEqual(afterEcho.customHeaders);
|
|
103
|
+
const moveReentered = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
104
|
+
token: manager.token,
|
|
105
|
+
data: {
|
|
106
|
+
url: "https://other.example.net/tc-webhook-013",
|
|
107
|
+
customHeaders: { Authorization: "Bearer re-entered", "x-source": "erp" }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
expect(moveReentered.status()).toBe(200);
|
|
111
|
+
const adminMove = await apiRequest(request, "PUT", `/api/webhooks/${created.id}`, {
|
|
112
|
+
token: adminToken,
|
|
113
|
+
data: { url: "https://example.com/tc-webhook-013", customHeaders: { Authorization: REDACTED, "x-source": "erp" } }
|
|
114
|
+
});
|
|
115
|
+
expect(adminMove.status()).toBe(200);
|
|
116
|
+
expect((await adminMove.json()).customHeaders).toEqual({ Authorization: "Bearer re-entered", "x-source": "erp" });
|
|
117
|
+
} finally {
|
|
118
|
+
await deleteWebhookIfExists(request, adminToken, webhookId);
|
|
119
|
+
await cleanupWebhooksUser(request, adminToken, viewer);
|
|
120
|
+
await cleanupWebhooksUser(request, adminToken, manager);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
test("shows hidden values in the UI and keeps them when a manager saves the edit form", async ({ browser, request }) => {
|
|
124
|
+
test.setTimeout(12e4);
|
|
125
|
+
const adminToken = await getAuthToken(request);
|
|
126
|
+
const scope = getTokenScope(adminToken);
|
|
127
|
+
let webhookId = null;
|
|
128
|
+
let viewer = null;
|
|
129
|
+
let manager = null;
|
|
130
|
+
try {
|
|
131
|
+
viewer = await provisionWebhooksUser(request, adminToken, {
|
|
132
|
+
tenantId: scope.tenantId,
|
|
133
|
+
organizationId: scope.organizationId,
|
|
134
|
+
features: ["webhooks.view"],
|
|
135
|
+
slug: "cred-ui-viewer"
|
|
136
|
+
});
|
|
137
|
+
manager = await provisionWebhooksUser(request, adminToken, {
|
|
138
|
+
tenantId: scope.tenantId,
|
|
139
|
+
organizationId: scope.organizationId,
|
|
140
|
+
features: ["webhooks.view", "webhooks.manage"],
|
|
141
|
+
slug: "cred-ui-manager"
|
|
142
|
+
});
|
|
143
|
+
const created = await createWebhookFixture(request, adminToken, {
|
|
144
|
+
name: `Webhook credential UI ${Date.now()}`,
|
|
145
|
+
url: "https://example.com/tc-webhook-013-ui",
|
|
146
|
+
subscribedEvents: ["catalog.product.created"],
|
|
147
|
+
customHeaders: { Authorization: DESTINATION_TOKEN, "x-source": "qa" }
|
|
148
|
+
});
|
|
149
|
+
webhookId = created.id;
|
|
150
|
+
const viewerContext = await browser.newContext();
|
|
151
|
+
try {
|
|
152
|
+
const viewerPage = await viewerContext.newPage();
|
|
153
|
+
await loginWithCredentials(viewerPage, viewer.email, viewer.password);
|
|
154
|
+
await viewerPage.goto(`/backend/webhooks/${created.id}`);
|
|
155
|
+
const headersCard = viewerPage.locator("pre").filter({ hasText: '"x-source": "qa"' });
|
|
156
|
+
await expect(headersCard).toContainText(`"Authorization": "${REDACTED}"`);
|
|
157
|
+
await expect(viewerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);
|
|
158
|
+
} finally {
|
|
159
|
+
await viewerContext.close();
|
|
160
|
+
}
|
|
161
|
+
const renamed = `Webhook credential UI renamed ${Date.now()}`;
|
|
162
|
+
const managerContext = await browser.newContext();
|
|
163
|
+
try {
|
|
164
|
+
const managerPage = await managerContext.newPage();
|
|
165
|
+
await loginWithCredentials(managerPage, manager.email, manager.password);
|
|
166
|
+
await managerPage.goto(`/backend/webhooks/${created.id}`);
|
|
167
|
+
await expect(managerPage.locator("pre").filter({ hasText: '"x-source": "qa"' })).toContainText(REDACTED);
|
|
168
|
+
await managerPage.getByRole("button", { name: "Actions" }).hover();
|
|
169
|
+
await managerPage.getByRole("menuitem", { name: "Edit" }).click();
|
|
170
|
+
await expect(managerPage.getByText(`Hidden values (${REDACTED}) are kept when you save`)).toBeVisible();
|
|
171
|
+
await expect(managerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);
|
|
172
|
+
await expect(managerPage.getByPlaceholder('{"key": "value"}')).toHaveValue(new RegExp(`"Authorization": "${REDACTED}"`));
|
|
173
|
+
const nameInput = managerPage.getByPlaceholder("My Webhook");
|
|
174
|
+
await nameInput.fill(renamed);
|
|
175
|
+
await expect(nameInput).toHaveValue(renamed);
|
|
176
|
+
await managerPage.getByRole("button", { name: "Save" }).first().click();
|
|
177
|
+
await expect(managerPage.locator("pre").filter({ hasText: '"x-source": "qa"' })).toBeVisible({ timeout: 15e3 });
|
|
178
|
+
} finally {
|
|
179
|
+
await managerContext.close();
|
|
180
|
+
}
|
|
181
|
+
const adminDetail = await readDetail(request, adminToken, created.id);
|
|
182
|
+
expect(adminDetail.name).toBe(renamed);
|
|
183
|
+
expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, "x-source": "qa" });
|
|
184
|
+
} finally {
|
|
185
|
+
await deleteWebhookIfExists(request, adminToken, webhookId);
|
|
186
|
+
await cleanupWebhooksUser(request, adminToken, viewer);
|
|
187
|
+
await cleanupWebhooksUser(request, adminToken, manager);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
//# sourceMappingURL=TC-WEBHOOK-013.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { expect, test, type Page } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n cleanupWebhooksUser,\n createWebhookFixture,\n deleteWebhookIfExists,\n provisionWebhooksUser,\n type ProvisionedWebhooksUser,\n} from './helpers/fixtures';\n\n/**\n * TC-WEBHOOK-013: credential-like custom header values are hidden from callers without webhooks.secrets\n * Source: https://github.com/open-mercato/open-mercato/issues/7237\n *\n * A viewer (webhooks.view) and a manager without webhooks.secrets receive `\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022` for\n * credential-like custom headers, while ordinary headers stay readable and an admin\n * (webhooks.*) still receives the stored values. Echoing the marker back keeps the stored\n * value, a marker without a stored value is refused, and a manager without webhooks.secrets\n * cannot keep hidden values while changing the webhook URL.\n */\n\nconst REDACTED = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';\nconst DESTINATION_TOKEN = 'Bearer tc-webhook-013-destination-token';\n\ntype WebhookDetail = { name: string; url: string; customHeaders: Record<string, string> | null };\n\nasync function readDetail(request: Parameters<typeof apiRequest>[0], token: string, id: string): Promise<WebhookDetail> {\n const response = await apiRequest(request, 'GET', `/api/webhooks/${id}`, { token });\n expect(response.status()).toBe(200);\n return (await response.json()) as WebhookDetail;\n}\n\nasync function loginWithCredentials(page: Page, email: string, password: string): Promise<void> {\n const baseUrl = process.env.BASE_URL || 'http://localhost:3000';\n await page.context().addCookies([\n { name: 'om_demo_notice_ack', value: 'ack', url: baseUrl, sameSite: 'Lax' },\n { name: 'om_cookie_notice_ack', value: 'ack', url: baseUrl, sameSite: 'Lax' },\n { name: 'om_feedback_suppress', value: '1', url: baseUrl, sameSite: 'Lax' },\n ]);\n await page.goto('/login');\n await page.waitForSelector('form[data-auth-ready=\"1\"]', { state: 'visible', timeout: 30_000 });\n await page.getByLabel('Email').fill(email);\n const passwordInput = page.getByLabel('Password', { exact: true });\n await passwordInput.fill(password);\n await passwordInput.press('Enter');\n await expect(page).toHaveURL(/\\/backend(?:\\/.*)?$/, { timeout: 30_000 });\n}\n\ntest.describe('TC-WEBHOOK-013: credential custom headers are hidden without webhooks.secrets', () => {\n test('redacts on read, keeps stored values on write, and refuses unsafe writes', async ({ request }) => {\n test.setTimeout(60_000);\n const adminToken = await getAuthToken(request);\n const scope = getTokenScope(adminToken);\n\n let webhookId: string | null = null;\n let viewer: ProvisionedWebhooksUser | null = null;\n let manager: ProvisionedWebhooksUser | null = null;\n\n try {\n viewer = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: ['webhooks.view'],\n slug: 'cred-viewer',\n });\n manager = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: ['webhooks.view', 'webhooks.manage'],\n slug: 'cred-manager',\n });\n\n const created = await createWebhookFixture(request, adminToken, {\n name: `Webhook credential headers ${Date.now()}`,\n url: 'https://example.com/tc-webhook-013',\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'qa' },\n });\n webhookId = created.id;\n\n const viewerDetail = await readDetail(request, viewer.token, created.id);\n expect(viewerDetail.customHeaders).toEqual({ Authorization: REDACTED, 'x-api-key': REDACTED, 'x-source': 'qa' });\n\n const aliasResponse = await apiRequest(request, 'GET', `/api/webhooks/webhooks/${created.id}`, { token: viewer.token });\n expect(aliasResponse.status()).toBe(200);\n expect(((await aliasResponse.json()) as WebhookDetail).customHeaders?.Authorization).toBe(REDACTED);\n\n const managerDetail = await readDetail(request, manager.token, created.id);\n expect(managerDetail.customHeaders?.Authorization).toBe(REDACTED);\n\n const adminDetail = await readDetail(request, adminToken, created.id);\n expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'qa' });\n\n const renamed = `Webhook credential headers renamed ${Date.now()}`;\n const echoUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: manager.token,\n data: { name: renamed, customHeaders: { ...managerDetail.customHeaders, 'x-source': 'erp' } },\n });\n expect(echoUpdate.status()).toBe(200);\n expect(((await echoUpdate.json()) as WebhookDetail).customHeaders?.Authorization).toBe(REDACTED);\n\n const afterEcho = await readDetail(request, adminToken, created.id);\n expect(afterEcho.name).toBe(renamed);\n expect(afterEcho.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'erp' });\n\n const orphanMarker = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: manager.token,\n data: { customHeaders: { 'x-new-token': REDACTED } },\n });\n expect(orphanMarker.status()).toBe(400);\n expect(((await orphanMarker.json()) as { error: string }).error).toContain('x-new-token');\n\n const moveWithHidden = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: manager.token,\n data: { url: 'https://other.example.net/tc-webhook-013' },\n });\n expect(moveWithHidden.status()).toBe(400);\n\n const moveEchoingMarker = await apiRequest(request, 'PUT', `/api/webhooks/webhooks/${created.id}`, {\n token: manager.token,\n data: { url: 'https://other.example.net/tc-webhook-013', customHeaders: managerDetail.customHeaders },\n });\n expect(moveEchoingMarker.status()).toBe(400);\n\n const samePathMove = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: manager.token,\n data: { url: 'https://example.com/tc-webhook-013-other-bin', customHeaders: managerDetail.customHeaders },\n });\n expect(samePathMove.status()).toBe(400);\n\n const unchanged = await readDetail(request, adminToken, created.id);\n expect(unchanged.url).toBe('https://example.com/tc-webhook-013');\n expect(unchanged.customHeaders).toEqual(afterEcho.customHeaders);\n\n const moveReentered = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: manager.token,\n data: {\n url: 'https://other.example.net/tc-webhook-013',\n customHeaders: { Authorization: 'Bearer re-entered', 'x-source': 'erp' },\n },\n });\n expect(moveReentered.status()).toBe(200);\n\n const adminMove = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {\n token: adminToken,\n data: { url: 'https://example.com/tc-webhook-013', customHeaders: { Authorization: REDACTED, 'x-source': 'erp' } },\n });\n expect(adminMove.status()).toBe(200);\n expect(((await adminMove.json()) as WebhookDetail).customHeaders).toEqual({ Authorization: 'Bearer re-entered', 'x-source': 'erp' });\n } finally {\n await deleteWebhookIfExists(request, adminToken, webhookId);\n await cleanupWebhooksUser(request, adminToken, viewer);\n await cleanupWebhooksUser(request, adminToken, manager);\n }\n });\n\n test('shows hidden values in the UI and keeps them when a manager saves the edit form', async ({ browser, request }) => {\n test.setTimeout(120_000);\n const adminToken = await getAuthToken(request);\n const scope = getTokenScope(adminToken);\n\n let webhookId: string | null = null;\n let viewer: ProvisionedWebhooksUser | null = null;\n let manager: ProvisionedWebhooksUser | null = null;\n\n try {\n viewer = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: ['webhooks.view'],\n slug: 'cred-ui-viewer',\n });\n manager = await provisionWebhooksUser(request, adminToken, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n features: ['webhooks.view', 'webhooks.manage'],\n slug: 'cred-ui-manager',\n });\n\n const created = await createWebhookFixture(request, adminToken, {\n name: `Webhook credential UI ${Date.now()}`,\n url: 'https://example.com/tc-webhook-013-ui',\n subscribedEvents: ['catalog.product.created'],\n customHeaders: { Authorization: DESTINATION_TOKEN, 'x-source': 'qa' },\n });\n webhookId = created.id;\n\n const viewerContext = await browser.newContext();\n try {\n const viewerPage = await viewerContext.newPage();\n await loginWithCredentials(viewerPage, viewer.email, viewer.password);\n await viewerPage.goto(`/backend/webhooks/${created.id}`);\n const headersCard = viewerPage.locator('pre').filter({ hasText: '\"x-source\": \"qa\"' });\n await expect(headersCard).toContainText(`\"Authorization\": \"${REDACTED}\"`);\n await expect(viewerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);\n } finally {\n await viewerContext.close();\n }\n\n const renamed = `Webhook credential UI renamed ${Date.now()}`;\n const managerContext = await browser.newContext();\n try {\n const managerPage = await managerContext.newPage();\n await loginWithCredentials(managerPage, manager.email, manager.password);\n await managerPage.goto(`/backend/webhooks/${created.id}`);\n await expect(managerPage.locator('pre').filter({ hasText: '\"x-source\": \"qa\"' })).toContainText(REDACTED);\n await managerPage.getByRole('button', { name: 'Actions' }).hover();\n await managerPage.getByRole('menuitem', { name: 'Edit' }).click();\n await expect(managerPage.getByText(`Hidden values (${REDACTED}) are kept when you save`)).toBeVisible();\n await expect(managerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);\n await expect(managerPage.getByPlaceholder('{\"key\": \"value\"}')).toHaveValue(new RegExp(`\"Authorization\": \"${REDACTED}\"`));\n const nameInput = managerPage.getByPlaceholder('My Webhook');\n await nameInput.fill(renamed);\n await expect(nameInput).toHaveValue(renamed);\n await managerPage.getByRole('button', { name: 'Save' }).first().click();\n await expect(managerPage.locator('pre').filter({ hasText: '\"x-source\": \"qa\"' })).toBeVisible({ timeout: 15_000 });\n } finally {\n await managerContext.close();\n }\n\n const adminDetail = await readDetail(request, adminToken, created.id);\n expect(adminDetail.name).toBe(renamed);\n expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-source': 'qa' });\n } finally {\n await deleteWebhookIfExists(request, adminToken, webhookId);\n await cleanupWebhooksUser(request, adminToken, viewer);\n await cleanupWebhooksUser(request, adminToken, manager);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -5,6 +5,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
6
|
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
7
7
|
import { WebhookDeliveryEntity, WebhookEntity } from "../data/entities.js";
|
|
8
|
+
import { redactWebhookCustomHeaders } from "../lib/custom-headers.js";
|
|
8
9
|
function json(payload, init = { status: 200 }) {
|
|
9
10
|
return new Response(JSON.stringify(payload), {
|
|
10
11
|
...init,
|
|
@@ -30,9 +31,18 @@ async function resolveWebhookRequestScope(request) {
|
|
|
30
31
|
em: container.resolve("em"),
|
|
31
32
|
tenantId,
|
|
32
33
|
organizationId,
|
|
33
|
-
allowedOrganizationIds
|
|
34
|
+
allowedOrganizationIds,
|
|
35
|
+
auth
|
|
34
36
|
};
|
|
35
37
|
}
|
|
38
|
+
async function canRevealWebhookCredentialHeaders(container, auth, webhook) {
|
|
39
|
+
if (!auth?.sub) return false;
|
|
40
|
+
const rbacService = container.resolve("rbacService");
|
|
41
|
+
return rbacService.userHasAllFeatures(auth.sub, ["webhooks.secrets"], {
|
|
42
|
+
tenantId: webhook.tenantId,
|
|
43
|
+
organizationId: webhook.organizationId
|
|
44
|
+
});
|
|
45
|
+
}
|
|
36
46
|
async function findScopedWebhook(em, scope, webhookId) {
|
|
37
47
|
const webhook = await findOneWithDecryption(
|
|
38
48
|
em,
|
|
@@ -61,6 +71,21 @@ async function findScopedDelivery(em, scope, deliveryId) {
|
|
|
61
71
|
}
|
|
62
72
|
return delivery;
|
|
63
73
|
}
|
|
74
|
+
async function translateWebhookCustomHeadersRejection(rejection) {
|
|
75
|
+
const { translate } = await resolveTranslations();
|
|
76
|
+
if (rejection.reason === "credentialHeadersRequireReentry") {
|
|
77
|
+
return translate(
|
|
78
|
+
"webhooks.errors.credentialHeadersRequireReentry",
|
|
79
|
+
'Header "{header}" has a hidden value. Enter it again or remove it when you change the webhook URL.',
|
|
80
|
+
{ header: rejection.header }
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return translate(
|
|
84
|
+
"webhooks.errors.redactedHeaderWithoutValue",
|
|
85
|
+
'Header "{header}" has a hidden value but no saved value to keep. Enter the header value.',
|
|
86
|
+
{ header: rejection.header }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
64
89
|
function serializeWebhookListItem(item) {
|
|
65
90
|
return {
|
|
66
91
|
id: item.id,
|
|
@@ -79,10 +104,10 @@ function serializeWebhookListItem(item) {
|
|
|
79
104
|
updatedAt: item.updatedAt.toISOString()
|
|
80
105
|
};
|
|
81
106
|
}
|
|
82
|
-
function serializeWebhookDetail(item) {
|
|
107
|
+
function serializeWebhookDetail(item, options = {}) {
|
|
83
108
|
return {
|
|
84
109
|
...serializeWebhookListItem(item),
|
|
85
|
-
customHeaders: item.customHeaders ?? null,
|
|
110
|
+
customHeaders: options.revealCredentialHeaders ? item.customHeaders ?? null : redactWebhookCustomHeaders(item.customHeaders),
|
|
86
111
|
strategyConfig: item.strategyConfig ?? null,
|
|
87
112
|
timeoutMs: item.timeoutMs,
|
|
88
113
|
rateLimitPerMinute: item.rateLimitPerMinute,
|
|
@@ -127,6 +152,7 @@ function maskSecret(secret) {
|
|
|
127
152
|
return `${secret.slice(0, 6)}\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`;
|
|
128
153
|
}
|
|
129
154
|
export {
|
|
155
|
+
canRevealWebhookCredentialHeaders,
|
|
130
156
|
findScopedDelivery,
|
|
131
157
|
findScopedWebhook,
|
|
132
158
|
json,
|
|
@@ -134,6 +160,7 @@ export {
|
|
|
134
160
|
serializeDeliveryDetail,
|
|
135
161
|
serializeDeliveryListItem,
|
|
136
162
|
serializeWebhookDetail,
|
|
137
|
-
serializeWebhookListItem
|
|
163
|
+
serializeWebhookListItem,
|
|
164
|
+
translateWebhookCustomHeadersRejection
|
|
138
165
|
};
|
|
139
166
|
//# sourceMappingURL=helpers.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/webhooks/api/helpers.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\n\nexport type WebhookRequestScope = {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n em: EntityManager\n tenantId: string\n organizationId: string | null\n allowedOrganizationIds: string[] | null\n}\n\nexport function json(payload: unknown, init: ResponseInit = { status: 200 }): Response {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nexport async function resolveWebhookRequestScope(request: Request): Promise<WebhookRequestScope | Response> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(request)\n const { translate } = await resolveTranslations()\n\n if (!auth) {\n return json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth)\n const allowedOrganizationIds = Array.isArray(scope?.allowedIds) ? scope.allowedIds : null\n\n if (!tenantId) {\n return json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n }\n\n return {\n container,\n em: container.resolve('em') as EntityManager,\n tenantId,\n organizationId,\n allowedOrganizationIds,\n }\n}\n\nexport async function findScopedWebhook(\n em: EntityManager,\n scope: Pick<WebhookRequestScope, 'tenantId' | 'organizationId' | 'allowedOrganizationIds'>,\n webhookId: string,\n): Promise<WebhookEntity | null> {\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n { id: webhookId, tenantId: scope.tenantId, deletedAt: null },\n {},\n { tenantId: scope.tenantId, organizationId: scope.organizationId ?? '' },\n )\n\n if (!webhook) return null\n if (scope.allowedOrganizationIds && scope.allowedOrganizationIds.length > 0 && !scope.allowedOrganizationIds.includes(webhook.organizationId)) {\n return null\n }\n if (!scope.allowedOrganizationIds && scope.organizationId && webhook.organizationId !== scope.organizationId) {\n return null\n }\n\n return webhook\n}\n\nexport async function findScopedDelivery(\n em: EntityManager,\n scope: Pick<WebhookRequestScope, 'tenantId' | 'organizationId' | 'allowedOrganizationIds'>,\n deliveryId: string,\n): Promise<WebhookDeliveryEntity | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, { id: deliveryId, tenantId: scope.tenantId })\n if (!delivery) return null\n\n if (scope.allowedOrganizationIds && scope.allowedOrganizationIds.length > 0 && !scope.allowedOrganizationIds.includes(delivery.organizationId)) {\n return null\n }\n if (!scope.allowedOrganizationIds && scope.organizationId && delivery.organizationId !== scope.organizationId) {\n return null\n }\n\n return delivery\n}\n\nexport function serializeWebhookListItem(item: WebhookEntity) {\n return {\n id: item.id,\n name: item.name,\n description: item.description ?? null,\n url: item.url,\n subscribedEvents: item.subscribedEvents,\n httpMethod: item.httpMethod,\n isActive: item.isActive,\n deliveryStrategy: item.deliveryStrategy,\n maxRetries: item.maxRetries,\n consecutiveFailures: item.consecutiveFailures,\n lastSuccessAt: item.lastSuccessAt?.toISOString() ?? null,\n lastFailureAt: item.lastFailureAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n updatedAt: item.updatedAt.toISOString(),\n }\n}\n\nexport function serializeWebhookDetail(item: WebhookEntity) {\n return {\n ...serializeWebhookListItem(item),\n customHeaders: item.customHeaders ?? null,\n strategyConfig: item.strategyConfig ?? null,\n timeoutMs: item.timeoutMs,\n rateLimitPerMinute: item.rateLimitPerMinute,\n autoDisableThreshold: item.autoDisableThreshold,\n integrationId: item.integrationId ?? null,\n maskedSecret: maskSecret(item.secret),\n previousSecretSetAt: item.previousSecretSetAt?.toISOString() ?? null,\n }\n}\n\nexport function serializeDeliveryListItem(\n item: WebhookDeliveryEntity,\n options: { webhookName?: string | null } = {},\n) {\n return {\n id: item.id,\n webhookId: item.webhookId,\n webhookName: options.webhookName ?? null,\n eventType: item.eventType,\n messageId: item.messageId,\n status: item.status,\n responseStatus: item.responseStatus ?? null,\n errorMessage: item.errorMessage ?? null,\n attemptNumber: item.attemptNumber,\n maxAttempts: item.maxAttempts,\n targetUrl: item.targetUrl,\n durationMs: item.durationMs ?? null,\n enqueuedAt: item.enqueuedAt.toISOString(),\n lastAttemptAt: item.lastAttemptAt?.toISOString() ?? null,\n deliveredAt: item.deliveredAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n }\n}\n\nexport function serializeDeliveryDetail(item: WebhookDeliveryEntity) {\n return {\n ...serializeDeliveryListItem(item),\n payload: item.payload,\n responseBody: item.responseBody ?? null,\n responseHeaders: item.responseHeaders ?? null,\n nextRetryAt: item.nextRetryAt?.toISOString() ?? null,\n updatedAt: item.updatedAt.toISOString(),\n }\n}\n\nfunction maskSecret(secret: string): string {\n if (!secret) return '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'\n return `${secret.slice(0, 6)}\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'\nimport { redactWebhookCustomHeaders, type WebhookCustomHeadersUpdate } from '../lib/custom-headers'\n\nexport type WebhookRequestScope = {\n container: Awaited<ReturnType<typeof createRequestContainer>>\n em: EntityManager\n tenantId: string\n organizationId: string | null\n allowedOrganizationIds: string[] | null\n auth?: AuthContext\n}\n\nexport function json(payload: unknown, init: ResponseInit = { status: 200 }): Response {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nexport async function resolveWebhookRequestScope(request: Request): Promise<WebhookRequestScope | Response> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(request)\n const { translate } = await resolveTranslations()\n\n if (!auth) {\n return json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth)\n const allowedOrganizationIds = Array.isArray(scope?.allowedIds) ? scope.allowedIds : null\n\n if (!tenantId) {\n return json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n }\n\n return {\n container,\n em: container.resolve('em') as EntityManager,\n tenantId,\n organizationId,\n allowedOrganizationIds,\n auth,\n }\n}\n\nexport async function canRevealWebhookCredentialHeaders(\n container: WebhookRequestScope['container'],\n auth: AuthContext | undefined,\n webhook: Pick<WebhookEntity, 'tenantId' | 'organizationId'>,\n): Promise<boolean> {\n if (!auth?.sub) return false\n const rbacService = container.resolve('rbacService') as RbacService\n return rbacService.userHasAllFeatures(auth.sub, ['webhooks.secrets'], {\n tenantId: webhook.tenantId,\n organizationId: webhook.organizationId,\n })\n}\n\nexport async function findScopedWebhook(\n em: EntityManager,\n scope: Pick<WebhookRequestScope, 'tenantId' | 'organizationId' | 'allowedOrganizationIds'>,\n webhookId: string,\n): Promise<WebhookEntity | null> {\n const webhook = await findOneWithDecryption(\n em,\n WebhookEntity,\n { id: webhookId, tenantId: scope.tenantId, deletedAt: null },\n {},\n { tenantId: scope.tenantId, organizationId: scope.organizationId ?? '' },\n )\n\n if (!webhook) return null\n if (scope.allowedOrganizationIds && scope.allowedOrganizationIds.length > 0 && !scope.allowedOrganizationIds.includes(webhook.organizationId)) {\n return null\n }\n if (!scope.allowedOrganizationIds && scope.organizationId && webhook.organizationId !== scope.organizationId) {\n return null\n }\n\n return webhook\n}\n\nexport async function findScopedDelivery(\n em: EntityManager,\n scope: Pick<WebhookRequestScope, 'tenantId' | 'organizationId' | 'allowedOrganizationIds'>,\n deliveryId: string,\n): Promise<WebhookDeliveryEntity | null> {\n const delivery = await em.findOne(WebhookDeliveryEntity, { id: deliveryId, tenantId: scope.tenantId })\n if (!delivery) return null\n\n if (scope.allowedOrganizationIds && scope.allowedOrganizationIds.length > 0 && !scope.allowedOrganizationIds.includes(delivery.organizationId)) {\n return null\n }\n if (!scope.allowedOrganizationIds && scope.organizationId && delivery.organizationId !== scope.organizationId) {\n return null\n }\n\n return delivery\n}\n\nexport async function translateWebhookCustomHeadersRejection(\n rejection: Extract<WebhookCustomHeadersUpdate, { ok: false }>,\n): Promise<string> {\n const { translate } = await resolveTranslations()\n if (rejection.reason === 'credentialHeadersRequireReentry') {\n return translate(\n 'webhooks.errors.credentialHeadersRequireReentry',\n 'Header \"{header}\" has a hidden value. Enter it again or remove it when you change the webhook URL.',\n { header: rejection.header },\n )\n }\n return translate(\n 'webhooks.errors.redactedHeaderWithoutValue',\n 'Header \"{header}\" has a hidden value but no saved value to keep. Enter the header value.',\n { header: rejection.header },\n )\n}\n\nexport function serializeWebhookListItem(item: WebhookEntity) {\n return {\n id: item.id,\n name: item.name,\n description: item.description ?? null,\n url: item.url,\n subscribedEvents: item.subscribedEvents,\n httpMethod: item.httpMethod,\n isActive: item.isActive,\n deliveryStrategy: item.deliveryStrategy,\n maxRetries: item.maxRetries,\n consecutiveFailures: item.consecutiveFailures,\n lastSuccessAt: item.lastSuccessAt?.toISOString() ?? null,\n lastFailureAt: item.lastFailureAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n updatedAt: item.updatedAt.toISOString(),\n }\n}\n\nexport function serializeWebhookDetail(\n item: WebhookEntity,\n options: { revealCredentialHeaders?: boolean } = {},\n) {\n return {\n ...serializeWebhookListItem(item),\n customHeaders: options.revealCredentialHeaders\n ? item.customHeaders ?? null\n : redactWebhookCustomHeaders(item.customHeaders),\n strategyConfig: item.strategyConfig ?? null,\n timeoutMs: item.timeoutMs,\n rateLimitPerMinute: item.rateLimitPerMinute,\n autoDisableThreshold: item.autoDisableThreshold,\n integrationId: item.integrationId ?? null,\n maskedSecret: maskSecret(item.secret),\n previousSecretSetAt: item.previousSecretSetAt?.toISOString() ?? null,\n }\n}\n\nexport function serializeDeliveryListItem(\n item: WebhookDeliveryEntity,\n options: { webhookName?: string | null } = {},\n) {\n return {\n id: item.id,\n webhookId: item.webhookId,\n webhookName: options.webhookName ?? null,\n eventType: item.eventType,\n messageId: item.messageId,\n status: item.status,\n responseStatus: item.responseStatus ?? null,\n errorMessage: item.errorMessage ?? null,\n attemptNumber: item.attemptNumber,\n maxAttempts: item.maxAttempts,\n targetUrl: item.targetUrl,\n durationMs: item.durationMs ?? null,\n enqueuedAt: item.enqueuedAt.toISOString(),\n lastAttemptAt: item.lastAttemptAt?.toISOString() ?? null,\n deliveredAt: item.deliveredAt?.toISOString() ?? null,\n createdAt: item.createdAt.toISOString(),\n }\n}\n\nexport function serializeDeliveryDetail(item: WebhookDeliveryEntity) {\n return {\n ...serializeDeliveryListItem(item),\n payload: item.payload,\n responseBody: item.responseBody ?? null,\n responseHeaders: item.responseHeaders ?? null,\n nextRetryAt: item.nextRetryAt?.toISOString() ?? null,\n updatedAt: item.updatedAt.toISOString(),\n }\n}\n\nfunction maskSecret(secret: string): string {\n if (!secret) return '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'\n return `${secret.slice(0, 6)}\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
3
3
|
import { emitWebhooksEvent } from "../../../events.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
canRevealWebhookCredentialHeaders,
|
|
6
|
+
findScopedWebhook,
|
|
7
|
+
json,
|
|
8
|
+
resolveWebhookRequestScope,
|
|
9
|
+
serializeWebhookDetail,
|
|
10
|
+
translateWebhookCustomHeadersRejection
|
|
11
|
+
} from "../../helpers.js";
|
|
5
12
|
import { webhookUpdateSchema } from "../../../data/validators.js";
|
|
6
13
|
import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
|
|
7
14
|
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
8
15
|
import { invalidateWebhookSubscriptionCacheFor } from "../../../lib/subscription-cache.js";
|
|
9
16
|
import { applyWebhookActiveState } from "../../../lib/activation.js";
|
|
17
|
+
import { planWebhookCustomHeadersUpdate } from "../../../lib/custom-headers.js";
|
|
10
18
|
const metadata = {
|
|
11
19
|
GET: { requireAuth: true, requireFeatures: ["webhooks.view"] },
|
|
12
20
|
PUT: { requireAuth: true, requireFeatures: ["webhooks.manage"] },
|
|
@@ -27,7 +35,9 @@ const webhookDetailResponseSchema = z.object({
|
|
|
27
35
|
lastFailureAt: z.string().nullable(),
|
|
28
36
|
createdAt: z.string(),
|
|
29
37
|
updatedAt: z.string(),
|
|
30
|
-
customHeaders: z.record(z.string(), z.string()).nullable()
|
|
38
|
+
customHeaders: z.record(z.string(), z.string()).nullable().describe(
|
|
39
|
+
"Credential-like header values are returned as \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 unless the caller has webhooks.secrets. Sending \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 back on update keeps the stored value."
|
|
40
|
+
),
|
|
31
41
|
strategyConfig: z.record(z.string(), z.unknown()).nullable(),
|
|
32
42
|
timeoutMs: z.number(),
|
|
33
43
|
rateLimitPerMinute: z.number(),
|
|
@@ -46,7 +56,8 @@ async function GET(request, context) {
|
|
|
46
56
|
if (!webhook) {
|
|
47
57
|
return json({ error: "Webhook not found" }, { status: 404 });
|
|
48
58
|
}
|
|
49
|
-
|
|
59
|
+
const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook);
|
|
60
|
+
return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }));
|
|
50
61
|
}
|
|
51
62
|
async function PUT(request, context) {
|
|
52
63
|
const scope = await resolveWebhookRequestScope(request);
|
|
@@ -73,12 +84,21 @@ async function PUT(request, context) {
|
|
|
73
84
|
return json({ error: "Invalid request payload" }, { status: 400 });
|
|
74
85
|
}
|
|
75
86
|
const input = parsed.data;
|
|
87
|
+
const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook);
|
|
88
|
+
const headersUpdate = planWebhookCustomHeadersUpdate({
|
|
89
|
+
current: webhook,
|
|
90
|
+
next: input,
|
|
91
|
+
canRevealCredentials: revealCredentialHeaders
|
|
92
|
+
});
|
|
93
|
+
if (!headersUpdate.ok) {
|
|
94
|
+
return json({ error: await translateWebhookCustomHeadersRejection(headersUpdate) }, { status: 400 });
|
|
95
|
+
}
|
|
76
96
|
if (input.name !== void 0) webhook.name = input.name;
|
|
77
97
|
if (input.description !== void 0) webhook.description = input.description;
|
|
78
98
|
if (input.url !== void 0) webhook.url = input.url;
|
|
79
99
|
if (input.subscribedEvents !== void 0) webhook.subscribedEvents = input.subscribedEvents;
|
|
80
100
|
if (input.httpMethod !== void 0) webhook.httpMethod = input.httpMethod;
|
|
81
|
-
if (
|
|
101
|
+
if (headersUpdate.customHeaders !== void 0) webhook.customHeaders = headersUpdate.customHeaders;
|
|
82
102
|
if (input.deliveryStrategy !== void 0) webhook.deliveryStrategy = input.deliveryStrategy;
|
|
83
103
|
if (input.strategyConfig !== void 0) webhook.strategyConfig = input.strategyConfig;
|
|
84
104
|
if (input.maxRetries !== void 0) webhook.maxRetries = input.maxRetries;
|
|
@@ -94,7 +114,7 @@ async function PUT(request, context) {
|
|
|
94
114
|
organizationId: webhook.organizationId,
|
|
95
115
|
tenantId: webhook.tenantId
|
|
96
116
|
}, { persistent: true });
|
|
97
|
-
return json(serializeWebhookDetail(webhook));
|
|
117
|
+
return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }));
|
|
98
118
|
}
|
|
99
119
|
async function DELETE(request, context) {
|
|
100
120
|
const scope = await resolveWebhookRequestScope(request);
|
|
@@ -152,7 +172,11 @@ const openApi = {
|
|
|
152
172
|
},
|
|
153
173
|
responses: [{ status: 200, description: "Webhook updated", schema: webhookDetailResponseSchema }],
|
|
154
174
|
errors: [
|
|
155
|
-
{
|
|
175
|
+
{
|
|
176
|
+
status: 400,
|
|
177
|
+
description: "Invalid payload, a hidden header value with no stored value, or a URL change that keeps hidden header values for a caller without webhooks.secrets",
|
|
178
|
+
schema: errorSchema
|
|
179
|
+
},
|
|
156
180
|
{ status: 401, description: "Unauthorized", schema: errorSchema },
|
|
157
181
|
{ status: 404, description: "Webhook not found", schema: errorSchema }
|
|
158
182
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/webhooks/api/webhooks/%5Bid%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAElB,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { emitWebhooksEvent } from '../../../events'\nimport {\n canRevealWebhookCredentialHeaders,\n findScopedWebhook,\n json,\n resolveWebhookRequestScope,\n serializeWebhookDetail,\n translateWebhookCustomHeadersRejection,\n} from '../../helpers'\nimport { webhookUpdateSchema } from '../../../data/validators'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'\nimport { applyWebhookActiveState } from '../../../lib/activation'\nimport { planWebhookCustomHeadersUpdate } from '../../../lib/custom-headers'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nconst webhookDetailResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n description: z.string().nullable(),\n url: z.string(),\n subscribedEvents: z.array(z.string()),\n httpMethod: z.string(),\n isActive: z.boolean(),\n deliveryStrategy: z.string(),\n maxRetries: z.number(),\n consecutiveFailures: z.number(),\n lastSuccessAt: z.string().nullable(),\n lastFailureAt: z.string().nullable(),\n createdAt: z.string(),\n updatedAt: z.string(),\n customHeaders: z.record(z.string(), z.string()).nullable().describe(\n 'Credential-like header values are returned as \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 unless the caller has webhooks.secrets. Sending \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 back on update keeps the stored value.',\n ),\n strategyConfig: z.record(z.string(), z.unknown()).nullable(),\n timeoutMs: z.number(),\n rateLimitPerMinute: z.number(),\n autoDisableThreshold: z.number(),\n integrationId: z.string().nullable(),\n maskedSecret: z.string(),\n previousSecretSetAt: z.string().nullable(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\n\nexport async function GET(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const webhook = await findScopedWebhook(scope.em.fork(), scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)\n return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))\n}\n\nexport async function PUT(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n\n if (!webhook) {\n return json({ error: 'Webhook not found' }, { status: 404 })\n }\n\n try {\n await enforceCommandOptimisticLockWithGuards(scope.container, {\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n const parsed = webhookUpdateSchema.safeParse(await request.json().catch(() => null))\n if (!parsed.success) {\n return json({ error: 'Invalid request payload' }, { status: 400 })\n }\n\n const input = parsed.data\n const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)\n const headersUpdate = planWebhookCustomHeadersUpdate({\n current: webhook,\n next: input,\n canRevealCredentials: revealCredentialHeaders,\n })\n if (!headersUpdate.ok) {\n return json({ error: await translateWebhookCustomHeadersRejection(headersUpdate) }, { status: 400 })\n }\n\n if (input.name !== undefined) webhook.name = input.name\n if (input.description !== undefined) webhook.description = input.description\n if (input.url !== undefined) webhook.url = input.url\n if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod\n if (headersUpdate.customHeaders !== undefined) webhook.customHeaders = headersUpdate.customHeaders\n if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) webhook.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) webhook.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) webhook.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) webhook.integrationId = input.integrationId\n if (input.isActive !== undefined) applyWebhookActiveState(webhook, input.isActive)\n\n await em.flush()\n\n await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)\n\n await emitWebhooksEvent('webhooks.webhook.updated', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))\n}\n\nexport async function DELETE(request: Request, context: RouteContext): Promise<Response> {\n const scope = await resolveWebhookRequestScope(request)\n if (scope instanceof Response) return scope\n\n const params = await context.params\n const em = scope.em.fork()\n const webhook = await findScopedWebhook(em, scope, params.id)\n const { translate } = await resolveTranslations()\n\n if (!webhook) {\n return json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n }\n\n try {\n await enforceCommandOptimisticLockWithGuards(scope.container, {\n resourceKind: 'webhooks.endpoint',\n resourceId: webhook.id,\n current: webhook.updatedAt ?? null,\n request,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return json(err.body, { status: err.status })\n throw err\n }\n\n webhook.deletedAt = new Date()\n await em.flush()\n\n await invalidateWebhookSubscriptionCacheFor(scope.container, webhook.tenantId)\n\n await emitWebhooksEvent('webhooks.webhook.deleted', {\n webhookId: webhook.id,\n organizationId: webhook.organizationId,\n tenantId: webhook.tenantId,\n }, { persistent: true })\n\n return json({ success: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Get webhook detail',\n description: 'Returns a single webhook configuration for the current tenant scope.',\n methods: {\n GET: {\n summary: 'Get webhook',\n description: 'Returns webhook configuration, masked secret metadata, and delivery settings.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook detail', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates a single webhook configuration.',\n pathParams: z.object({ id: z.string().uuid() }),\n requestBody: {\n contentType: 'application/json',\n schema: webhookUpdateSchema,\n description: 'Webhook fields to update.',\n },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n {\n status: 400,\n description: 'Invalid payload, a hidden header value with no stored value, or a URL change that keeps hidden header values for a caller without webhooks.secrets',\n schema: errorSchema,\n },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n pathParams: z.object({ id: z.string().uuid() }),\n responses: [{ status: 200, description: 'Webhook deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Webhook not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,6 +8,9 @@ import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern
|
|
|
8
8
|
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
9
9
|
import { invalidateWebhookSubscriptionCacheFor } from "../../lib/subscription-cache.js";
|
|
10
10
|
import { applyWebhookActiveState } from "../../lib/activation.js";
|
|
11
|
+
import { planWebhookCustomHeadersUpdate } from "../../lib/custom-headers.js";
|
|
12
|
+
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
13
|
+
import { canRevealWebhookCredentialHeaders, translateWebhookCustomHeadersRejection } from "../helpers.js";
|
|
11
14
|
function json(payload, init = { status: 200 }) {
|
|
12
15
|
return new Response(JSON.stringify(payload), {
|
|
13
16
|
...init,
|
|
@@ -46,7 +49,9 @@ const webhookCreateResponseSchema = z.object({
|
|
|
46
49
|
isActive: z.boolean()
|
|
47
50
|
});
|
|
48
51
|
const webhookDetailResponseSchema = webhookListItemSchema.extend({
|
|
49
|
-
customHeaders: z.record(z.string(), z.string()).nullable()
|
|
52
|
+
customHeaders: z.record(z.string(), z.string()).nullable().describe(
|
|
53
|
+
"Credential-like header values are returned as \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 unless the caller has webhooks.secrets. Sending \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 back on update keeps the stored value."
|
|
54
|
+
),
|
|
50
55
|
strategyConfig: z.record(z.string(), z.unknown()).nullable(),
|
|
51
56
|
timeoutMs: z.number(),
|
|
52
57
|
rateLimitPerMinute: z.number(),
|
|
@@ -106,13 +111,21 @@ const crud = makeCrudRoute({
|
|
|
106
111
|
},
|
|
107
112
|
update: {
|
|
108
113
|
schema: webhookUpdateSchema,
|
|
109
|
-
applyToEntity: (entity, input) => {
|
|
114
|
+
applyToEntity: async (entity, input, ctx) => {
|
|
115
|
+
const headersUpdate = planWebhookCustomHeadersUpdate({
|
|
116
|
+
current: entity,
|
|
117
|
+
next: input,
|
|
118
|
+
canRevealCredentials: await canRevealWebhookCredentialHeaders(ctx.container, ctx.auth, entity)
|
|
119
|
+
});
|
|
120
|
+
if (!headersUpdate.ok) {
|
|
121
|
+
throw new CrudHttpError(400, { error: await translateWebhookCustomHeadersRejection(headersUpdate) });
|
|
122
|
+
}
|
|
110
123
|
if (input.name !== void 0) entity.name = input.name;
|
|
111
124
|
if (input.description !== void 0) entity.description = input.description;
|
|
112
125
|
if (input.url !== void 0) entity.url = input.url;
|
|
113
126
|
if (input.subscribedEvents !== void 0) entity.subscribedEvents = input.subscribedEvents;
|
|
114
127
|
if (input.httpMethod !== void 0) entity.httpMethod = input.httpMethod;
|
|
115
|
-
if (
|
|
128
|
+
if (headersUpdate.customHeaders !== void 0) entity.customHeaders = headersUpdate.customHeaders;
|
|
116
129
|
if (input.deliveryStrategy !== void 0) entity.deliveryStrategy = input.deliveryStrategy;
|
|
117
130
|
if (input.strategyConfig !== void 0) entity.strategyConfig = input.strategyConfig;
|
|
118
131
|
if (input.maxRetries !== void 0) entity.maxRetries = input.maxRetries;
|
|
@@ -261,7 +274,11 @@ const openApi = {
|
|
|
261
274
|
requestBody: { contentType: "application/json", schema: webhookUpdateSchema, description: "Fields to update." },
|
|
262
275
|
responses: [{ status: 200, description: "Webhook updated", schema: webhookDetailResponseSchema }],
|
|
263
276
|
errors: [
|
|
264
|
-
{
|
|
277
|
+
{
|
|
278
|
+
status: 400,
|
|
279
|
+
description: "Invalid payload, a hidden header value with no stored value, or a URL change that keeps hidden header values for a caller without webhooks.secrets",
|
|
280
|
+
schema: errorSchema
|
|
281
|
+
},
|
|
265
282
|
{ status: 404, description: "Not found", schema: errorSchema }
|
|
266
283
|
]
|
|
267
284
|
},
|