@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.
Files changed (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.js +191 -0
  3. package/dist/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.js.map +7 -0
  4. package/dist/modules/webhooks/api/helpers.js +31 -4
  5. package/dist/modules/webhooks/api/helpers.js.map +2 -2
  6. package/dist/modules/webhooks/api/webhooks/[id]/route.js +30 -6
  7. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  8. package/dist/modules/webhooks/api/webhooks/route.js +21 -4
  9. package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
  10. package/dist/modules/webhooks/lib/custom-headers.js +60 -0
  11. package/dist/modules/webhooks/lib/custom-headers.js.map +2 -2
  12. package/package.json +7 -7
  13. package/src/modules/webhooks/__integration__/TC-WEBHOOK-013.spec.ts +231 -0
  14. package/src/modules/webhooks/api/helpers.ts +43 -2
  15. package/src/modules/webhooks/api/webhooks/[id]/__tests__/credential-headers.test.ts +358 -0
  16. package/src/modules/webhooks/api/webhooks/[id]/__tests__/optimistic-lock.test.ts +2 -0
  17. package/src/modules/webhooks/api/webhooks/[id]/__tests__/reactivation.test.ts +2 -0
  18. package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +2 -0
  19. package/src/modules/webhooks/api/webhooks/[id]/route.ts +31 -6
  20. package/src/modules/webhooks/api/webhooks/__tests__/credential-headers.test.ts +96 -0
  21. package/src/modules/webhooks/api/webhooks/route.ts +21 -4
  22. package/src/modules/webhooks/i18n/de.json +3 -1
  23. package/src/modules/webhooks/i18n/en.json +3 -1
  24. package/src/modules/webhooks/i18n/es.json +3 -1
  25. package/src/modules/webhooks/i18n/ko.json +3 -1
  26. package/src/modules/webhooks/i18n/pl.json +3 -1
  27. package/src/modules/webhooks/lib/__tests__/custom-headers.test.ts +144 -0
  28. package/src/modules/webhooks/lib/custom-headers.ts +84 -0
@@ -0,0 +1,358 @@
1
+ /** @jest-environment node */
2
+
3
+ const WEBHOOK_ID = '123e4567-e89b-12d3-a456-426614174237'
4
+ const REDACTED = '••••••••'
5
+
6
+ type StoredWebhook = Record<string, unknown> & { customHeaders: Record<string, string> | null }
7
+
8
+ function makeWebhook(): StoredWebhook {
9
+ return {
10
+ id: WEBHOOK_ID,
11
+ name: 'Hook',
12
+ description: null,
13
+ url: 'https://example.com/hook',
14
+ secret: 'whsec_abcdefghijklmnop',
15
+ subscribedEvents: ['catalog.product.created'],
16
+ httpMethod: 'POST',
17
+ isActive: true,
18
+ deliveryStrategy: 'http',
19
+ maxRetries: 3,
20
+ consecutiveFailures: 0,
21
+ lastSuccessAt: null,
22
+ lastFailureAt: null,
23
+ createdAt: new Date('2026-06-01T10:00:00.000Z'),
24
+ updatedAt: new Date('2026-06-01T10:00:00.000Z'),
25
+ customHeaders: {
26
+ Authorization: 'Bearer live-destination-token',
27
+ 'x-api-key': 'dest-api-key-123',
28
+ 'x-mock-webhook-token': 'shared-secret',
29
+ 'x-source': 'open-mercato',
30
+ },
31
+ strategyConfig: null,
32
+ timeoutMs: 5000,
33
+ rateLimitPerMinute: 0,
34
+ autoDisableThreshold: 5,
35
+ integrationId: null,
36
+ previousSecretSetAt: null,
37
+ organizationId: 'org-1',
38
+ tenantId: 'tenant-1',
39
+ deletedAt: null,
40
+ }
41
+ }
42
+
43
+ let webhookRecord = makeWebhook()
44
+ let grantedFeatures: string[] = []
45
+
46
+ const mockRbac = {
47
+ userHasAllFeatures: jest.fn(async (_sub: string, required: string[]) =>
48
+ required.every((feature) => grantedFeatures.includes(feature) || grantedFeatures.includes('webhooks.*')),
49
+ ),
50
+ loadAcl: jest.fn(async () => ({ isSuperAdmin: false, features: grantedFeatures, organizations: null })),
51
+ }
52
+
53
+ const mockEm = {
54
+ fork: jest.fn(() => mockEm),
55
+ flush: jest.fn(async () => undefined),
56
+ }
57
+
58
+ const mockContainer = {
59
+ resolve: (token: string) => {
60
+ if (token === 'em') return mockEm
61
+ if (token === 'rbacService') return mockRbac
62
+ if (token === 'cache') return null
63
+ if (token === 'commandOptimisticLockGuardService') throw new Error('not registered')
64
+ return null
65
+ },
66
+ }
67
+
68
+ const authContext = { sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1', roles: [] }
69
+
70
+ jest.mock('../../../../events', () => ({
71
+ emitWebhooksEvent: jest.fn(async () => undefined),
72
+ }))
73
+
74
+ jest.mock('../../../../lib/subscription-cache', () => ({
75
+ invalidateWebhookSubscriptionCacheFor: jest.fn(async () => undefined),
76
+ }))
77
+
78
+ jest.mock('../../../helpers', () => {
79
+ const actual = jest.requireActual('../../../helpers')
80
+ return {
81
+ ...actual,
82
+ resolveWebhookRequestScope: jest.fn(async () => ({
83
+ container: mockContainer,
84
+ em: mockEm,
85
+ tenantId: 'tenant-1',
86
+ organizationId: 'org-1',
87
+ allowedOrganizationIds: null,
88
+ auth: authContext,
89
+ })),
90
+ findScopedWebhook: jest.fn(async () => webhookRecord),
91
+ }
92
+ })
93
+
94
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
95
+ resolveTranslations: jest.fn(async () => ({
96
+ translate: (_key: string, fallback?: string, params?: Record<string, string>) =>
97
+ (fallback ?? '').replace(/\{(\w+)\}/g, (_match, name: string) => params?.[name] ?? ''),
98
+ })),
99
+ }))
100
+
101
+ import { GET, PUT } from '../route'
102
+
103
+ const context = { params: Promise.resolve({ id: WEBHOOK_ID }) }
104
+
105
+ function request(method: string, body?: unknown) {
106
+ return new Request(`http://localhost/api/webhooks/${WEBHOOK_ID}`, {
107
+ method,
108
+ headers: { 'content-type': 'application/json' },
109
+ body: body !== undefined ? JSON.stringify(body) : undefined,
110
+ })
111
+ }
112
+
113
+ async function readHeaders(res: Response): Promise<Record<string, string> | null> {
114
+ const body = (await res.json()) as { customHeaders: Record<string, string> | null }
115
+ return body.customHeaders
116
+ }
117
+
118
+ describe('webhook custom credential headers (issue #7237)', () => {
119
+ beforeEach(() => {
120
+ jest.clearAllMocks()
121
+ webhookRecord = makeWebhook()
122
+ })
123
+
124
+ describe('GET', () => {
125
+ it('redacts credential-like header values for a webhooks.view-only caller', async () => {
126
+ grantedFeatures = ['webhooks.view']
127
+
128
+ const headers = await readHeaders(await GET(request('GET'), context))
129
+
130
+ expect(headers).toEqual({
131
+ Authorization: REDACTED,
132
+ 'x-api-key': REDACTED,
133
+ 'x-mock-webhook-token': REDACTED,
134
+ 'x-source': 'open-mercato',
135
+ })
136
+ })
137
+
138
+ it('keeps the response shape: header names stay visible, values stay strings', async () => {
139
+ grantedFeatures = ['webhooks.view']
140
+
141
+ const headers = await readHeaders(await GET(request('GET'), context))
142
+
143
+ expect(Object.keys(headers ?? {}).sort()).toEqual(
144
+ ['Authorization', 'x-api-key', 'x-mock-webhook-token', 'x-source'].sort(),
145
+ )
146
+ for (const value of Object.values(headers ?? {})) expect(typeof value).toBe('string')
147
+ })
148
+
149
+ it('redacts for webhooks.manage without webhooks.secrets', async () => {
150
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
151
+
152
+ const headers = await readHeaders(await GET(request('GET'), context))
153
+
154
+ expect(headers?.Authorization).toBe(REDACTED)
155
+ })
156
+
157
+ it('returns values verbatim to a webhooks.secrets holder', async () => {
158
+ grantedFeatures = ['webhooks.view', 'webhooks.secrets']
159
+
160
+ const headers = await readHeaders(await GET(request('GET'), context))
161
+
162
+ expect(headers).toEqual(makeWebhook().customHeaders)
163
+ })
164
+
165
+ it('checks webhooks.secrets in the scope of the webhook being returned', async () => {
166
+ grantedFeatures = ['webhooks.view']
167
+
168
+ await GET(request('GET'), context)
169
+
170
+ expect(mockRbac.userHasAllFeatures).toHaveBeenCalledWith('user-1', ['webhooks.secrets'], {
171
+ tenantId: 'tenant-1',
172
+ organizationId: 'org-1',
173
+ })
174
+ })
175
+
176
+ it('returns null headers unchanged', async () => {
177
+ grantedFeatures = ['webhooks.view']
178
+ webhookRecord.customHeaders = null
179
+
180
+ expect(await readHeaders(await GET(request('GET'), context))).toBeNull()
181
+ })
182
+ })
183
+
184
+ describe('PUT', () => {
185
+ beforeEach(() => {
186
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
187
+ })
188
+
189
+ it('keeps stored credential values when the redaction marker is echoed back', async () => {
190
+ const res = await PUT(
191
+ request('PUT', {
192
+ name: 'Renamed',
193
+ customHeaders: {
194
+ Authorization: REDACTED,
195
+ 'x-api-key': REDACTED,
196
+ 'x-mock-webhook-token': REDACTED,
197
+ 'x-source': 'erp',
198
+ },
199
+ }),
200
+ context,
201
+ )
202
+
203
+ expect(res.status).toBe(200)
204
+ expect(webhookRecord.customHeaders).toEqual({
205
+ Authorization: 'Bearer live-destination-token',
206
+ 'x-api-key': 'dest-api-key-123',
207
+ 'x-mock-webhook-token': 'shared-secret',
208
+ 'x-source': 'erp',
209
+ })
210
+ expect(webhookRecord.name).toBe('Renamed')
211
+ })
212
+
213
+ it('matches the stored header case-insensitively when the marker is echoed', async () => {
214
+ await PUT(request('PUT', { customHeaders: { authorization: REDACTED } }), context)
215
+
216
+ expect(webhookRecord.customHeaders).toEqual({ authorization: 'Bearer live-destination-token' })
217
+ })
218
+
219
+ it('replaces a credential value when a new one is sent', async () => {
220
+ await PUT(request('PUT', { customHeaders: { Authorization: 'Bearer rotated' } }), context)
221
+
222
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer rotated' })
223
+ })
224
+
225
+ it('drops a credential header the caller omits', async () => {
226
+ await PUT(request('PUT', { customHeaders: { 'x-source': 'erp' } }), context)
227
+
228
+ expect(webhookRecord.customHeaders).toEqual({ 'x-source': 'erp' })
229
+ })
230
+
231
+ it('rejects the marker for a header that has no stored value instead of saving it', async () => {
232
+ const res = await PUT(request('PUT', { customHeaders: { 'x-new-token': REDACTED } }), context)
233
+
234
+ expect(res.status).toBe(400)
235
+ expect(webhookRecord.customHeaders).toEqual(makeWebhook().customHeaders)
236
+ })
237
+
238
+ it('redacts the PUT response the same way as GET for a caller without webhooks.secrets', async () => {
239
+ const res = await PUT(request('PUT', { name: 'Renamed' }), context)
240
+
241
+ expect((await readHeaders(res))?.Authorization).toBe(REDACTED)
242
+ })
243
+ it('names the header in the orphan-marker error', async () => {
244
+ const res = await PUT(request('PUT', { customHeaders: { 'x-new-token': REDACTED } }), context)
245
+
246
+ expect(((await res.json()) as { error: string }).error).toContain('x-new-token')
247
+ })
248
+
249
+ it('stores a value that only starts with the marker as a new value', async () => {
250
+ await PUT(request('PUT', { customHeaders: { Authorization: `${REDACTED}x` } }), context)
251
+
252
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: `${REDACTED}x` })
253
+ })
254
+
255
+ it('clears all headers when customHeaders is null', async () => {
256
+ await PUT(request('PUT', { customHeaders: null }), context)
257
+
258
+ expect(webhookRecord.customHeaders).toBeNull()
259
+ })
260
+
261
+ it('rejects an ambiguous marker when two stored names differ only by case', async () => {
262
+ webhookRecord.customHeaders = { Authorization: 'Bearer a', authorization: 'Bearer b' }
263
+
264
+ const res = await PUT(request('PUT', { customHeaders: { AUTHORIZATION: REDACTED } }), context)
265
+
266
+ expect(res.status).toBe(400)
267
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer a', authorization: 'Bearer b' })
268
+ })
269
+
270
+ it('matches a stored name that differs only by surrounding whitespace', async () => {
271
+ webhookRecord.customHeaders = { ' Authorization': 'Bearer padded' }
272
+
273
+ await PUT(request('PUT', { customHeaders: { Authorization: REDACTED } }), context)
274
+
275
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer padded' })
276
+ })
277
+ })
278
+
279
+ describe('PUT with a changed URL', () => {
280
+ it('refuses to keep hidden credential values for a caller without webhooks.secrets', async () => {
281
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
282
+
283
+ const res = await PUT(
284
+ request('PUT', {
285
+ url: 'https://attacker.example.net/collect',
286
+ customHeaders: { Authorization: REDACTED, 'x-source': 'open-mercato' },
287
+ }),
288
+ context,
289
+ )
290
+
291
+ expect(res.status).toBe(400)
292
+ expect(((await res.json()) as { error: string }).error).toContain('Authorization')
293
+ expect(webhookRecord.url).toBe('https://example.com/hook')
294
+ expect(webhookRecord.customHeaders).toEqual(makeWebhook().customHeaders)
295
+ })
296
+
297
+ it('refuses when customHeaders is omitted and stored credential headers would follow the new URL', async () => {
298
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
299
+
300
+ const res = await PUT(request('PUT', { url: 'https://attacker.example.net/collect' }), context)
301
+
302
+ expect(res.status).toBe(400)
303
+ expect(webhookRecord.url).toBe('https://example.com/hook')
304
+ })
305
+
306
+ it('accepts the move when the caller re-enters or removes every credential header', async () => {
307
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
308
+
309
+ const res = await PUT(
310
+ request('PUT', {
311
+ url: 'https://other.example.net/hook',
312
+ customHeaders: { Authorization: 'Bearer re-entered', 'x-source': 'open-mercato' },
313
+ }),
314
+ context,
315
+ )
316
+
317
+ expect(res.status).toBe(200)
318
+ expect(webhookRecord.url).toBe('https://other.example.net/hook')
319
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer re-entered', 'x-source': 'open-mercato' })
320
+ })
321
+
322
+ it('refuses when only the path changes on the same host', async () => {
323
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
324
+
325
+ const res = await PUT(
326
+ request('PUT', { url: 'https://example.com/attacker-bin', customHeaders: { Authorization: REDACTED } }),
327
+ context,
328
+ )
329
+
330
+ expect(res.status).toBe(400)
331
+ expect(webhookRecord.url).toBe('https://example.com/hook')
332
+ })
333
+
334
+ it('keeps hidden values when the unchanged URL is sent back', async () => {
335
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
336
+
337
+ const res = await PUT(
338
+ request('PUT', { name: 'Renamed', url: 'https://example.com/hook', customHeaders: { Authorization: REDACTED } }),
339
+ context,
340
+ )
341
+
342
+ expect(res.status).toBe(200)
343
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer live-destination-token' })
344
+ })
345
+
346
+ it('lets a webhooks.secrets holder keep stored values on a new host', async () => {
347
+ grantedFeatures = ['webhooks.view', 'webhooks.manage', 'webhooks.secrets']
348
+
349
+ const res = await PUT(
350
+ request('PUT', { url: 'https://other.example.net/hook', customHeaders: { Authorization: REDACTED } }),
351
+ context,
352
+ )
353
+
354
+ expect(res.status).toBe(200)
355
+ expect(webhookRecord.customHeaders).toEqual({ Authorization: 'Bearer live-destination-token' })
356
+ })
357
+ })
358
+ })
@@ -56,6 +56,8 @@ jest.mock('../../../helpers', () => ({
56
56
  }),
57
57
  resolveWebhookRequestScope: jest.fn(async () => ({ container: mockContainer, em: mockEm, tenantId: 'tenant-1', organizationId: 'org-1' })),
58
58
  findScopedWebhook: jest.fn(async () => (webhookRecord.deletedAt ? null : webhookRecord)),
59
+ canRevealWebhookCredentialHeaders: jest.fn(async () => true),
60
+ translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
59
61
  serializeWebhookDetail: (item: { id: string; updatedAt: Date }) => ({
60
62
  id: item.id,
61
63
  updatedAt: item.updatedAt.toISOString(),
@@ -46,6 +46,8 @@ jest.mock('../../../helpers', () => ({
46
46
  organizationId: 'org-1',
47
47
  })),
48
48
  findScopedWebhook: jest.fn(async () => webhookRecord),
49
+ canRevealWebhookCredentialHeaders: jest.fn(async () => true),
50
+ translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
49
51
  serializeWebhookDetail: (item: { id: string; isActive: boolean; consecutiveFailures: number }) => ({
50
52
  id: item.id,
51
53
  isActive: item.isActive,
@@ -55,6 +55,8 @@ jest.mock('../../../helpers', () => ({
55
55
  organizationId: 'org-1',
56
56
  })),
57
57
  findScopedWebhook: jest.fn(async () => webhookRecord),
58
+ canRevealWebhookCredentialHeaders: jest.fn(async () => true),
59
+ translateWebhookCustomHeadersRejection: jest.fn(async () => 'rejected'),
58
60
  serializeWebhookDetail: (item: { id: string }) => ({ id: item.id }),
59
61
  }))
60
62
 
@@ -2,12 +2,20 @@ import { z } from 'zod'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
4
  import { emitWebhooksEvent } from '../../../events'
5
- import { findScopedWebhook, json, resolveWebhookRequestScope, serializeWebhookDetail } from '../../helpers'
5
+ import {
6
+ canRevealWebhookCredentialHeaders,
7
+ findScopedWebhook,
8
+ json,
9
+ resolveWebhookRequestScope,
10
+ serializeWebhookDetail,
11
+ translateWebhookCustomHeadersRejection,
12
+ } from '../../helpers'
6
13
  import { webhookUpdateSchema } from '../../../data/validators'
7
14
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
8
15
  import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
16
  import { invalidateWebhookSubscriptionCacheFor } from '../../../lib/subscription-cache'
10
17
  import { applyWebhookActiveState } from '../../../lib/activation'
18
+ import { planWebhookCustomHeadersUpdate } from '../../../lib/custom-headers'
11
19
 
12
20
  export const metadata = {
13
21
  GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },
@@ -34,7 +42,9 @@ const webhookDetailResponseSchema = z.object({
34
42
  lastFailureAt: z.string().nullable(),
35
43
  createdAt: z.string(),
36
44
  updatedAt: z.string(),
37
- customHeaders: z.record(z.string(), z.string()).nullable(),
45
+ customHeaders: z.record(z.string(), z.string()).nullable().describe(
46
+ 'Credential-like header values are returned as •••••••• unless the caller has webhooks.secrets. Sending •••••••• back on update keeps the stored value.',
47
+ ),
38
48
  strategyConfig: z.record(z.string(), z.unknown()).nullable(),
39
49
  timeoutMs: z.number(),
40
50
  rateLimitPerMinute: z.number(),
@@ -58,7 +68,8 @@ export async function GET(request: Request, context: RouteContext): Promise<Resp
58
68
  return json({ error: 'Webhook not found' }, { status: 404 })
59
69
  }
60
70
 
61
- return json(serializeWebhookDetail(webhook))
71
+ const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)
72
+ return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))
62
73
  }
63
74
 
64
75
  export async function PUT(request: Request, context: RouteContext): Promise<Response> {
@@ -91,12 +102,22 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
91
102
  }
92
103
 
93
104
  const input = parsed.data
105
+ const revealCredentialHeaders = await canRevealWebhookCredentialHeaders(scope.container, scope.auth, webhook)
106
+ const headersUpdate = planWebhookCustomHeadersUpdate({
107
+ current: webhook,
108
+ next: input,
109
+ canRevealCredentials: revealCredentialHeaders,
110
+ })
111
+ if (!headersUpdate.ok) {
112
+ return json({ error: await translateWebhookCustomHeadersRejection(headersUpdate) }, { status: 400 })
113
+ }
114
+
94
115
  if (input.name !== undefined) webhook.name = input.name
95
116
  if (input.description !== undefined) webhook.description = input.description
96
117
  if (input.url !== undefined) webhook.url = input.url
97
118
  if (input.subscribedEvents !== undefined) webhook.subscribedEvents = input.subscribedEvents
98
119
  if (input.httpMethod !== undefined) webhook.httpMethod = input.httpMethod
99
- if (input.customHeaders !== undefined) webhook.customHeaders = input.customHeaders
120
+ if (headersUpdate.customHeaders !== undefined) webhook.customHeaders = headersUpdate.customHeaders
100
121
  if (input.deliveryStrategy !== undefined) webhook.deliveryStrategy = input.deliveryStrategy
101
122
  if (input.strategyConfig !== undefined) webhook.strategyConfig = input.strategyConfig
102
123
  if (input.maxRetries !== undefined) webhook.maxRetries = input.maxRetries
@@ -116,7 +137,7 @@ export async function PUT(request: Request, context: RouteContext): Promise<Resp
116
137
  tenantId: webhook.tenantId,
117
138
  }, { persistent: true })
118
139
 
119
- return json(serializeWebhookDetail(webhook))
140
+ return json(serializeWebhookDetail(webhook, { revealCredentialHeaders }))
120
141
  }
121
142
 
122
143
  export async function DELETE(request: Request, context: RouteContext): Promise<Response> {
@@ -183,7 +204,11 @@ export const openApi: OpenApiRouteDoc = {
183
204
  },
184
205
  responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],
185
206
  errors: [
186
- { status: 400, description: 'Invalid payload', schema: errorSchema },
207
+ {
208
+ status: 400,
209
+ 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',
210
+ schema: errorSchema,
211
+ },
187
212
  { status: 401, description: 'Unauthorized', schema: errorSchema },
188
213
  { status: 404, description: 'Webhook not found', schema: errorSchema },
189
214
  ],
@@ -0,0 +1,96 @@
1
+ /** @jest-environment node */
2
+
3
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+
5
+ type ApplyToEntity = (entity: Record<string, unknown>, input: Record<string, unknown>, ctx: unknown) => Promise<void> | void
6
+
7
+ const captured: { applyToEntity: ApplyToEntity | null } = { applyToEntity: null }
8
+
9
+ jest.mock('@open-mercato/shared/lib/crud/factory', () => ({
10
+ makeCrudRoute: jest.fn((options: { update?: { applyToEntity?: ApplyToEntity } }) => {
11
+ captured.applyToEntity = options.update?.applyToEntity ?? null
12
+ return { metadata: {}, GET: jest.fn(), POST: jest.fn(), PUT: jest.fn(), DELETE: jest.fn() }
13
+ }),
14
+ }))
15
+
16
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
17
+ resolveTranslations: jest.fn(async () => ({ translate: (_key: string, fallback?: string) => fallback ?? '' })),
18
+ }))
19
+
20
+ import '../route'
21
+
22
+ const REDACTED = '••••••••'
23
+
24
+ let grantedFeatures: string[] = []
25
+ const userHasAllFeatures = jest.fn(async (_sub: string, required: string[]) =>
26
+ required.every((feature) => grantedFeatures.includes(feature)),
27
+ )
28
+ const ctx = {
29
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' },
30
+ container: { resolve: (token: string) => (token === 'rbacService' ? { userHasAllFeatures } : null) },
31
+ }
32
+
33
+ function makeEntity() {
34
+ return {
35
+ id: 'webhook-1',
36
+ name: 'Hook',
37
+ url: 'https://example.com/hook',
38
+ tenantId: 'tenant-1',
39
+ organizationId: 'org-1',
40
+ customHeaders: { Authorization: 'Bearer stored', 'x-source': 'qa' } as Record<string, string> | null,
41
+ }
42
+ }
43
+
44
+ async function applyExpectingError(entity: Record<string, unknown>, input: Record<string, unknown>) {
45
+ try {
46
+ await captured.applyToEntity?.(entity, input, ctx)
47
+ } catch (error) {
48
+ return error
49
+ }
50
+ throw new Error('[internal] expected applyToEntity to throw')
51
+ }
52
+
53
+ describe('PUT /api/webhooks credential header handling (issue #7237)', () => {
54
+ beforeEach(() => {
55
+ jest.clearAllMocks()
56
+ grantedFeatures = ['webhooks.view', 'webhooks.manage']
57
+ })
58
+
59
+ it('keeps the stored value when the redaction marker is echoed', async () => {
60
+ const entity = makeEntity()
61
+
62
+ await captured.applyToEntity?.(entity, { name: 'Renamed', customHeaders: { Authorization: REDACTED, 'x-source': 'erp' } }, ctx)
63
+
64
+ expect(entity.customHeaders).toEqual({ Authorization: 'Bearer stored', 'x-source': 'erp' })
65
+ expect(entity.name).toBe('Renamed')
66
+ expect(userHasAllFeatures).toHaveBeenCalledWith('user-1', ['webhooks.secrets'], { tenantId: 'tenant-1', organizationId: 'org-1' })
67
+ })
68
+
69
+ it('throws a 400 and changes nothing for a marker without a stored value', async () => {
70
+ const entity = makeEntity()
71
+
72
+ const error = await applyExpectingError(entity, { name: 'Renamed', customHeaders: { 'x-token': REDACTED } })
73
+
74
+ expect(isCrudHttpError(error) && error.status).toBe(400)
75
+ expect(entity).toEqual(makeEntity())
76
+ })
77
+
78
+ it('throws a 400 when stored credentials would follow a new host for a caller without webhooks.secrets', async () => {
79
+ const entity = makeEntity()
80
+
81
+ const error = await applyExpectingError(entity, { url: 'https://attacker.example.net/collect' })
82
+
83
+ expect(isCrudHttpError(error) && error.status).toBe(400)
84
+ expect(entity).toEqual(makeEntity())
85
+ })
86
+
87
+ it('lets a webhooks.secrets holder move stored credentials to a new host', async () => {
88
+ grantedFeatures = ['webhooks.view', 'webhooks.manage', 'webhooks.secrets']
89
+ const entity = makeEntity()
90
+
91
+ await captured.applyToEntity?.(entity, { url: 'https://other.example.net/hook' }, ctx)
92
+
93
+ expect(entity.url).toBe('https://other.example.net/hook')
94
+ expect(entity.customHeaders).toEqual(makeEntity().customHeaders)
95
+ })
96
+ })
@@ -12,6 +12,9 @@ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
12
12
  import type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'
13
13
  import { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'
14
14
  import { applyWebhookActiveState } from '../../lib/activation'
15
+ import { planWebhookCustomHeadersUpdate } from '../../lib/custom-headers'
16
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
17
+ import { canRevealWebhookCredentialHeaders, translateWebhookCustomHeadersRejection } from '../helpers'
15
18
 
16
19
  type WebhookCrudCtx = CrudCtx & {
17
20
  __webhookSecret?: string
@@ -59,7 +62,9 @@ const webhookCreateResponseSchema = z.object({
59
62
  })
60
63
 
61
64
  const webhookDetailResponseSchema = webhookListItemSchema.extend({
62
- customHeaders: z.record(z.string(), z.string()).nullable(),
65
+ customHeaders: z.record(z.string(), z.string()).nullable().describe(
66
+ 'Credential-like header values are returned as •••••••• unless the caller has webhooks.secrets. Sending •••••••• back on update keeps the stored value.',
67
+ ),
63
68
  strategyConfig: z.record(z.string(), z.unknown()).nullable(),
64
69
  timeoutMs: z.number(),
65
70
  rateLimitPerMinute: z.number(),
@@ -122,13 +127,21 @@ const crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeo
122
127
  },
123
128
  update: {
124
129
  schema: webhookUpdateSchema,
125
- applyToEntity: (entity, input) => {
130
+ applyToEntity: async (entity: WebhookEntity, input, ctx) => {
131
+ const headersUpdate = planWebhookCustomHeadersUpdate({
132
+ current: entity,
133
+ next: input,
134
+ canRevealCredentials: await canRevealWebhookCredentialHeaders(ctx.container, ctx.auth, entity),
135
+ })
136
+ if (!headersUpdate.ok) {
137
+ throw new CrudHttpError(400, { error: await translateWebhookCustomHeadersRejection(headersUpdate) })
138
+ }
126
139
  if (input.name !== undefined) entity.name = input.name
127
140
  if (input.description !== undefined) entity.description = input.description
128
141
  if (input.url !== undefined) entity.url = input.url
129
142
  if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents
130
143
  if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod
131
- if (input.customHeaders !== undefined) entity.customHeaders = input.customHeaders
144
+ if (headersUpdate.customHeaders !== undefined) entity.customHeaders = headersUpdate.customHeaders
132
145
  if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy
133
146
  if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig
134
147
  if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries
@@ -291,7 +304,11 @@ export const openApi: OpenApiRouteDoc = {
291
304
  requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },
292
305
  responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],
293
306
  errors: [
294
- { status: 400, description: 'Invalid payload', schema: errorSchema },
307
+ {
308
+ status: 400,
309
+ 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',
310
+ schema: errorSchema,
311
+ },
295
312
  { status: 404, description: 'Not found', schema: errorSchema },
296
313
  ],
297
314
  },
@@ -45,9 +45,11 @@
45
45
  "webhooks.detail.testResult": "Latest Test Result",
46
46
  "webhooks.detail.testSuccess": "Test delivery succeeded.",
47
47
  "webhooks.detail.title": "Webhook Detail",
48
+ "webhooks.errors.credentialHeadersRequireReentry": "Der Header \"{header}\" hat einen verborgenen Wert. Geben Sie ihn erneut ein oder entfernen Sie ihn, wenn Sie die Webhook-URL ändern.",
48
49
  "webhooks.errors.forbidden": "You do not have permission to perform this action.",
49
50
  "webhooks.errors.notFound": "Webhook not found.",
50
51
  "webhooks.errors.orgRequired": "Organization context required.",
52
+ "webhooks.errors.redactedHeaderWithoutValue": "Der Header \"{header}\" hat einen verborgenen Wert, aber keinen gespeicherten Wert, der beibehalten werden kann. Geben Sie den Header-Wert ein.",
51
53
  "webhooks.errors.tenantRequired": "Tenant context required.",
52
54
  "webhooks.form.advancedStrategyTip": "HTTP delivery is active today. Use custom headers for routing hints, and keep secrets out of request headers.",
53
55
  "webhooks.form.autoDisableThreshold": "Auto-Disable Threshold",
@@ -55,7 +57,7 @@
55
57
  "webhooks.form.createError": "Failed to create webhook.",
56
58
  "webhooks.form.createSuccess": "Webhook created successfully.",
57
59
  "webhooks.form.customHeaders": "Custom Headers",
58
- "webhooks.form.customHeadersHint": "Optional JSON object of additional headers to send with each webhook request.",
60
+ "webhooks.form.customHeadersHint": "Optionales JSON-Objekt mit zusätzlichen Headern, die mit jeder Webhook-Anfrage gesendet werden. Verborgene Werte (••••••••) bleiben beim Speichern erhalten; ersetzen Sie einen Wert, um ihn zu ändern.",
59
61
  "webhooks.form.customHeadersInvalid": "Custom headers must be a valid JSON object with string values.",
60
62
  "webhooks.form.customHeadersTip": "Use headers for static metadata such as environment or source identifiers. Do not put access tokens or secrets here.",
61
63
  "webhooks.form.deliveryDefaultsTip": "The defaults are a safe starting point for most consumers. Increase retries or timeout only if the receiver is known to be slow.",
@@ -45,9 +45,11 @@
45
45
  "webhooks.detail.testResult": "Latest Test Result",
46
46
  "webhooks.detail.testSuccess": "Test delivery succeeded.",
47
47
  "webhooks.detail.title": "Webhook Detail",
48
+ "webhooks.errors.credentialHeadersRequireReentry": "Header \"{header}\" has a hidden value. Enter it again or remove it when you change the webhook URL.",
48
49
  "webhooks.errors.forbidden": "You do not have permission to perform this action.",
49
50
  "webhooks.errors.notFound": "Webhook not found.",
50
51
  "webhooks.errors.orgRequired": "Organization context required.",
52
+ "webhooks.errors.redactedHeaderWithoutValue": "Header \"{header}\" has a hidden value but no saved value to keep. Enter the header value.",
51
53
  "webhooks.errors.tenantRequired": "Tenant context required.",
52
54
  "webhooks.form.advancedStrategyTip": "HTTP delivery is active today. Use custom headers for routing hints, and keep secrets out of request headers.",
53
55
  "webhooks.form.autoDisableThreshold": "Auto-Disable Threshold",
@@ -55,7 +57,7 @@
55
57
  "webhooks.form.createError": "Failed to create webhook.",
56
58
  "webhooks.form.createSuccess": "Webhook created successfully.",
57
59
  "webhooks.form.customHeaders": "Custom Headers",
58
- "webhooks.form.customHeadersHint": "Optional JSON object of additional headers to send with each webhook request.",
60
+ "webhooks.form.customHeadersHint": "Optional JSON object of additional headers to send with each webhook request. Hidden values (••••••••) are kept when you save; replace one to change it.",
59
61
  "webhooks.form.customHeadersInvalid": "Custom headers must be a valid JSON object with string values.",
60
62
  "webhooks.form.customHeadersTip": "Use headers for static metadata such as environment or source identifiers. Do not put access tokens or secrets here.",
61
63
  "webhooks.form.deliveryDefaultsTip": "The defaults are a safe starting point for most consumers. Increase retries or timeout only if the receiver is known to be slow.",