@open-mercato/webhooks 0.10.1-develop.7618.1.ba9531c18e → 0.10.1-develop.7619.1.97f9a4bda8

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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/webhooks/api/webhooks/route.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { WebhookEntity } from '../../data/entities'\nimport { webhookCreateSchema, webhookUpdateSchema, webhookListQuerySchema } from '../../data/validators'\nimport { generateWebhookSecret } from '@open-mercato/shared/lib/webhooks'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'\nimport { applyWebhookActiveState } from '../../lib/activation'\n\ntype WebhookCrudCtx = CrudCtx & {\n __webhookSecret?: string\n}\n\nfunction json(payload: unknown, init: ResponseInit = { status: 200 }) {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nconst webhookListItemSchema = 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})\n\nconst webhookCollectionResponseSchema = z.object({\n items: z.array(webhookListItemSchema),\n total: z.number().int().nonnegative(),\n page: z.number().int().positive(),\n pageSize: z.number().int().positive(),\n totalPages: z.number().int().nonnegative(),\n})\n\nconst webhookCreateResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n url: z.string(),\n secret: z.string(),\n subscribedEvents: z.array(z.string()),\n isActive: z.boolean(),\n})\n\nconst webhookDetailResponseSchema = webhookListItemSchema.extend({\n customHeaders: z.record(z.string(), z.string()).nullable(),\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})\n\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\nconst errorSchema = z.object({ error: z.string() })\n\nfunction resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {\n const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId\n if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId\n return ctx.auth?.tenantId ?? null\n}\n\nconst crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({\n metadata: {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n POST: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n },\n orm: { entity: WebhookEntity, orgField: 'organizationId' },\n events: { module: 'webhooks', entity: 'webhook', persistent: true },\n list: { schema: webhookListQuerySchema },\n create: {\n schema: webhookCreateSchema,\n mapToEntity: (input, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n const secret = scopedCtx.__webhookSecret\n if (!secret) throw new Error('Webhook secret not prepared')\n return {\n name: input.name,\n description: input.description ?? null,\n url: input.url,\n secret,\n subscribedEvents: input.subscribedEvents,\n httpMethod: input.httpMethod ?? 'POST',\n customHeaders: input.customHeaders ?? null,\n deliveryStrategy: input.deliveryStrategy ?? 'http',\n strategyConfig: input.strategyConfig ?? null,\n maxRetries: input.maxRetries ?? 10,\n timeoutMs: input.timeoutMs ?? 15000,\n rateLimitPerMinute: input.rateLimitPerMinute ?? 0,\n autoDisableThreshold: input.autoDisableThreshold ?? 100,\n integrationId: input.integrationId ?? null,\n organizationId: ctx.auth?.orgId ?? '',\n tenantId: ctx.auth?.tenantId ?? '',\n }\n },\n response: (entity) => ({\n id: entity.id,\n name: entity.name,\n url: entity.url,\n secret: (entity as WebhookEntity & { __revealSecret?: string }).__revealSecret ?? '***',\n subscribedEvents: entity.subscribedEvents,\n isActive: entity.isActive,\n }),\n },\n update: {\n schema: webhookUpdateSchema,\n applyToEntity: (entity, input) => {\n if (input.name !== undefined) entity.name = input.name\n if (input.description !== undefined) entity.description = input.description\n if (input.url !== undefined) entity.url = input.url\n if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod\n if (input.customHeaders !== undefined) entity.customHeaders = input.customHeaders\n if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) entity.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) entity.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) entity.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) entity.integrationId = input.integrationId\n if (input.isActive !== undefined) applyWebhookActiveState(entity, input.isActive)\n },\n },\n del: { idFrom: 'query' },\n hooks: {\n beforeList: async (query, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const page = Math.max(parseInt(query.page ?? '1', 10) || 1, 1)\n const pageSize = Math.min(Math.max(parseInt(query.pageSize ?? '20', 10) || 20, 1), 100)\n const search = (query.search ?? '').trim().toLowerCase()\n\n const organizationIds = Array.isArray(ctx.organizationIds) ? ctx.organizationIds : null\n if (organizationIds && organizationIds.length === 0) {\n throw json({ items: [], total: 0, page, pageSize, totalPages: 0 })\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const qb = em.createQueryBuilder(WebhookEntity, 'w')\n qb.where({ deletedAt: null })\n qb.andWhere({ tenantId: auth.tenantId })\n\n if (organizationIds && organizationIds.length > 0) {\n qb.andWhere({ organizationId: { $in: organizationIds } })\n } else if (auth.orgId) {\n qb.andWhere({ organizationId: auth.orgId })\n }\n\n if (search) {\n const pattern = `%${escapeLikePattern(search)}%`\n qb.andWhere({\n $or: [\n { name: { $ilike: pattern } },\n { url: { $ilike: pattern } },\n ],\n })\n }\n\n if (query.isActive !== undefined && query.isActive !== '') {\n const active = parseBooleanToken(query.isActive)\n if (active !== null) {\n qb.andWhere({ isActive: active })\n }\n }\n\n qb.orderBy({ createdAt: 'desc' })\n qb.limit(pageSize).offset((page - 1) * pageSize)\n const [items, total] = await qb.getResultAndCount()\n\n const payload = {\n items: items.map((item) => ({\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 total,\n page,\n pageSize,\n totalPages: Math.ceil(total / pageSize),\n }\n\n throw json(payload)\n },\n beforeCreate: async (input, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n if (!auth?.orgId) throw json({ error: translate('webhooks.errors.orgRequired', 'Organization context required') }, { status: 400 })\n\n const scopedCtx = ctx as WebhookCrudCtx\n scopedCtx.__webhookSecret = generateWebhookSecret()\n\n return input\n },\n afterCreate: async (entity, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n if (scopedCtx.__webhookSecret) {\n ;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret\n }\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterUpdate: async (entity, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterDelete: async (_id, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)\n },\n beforeDelete: async (id, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const em = ctx.container.resolve('em') as EntityManager\n const record = await em.findOne(WebhookEntity, { id, deletedAt: null, tenantId: auth.tenantId })\n if (!record) throw json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n\n const allowedIds = ctx.organizationScope?.allowedIds ?? null\n if (record.organizationId && Array.isArray(allowedIds) && allowedIds.length > 0) {\n if (!allowedIds.includes(record.organizationId)) {\n throw json({ error: translate('webhooks.errors.forbidden', 'Forbidden') }, { status: 403 })\n }\n }\n },\n },\n})\n\nexport const metadata = crud.metadata\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage webhooks',\n description: 'CRUD operations for webhook endpoints following the Standard Webhooks specification.',\n methods: {\n GET: {\n summary: 'List webhooks',\n description: 'Returns paginated webhooks for the current tenant and organization.',\n query: webhookListQuerySchema,\n responses: [{ status: 200, description: 'Webhook collection', schema: webhookCollectionResponseSchema }],\n errors: [\n { status: 400, description: 'Tenant context missing', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create webhook',\n description: 'Creates a new webhook endpoint. A signing secret (whsec_ prefixed) is auto-generated and returned once.',\n requestBody: { contentType: 'application/json', schema: webhookCreateSchema, description: 'Webhook configuration.' },\n responses: [{ status: 201, description: 'Webhook created', schema: webhookCreateResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates an existing webhook configuration.',\n requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },\n responses: [{ status: 200, description: 'Webhook updated', schema: webhookDetailResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 404, description: 'Not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n query: z.object({ id: z.string().uuid().describe('Webhook ID to delete') }),\n responses: [{ status: 200, description: 'Deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 404, description: 'Not found', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { WebhookEntity } from '../../data/entities'\nimport { webhookCreateSchema, webhookUpdateSchema, webhookListQuerySchema } from '../../data/validators'\nimport { generateWebhookSecret } from '@open-mercato/shared/lib/webhooks'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport type { WebhookCreateInput, WebhookUpdateInput } from '../../data/validators'\nimport { invalidateWebhookSubscriptionCacheFor } from '../../lib/subscription-cache'\nimport { applyWebhookActiveState } from '../../lib/activation'\nimport { planWebhookCustomHeadersUpdate } from '../../lib/custom-headers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { canRevealWebhookCredentialHeaders, translateWebhookCustomHeadersRejection } from '../helpers'\n\ntype WebhookCrudCtx = CrudCtx & {\n __webhookSecret?: string\n}\n\nfunction json(payload: unknown, init: ResponseInit = { status: 200 }) {\n return new Response(JSON.stringify(payload), {\n ...init,\n headers: { 'content-type': 'application/json', ...(init.headers || {}) },\n })\n}\n\nconst webhookListItemSchema = 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})\n\nconst webhookCollectionResponseSchema = z.object({\n items: z.array(webhookListItemSchema),\n total: z.number().int().nonnegative(),\n page: z.number().int().positive(),\n pageSize: z.number().int().positive(),\n totalPages: z.number().int().nonnegative(),\n})\n\nconst webhookCreateResponseSchema = z.object({\n id: z.string(),\n name: z.string(),\n url: z.string(),\n secret: z.string(),\n subscribedEvents: z.array(z.string()),\n isActive: z.boolean(),\n})\n\nconst webhookDetailResponseSchema = webhookListItemSchema.extend({\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})\n\nconst deleteResponseSchema = z.object({ success: z.literal(true) })\nconst errorSchema = z.object({ error: z.string() })\n\nfunction resolveTenantIdForInvalidation(entity: unknown, ctx: CrudCtx): string | null {\n const entityTenantId = (entity as { tenantId?: unknown } | null)?.tenantId\n if (typeof entityTenantId === 'string' && entityTenantId.length > 0) return entityTenantId\n return ctx.auth?.tenantId ?? null\n}\n\nconst crud = makeCrudRoute<WebhookCreateInput, WebhookUpdateInput, z.infer<typeof webhookListQuerySchema>>({\n metadata: {\n GET: { requireAuth: true, requireFeatures: ['webhooks.view'] },\n POST: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['webhooks.manage'] },\n },\n orm: { entity: WebhookEntity, orgField: 'organizationId' },\n events: { module: 'webhooks', entity: 'webhook', persistent: true },\n list: { schema: webhookListQuerySchema },\n create: {\n schema: webhookCreateSchema,\n mapToEntity: (input, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n const secret = scopedCtx.__webhookSecret\n if (!secret) throw new Error('Webhook secret not prepared')\n return {\n name: input.name,\n description: input.description ?? null,\n url: input.url,\n secret,\n subscribedEvents: input.subscribedEvents,\n httpMethod: input.httpMethod ?? 'POST',\n customHeaders: input.customHeaders ?? null,\n deliveryStrategy: input.deliveryStrategy ?? 'http',\n strategyConfig: input.strategyConfig ?? null,\n maxRetries: input.maxRetries ?? 10,\n timeoutMs: input.timeoutMs ?? 15000,\n rateLimitPerMinute: input.rateLimitPerMinute ?? 0,\n autoDisableThreshold: input.autoDisableThreshold ?? 100,\n integrationId: input.integrationId ?? null,\n organizationId: ctx.auth?.orgId ?? '',\n tenantId: ctx.auth?.tenantId ?? '',\n }\n },\n response: (entity) => ({\n id: entity.id,\n name: entity.name,\n url: entity.url,\n secret: (entity as WebhookEntity & { __revealSecret?: string }).__revealSecret ?? '***',\n subscribedEvents: entity.subscribedEvents,\n isActive: entity.isActive,\n }),\n },\n update: {\n schema: webhookUpdateSchema,\n applyToEntity: async (entity: WebhookEntity, input, ctx) => {\n const headersUpdate = planWebhookCustomHeadersUpdate({\n current: entity,\n next: input,\n canRevealCredentials: await canRevealWebhookCredentialHeaders(ctx.container, ctx.auth, entity),\n })\n if (!headersUpdate.ok) {\n throw new CrudHttpError(400, { error: await translateWebhookCustomHeadersRejection(headersUpdate) })\n }\n if (input.name !== undefined) entity.name = input.name\n if (input.description !== undefined) entity.description = input.description\n if (input.url !== undefined) entity.url = input.url\n if (input.subscribedEvents !== undefined) entity.subscribedEvents = input.subscribedEvents\n if (input.httpMethod !== undefined) entity.httpMethod = input.httpMethod\n if (headersUpdate.customHeaders !== undefined) entity.customHeaders = headersUpdate.customHeaders\n if (input.deliveryStrategy !== undefined) entity.deliveryStrategy = input.deliveryStrategy\n if (input.strategyConfig !== undefined) entity.strategyConfig = input.strategyConfig\n if (input.maxRetries !== undefined) entity.maxRetries = input.maxRetries\n if (input.timeoutMs !== undefined) entity.timeoutMs = input.timeoutMs\n if (input.rateLimitPerMinute !== undefined) entity.rateLimitPerMinute = input.rateLimitPerMinute\n if (input.autoDisableThreshold !== undefined) entity.autoDisableThreshold = input.autoDisableThreshold\n if (input.integrationId !== undefined) entity.integrationId = input.integrationId\n if (input.isActive !== undefined) applyWebhookActiveState(entity, input.isActive)\n },\n },\n del: { idFrom: 'query' },\n hooks: {\n beforeList: async (query, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const page = Math.max(parseInt(query.page ?? '1', 10) || 1, 1)\n const pageSize = Math.min(Math.max(parseInt(query.pageSize ?? '20', 10) || 20, 1), 100)\n const search = (query.search ?? '').trim().toLowerCase()\n\n const organizationIds = Array.isArray(ctx.organizationIds) ? ctx.organizationIds : null\n if (organizationIds && organizationIds.length === 0) {\n throw json({ items: [], total: 0, page, pageSize, totalPages: 0 })\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const qb = em.createQueryBuilder(WebhookEntity, 'w')\n qb.where({ deletedAt: null })\n qb.andWhere({ tenantId: auth.tenantId })\n\n if (organizationIds && organizationIds.length > 0) {\n qb.andWhere({ organizationId: { $in: organizationIds } })\n } else if (auth.orgId) {\n qb.andWhere({ organizationId: auth.orgId })\n }\n\n if (search) {\n const pattern = `%${escapeLikePattern(search)}%`\n qb.andWhere({\n $or: [\n { name: { $ilike: pattern } },\n { url: { $ilike: pattern } },\n ],\n })\n }\n\n if (query.isActive !== undefined && query.isActive !== '') {\n const active = parseBooleanToken(query.isActive)\n if (active !== null) {\n qb.andWhere({ isActive: active })\n }\n }\n\n qb.orderBy({ createdAt: 'desc' })\n qb.limit(pageSize).offset((page - 1) * pageSize)\n const [items, total] = await qb.getResultAndCount()\n\n const payload = {\n items: items.map((item) => ({\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 total,\n page,\n pageSize,\n totalPages: Math.ceil(total / pageSize),\n }\n\n throw json(payload)\n },\n beforeCreate: async (input, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n if (!auth?.orgId) throw json({ error: translate('webhooks.errors.orgRequired', 'Organization context required') }, { status: 400 })\n\n const scopedCtx = ctx as WebhookCrudCtx\n scopedCtx.__webhookSecret = generateWebhookSecret()\n\n return input\n },\n afterCreate: async (entity, ctx) => {\n const scopedCtx = ctx as WebhookCrudCtx\n if (scopedCtx.__webhookSecret) {\n ;(entity as WebhookEntity & { __revealSecret?: string }).__revealSecret = scopedCtx.__webhookSecret\n }\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterUpdate: async (entity, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, resolveTenantIdForInvalidation(entity, ctx))\n },\n afterDelete: async (_id, ctx) => {\n await invalidateWebhookSubscriptionCacheFor(ctx.container, ctx.auth?.tenantId ?? null)\n },\n beforeDelete: async (id, ctx) => {\n const auth = ctx.auth\n const { translate } = await resolveTranslations()\n if (!auth?.tenantId) throw json({ error: translate('webhooks.errors.tenantRequired', 'Tenant context required') }, { status: 400 })\n\n const em = ctx.container.resolve('em') as EntityManager\n const record = await em.findOne(WebhookEntity, { id, deletedAt: null, tenantId: auth.tenantId })\n if (!record) throw json({ error: translate('webhooks.errors.notFound', 'Webhook not found') }, { status: 404 })\n\n const allowedIds = ctx.organizationScope?.allowedIds ?? null\n if (record.organizationId && Array.isArray(allowedIds) && allowedIds.length > 0) {\n if (!allowedIds.includes(record.organizationId)) {\n throw json({ error: translate('webhooks.errors.forbidden', 'Forbidden') }, { status: 403 })\n }\n }\n },\n },\n})\n\nexport const metadata = crud.metadata\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Manage webhooks',\n description: 'CRUD operations for webhook endpoints following the Standard Webhooks specification.',\n methods: {\n GET: {\n summary: 'List webhooks',\n description: 'Returns paginated webhooks for the current tenant and organization.',\n query: webhookListQuerySchema,\n responses: [{ status: 200, description: 'Webhook collection', schema: webhookCollectionResponseSchema }],\n errors: [\n { status: 400, description: 'Tenant context missing', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n POST: {\n summary: 'Create webhook',\n description: 'Creates a new webhook endpoint. A signing secret (whsec_ prefixed) is auto-generated and returned once.',\n requestBody: { contentType: 'application/json', schema: webhookCreateSchema, description: 'Webhook configuration.' },\n responses: [{ status: 201, description: 'Webhook created', schema: webhookCreateResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update webhook',\n description: 'Updates an existing webhook configuration.',\n requestBody: { contentType: 'application/json', schema: webhookUpdateSchema, description: 'Fields to update.' },\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: 404, description: 'Not found', schema: errorSchema },\n ],\n },\n DELETE: {\n summary: 'Delete webhook',\n description: 'Soft-deletes a webhook endpoint.',\n query: z.object({ id: z.string().uuid().describe('Webhook ID to delete') }),\n responses: [{ status: 200, description: 'Deleted', schema: deleteResponseSchema }],\n errors: [\n { status: 404, description: 'Not found', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -10,8 +10,68 @@ function sanitizeWebhookCustomHeaders(customHeaders) {
10
10
  Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name))
11
11
  );
12
12
  }
13
+ const WEBHOOK_REDACTED_HEADER_VALUE = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
14
+ const CREDENTIAL_HEADER_NAME_PATTERN = /(auth|token|secret|pass|pwd|key|credential|sig|hmac|session|cookie|jwt|bearer|private)/i;
15
+ const CREDENTIAL_HEADER_VALUE_PATTERN = /^(?:(?:bearer|basic|digest|token)\s|eyJ|sk_(?:live|test)_|rk_(?:live|test)_|gh[pousr]_|github_pat_|xox[abpr]-|glpat-)/i;
16
+ function isCredentialWebhookHeader(name, value) {
17
+ if (CREDENTIAL_HEADER_NAME_PATTERN.test(name.trim())) return true;
18
+ return typeof value === "string" && CREDENTIAL_HEADER_VALUE_PATTERN.test(value.trim());
19
+ }
20
+ function redactWebhookCustomHeaders(customHeaders) {
21
+ if (!customHeaders) return null;
22
+ return Object.fromEntries(
23
+ Object.entries(customHeaders).map(([name, value]) => [
24
+ name,
25
+ isCredentialWebhookHeader(name, value) ? WEBHOOK_REDACTED_HEADER_VALUE : value
26
+ ])
27
+ );
28
+ }
29
+ function findStoredHeaderValue(name, stored) {
30
+ if (Object.prototype.hasOwnProperty.call(stored, name)) return stored[name];
31
+ const normalized = name.trim().toLowerCase();
32
+ const matches = Object.keys(stored).filter((storedName) => storedName.trim().toLowerCase() === normalized);
33
+ return matches.length === 1 ? stored[matches[0]] : null;
34
+ }
35
+ function resolveRedactedWebhookCustomHeaders(next, stored) {
36
+ const headers = {};
37
+ const keptStoredValues = [];
38
+ for (const [name, value] of Object.entries(next)) {
39
+ if (value !== WEBHOOK_REDACTED_HEADER_VALUE) {
40
+ headers[name] = value;
41
+ continue;
42
+ }
43
+ const storedValue = stored ? findStoredHeaderValue(name, stored) : null;
44
+ if (storedValue === null || storedValue === WEBHOOK_REDACTED_HEADER_VALUE) return { ok: false, header: name };
45
+ headers[name] = storedValue;
46
+ if (isCredentialWebhookHeader(name, storedValue)) keptStoredValues.push(name);
47
+ }
48
+ return { ok: true, headers, keptStoredValues };
49
+ }
50
+ function planWebhookCustomHeadersUpdate(input) {
51
+ const { current, next, canRevealCredentials } = input;
52
+ let customHeaders = next.customHeaders;
53
+ let keptCredentialHeaders = [];
54
+ if (next.customHeaders === void 0) {
55
+ keptCredentialHeaders = Object.entries(current.customHeaders ?? {}).filter(([name, value]) => isCredentialWebhookHeader(name, value)).map(([name]) => name);
56
+ } else if (next.customHeaders !== null) {
57
+ const resolved = resolveRedactedWebhookCustomHeaders(next.customHeaders, current.customHeaders);
58
+ if (!resolved.ok) return { ok: false, reason: "redactedHeaderWithoutValue", header: resolved.header };
59
+ customHeaders = resolved.headers;
60
+ keptCredentialHeaders = resolved.keptStoredValues;
61
+ }
62
+ const urlChanged = next.url !== void 0 && next.url !== current.url;
63
+ if (!canRevealCredentials && urlChanged && keptCredentialHeaders.length > 0) {
64
+ return { ok: false, reason: "credentialHeadersRequireReentry", header: keptCredentialHeaders[0] };
65
+ }
66
+ return { ok: true, customHeaders };
67
+ }
13
68
  export {
69
+ WEBHOOK_REDACTED_HEADER_VALUE,
70
+ isCredentialWebhookHeader,
14
71
  isReservedWebhookCustomHeader,
72
+ planWebhookCustomHeadersUpdate,
73
+ redactWebhookCustomHeaders,
74
+ resolveRedactedWebhookCustomHeaders,
15
75
  sanitizeWebhookCustomHeaders
16
76
  };
17
77
  //# sourceMappingURL=custom-headers.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/webhooks/lib/custom-headers.ts"],
4
- "sourcesContent": ["const RESERVED_HEADER_PREFIX = 'webhook-'\nconst RESERVED_HEADER_NAMES = new Set(['content-type'])\n\nexport function isReservedWebhookCustomHeader(name: string): boolean {\n const normalized = name.trim().toLowerCase()\n return normalized.startsWith(RESERVED_HEADER_PREFIX) || RESERVED_HEADER_NAMES.has(normalized)\n}\n\nexport function sanitizeWebhookCustomHeaders(\n customHeaders: Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!customHeaders) return {}\n return Object.fromEntries(\n Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name)),\n )\n}\n"],
5
- "mappings": "AAAA,MAAM,yBAAyB;AAC/B,MAAM,wBAAwB,oBAAI,IAAI,CAAC,cAAc,CAAC;AAE/C,SAAS,8BAA8B,MAAuB;AACnE,QAAM,aAAa,KAAK,KAAK,EAAE,YAAY;AAC3C,SAAO,WAAW,WAAW,sBAAsB,KAAK,sBAAsB,IAAI,UAAU;AAC9F;AAEO,SAAS,6BACd,eACwB;AACxB,MAAI,CAAC,cAAe,QAAO,CAAC;AAC5B,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,8BAA8B,IAAI,CAAC;AAAA,EACvF;AACF;",
4
+ "sourcesContent": ["const RESERVED_HEADER_PREFIX = 'webhook-'\nconst RESERVED_HEADER_NAMES = new Set(['content-type'])\n\nexport function isReservedWebhookCustomHeader(name: string): boolean {\n const normalized = name.trim().toLowerCase()\n return normalized.startsWith(RESERVED_HEADER_PREFIX) || RESERVED_HEADER_NAMES.has(normalized)\n}\n\nexport function sanitizeWebhookCustomHeaders(\n customHeaders: Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!customHeaders) return {}\n return Object.fromEntries(\n Object.entries(customHeaders).filter(([name]) => !isReservedWebhookCustomHeader(name)),\n )\n}\n\nexport const WEBHOOK_REDACTED_HEADER_VALUE = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'\n\nconst CREDENTIAL_HEADER_NAME_PATTERN = /(auth|token|secret|pass|pwd|key|credential|sig|hmac|session|cookie|jwt|bearer|private)/i\nconst CREDENTIAL_HEADER_VALUE_PATTERN = /^(?:(?:bearer|basic|digest|token)\\s|eyJ|sk_(?:live|test)_|rk_(?:live|test)_|gh[pousr]_|github_pat_|xox[abpr]-|glpat-)/i\n\nexport function isCredentialWebhookHeader(name: string, value?: string | null): boolean {\n if (CREDENTIAL_HEADER_NAME_PATTERN.test(name.trim())) return true\n return typeof value === 'string' && CREDENTIAL_HEADER_VALUE_PATTERN.test(value.trim())\n}\n\nexport function redactWebhookCustomHeaders(\n customHeaders: Record<string, string> | null | undefined,\n): Record<string, string> | null {\n if (!customHeaders) return null\n return Object.fromEntries(\n Object.entries(customHeaders).map(([name, value]) => [\n name,\n isCredentialWebhookHeader(name, value) ? WEBHOOK_REDACTED_HEADER_VALUE : value,\n ]),\n )\n}\n\nexport type ResolvedWebhookCustomHeaders =\n | { ok: true; headers: Record<string, string>; keptStoredValues: string[] }\n | { ok: false; header: string }\n\nfunction findStoredHeaderValue(name: string, stored: Record<string, string>): string | null {\n if (Object.prototype.hasOwnProperty.call(stored, name)) return stored[name]\n const normalized = name.trim().toLowerCase()\n const matches = Object.keys(stored).filter((storedName) => storedName.trim().toLowerCase() === normalized)\n return matches.length === 1 ? stored[matches[0]] : null\n}\n\nexport function resolveRedactedWebhookCustomHeaders(\n next: Record<string, string>,\n stored: Record<string, string> | null | undefined,\n): ResolvedWebhookCustomHeaders {\n const headers: Record<string, string> = {}\n const keptStoredValues: string[] = []\n for (const [name, value] of Object.entries(next)) {\n if (value !== WEBHOOK_REDACTED_HEADER_VALUE) {\n headers[name] = value\n continue\n }\n const storedValue = stored ? findStoredHeaderValue(name, stored) : null\n if (storedValue === null || storedValue === WEBHOOK_REDACTED_HEADER_VALUE) return { ok: false, header: name }\n headers[name] = storedValue\n if (isCredentialWebhookHeader(name, storedValue)) keptStoredValues.push(name)\n }\n return { ok: true, headers, keptStoredValues }\n}\n\nexport type WebhookCustomHeadersUpdate =\n | { ok: true; customHeaders: Record<string, string> | null | undefined }\n | { ok: false; reason: 'redactedHeaderWithoutValue' | 'credentialHeadersRequireReentry'; header: string }\n\nexport function planWebhookCustomHeadersUpdate(input: {\n current: { url: string; customHeaders?: Record<string, string> | null }\n next: { url?: string; customHeaders?: Record<string, string> | null }\n canRevealCredentials: boolean\n}): WebhookCustomHeadersUpdate {\n const { current, next, canRevealCredentials } = input\n let customHeaders: Record<string, string> | null | undefined = next.customHeaders\n let keptCredentialHeaders: string[] = []\n\n if (next.customHeaders === undefined) {\n keptCredentialHeaders = Object.entries(current.customHeaders ?? {})\n .filter(([name, value]) => isCredentialWebhookHeader(name, value))\n .map(([name]) => name)\n } else if (next.customHeaders !== null) {\n const resolved = resolveRedactedWebhookCustomHeaders(next.customHeaders, current.customHeaders)\n if (!resolved.ok) return { ok: false, reason: 'redactedHeaderWithoutValue', header: resolved.header }\n customHeaders = resolved.headers\n keptCredentialHeaders = resolved.keptStoredValues\n }\n\n const urlChanged = next.url !== undefined && next.url !== current.url\n if (!canRevealCredentials && urlChanged && keptCredentialHeaders.length > 0) {\n return { ok: false, reason: 'credentialHeadersRequireReentry', header: keptCredentialHeaders[0] }\n }\n\n return { ok: true, customHeaders }\n}\n"],
5
+ "mappings": "AAAA,MAAM,yBAAyB;AAC/B,MAAM,wBAAwB,oBAAI,IAAI,CAAC,cAAc,CAAC;AAE/C,SAAS,8BAA8B,MAAuB;AACnE,QAAM,aAAa,KAAK,KAAK,EAAE,YAAY;AAC3C,SAAO,WAAW,WAAW,sBAAsB,KAAK,sBAAsB,IAAI,UAAU;AAC9F;AAEO,SAAS,6BACd,eACwB;AACxB,MAAI,CAAC,cAAe,QAAO,CAAC;AAC5B,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,MAAM,CAAC,8BAA8B,IAAI,CAAC;AAAA,EACvF;AACF;AAEO,MAAM,gCAAgC;AAE7C,MAAM,iCAAiC;AACvC,MAAM,kCAAkC;AAEjC,SAAS,0BAA0B,MAAc,OAAgC;AACtF,MAAI,+BAA+B,KAAK,KAAK,KAAK,CAAC,EAAG,QAAO;AAC7D,SAAO,OAAO,UAAU,YAAY,gCAAgC,KAAK,MAAM,KAAK,CAAC;AACvF;AAEO,SAAS,2BACd,eAC+B;AAC/B,MAAI,CAAC,cAAe,QAAO;AAC3B,SAAO,OAAO;AAAA,IACZ,OAAO,QAAQ,aAAa,EAAE,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM;AAAA,MACnD;AAAA,MACA,0BAA0B,MAAM,KAAK,IAAI,gCAAgC;AAAA,IAC3E,CAAC;AAAA,EACH;AACF;AAMA,SAAS,sBAAsB,MAAc,QAA+C;AAC1F,MAAI,OAAO,UAAU,eAAe,KAAK,QAAQ,IAAI,EAAG,QAAO,OAAO,IAAI;AAC1E,QAAM,aAAa,KAAK,KAAK,EAAE,YAAY;AAC3C,QAAM,UAAU,OAAO,KAAK,MAAM,EAAE,OAAO,CAAC,eAAe,WAAW,KAAK,EAAE,YAAY,MAAM,UAAU;AACzG,SAAO,QAAQ,WAAW,IAAI,OAAO,QAAQ,CAAC,CAAC,IAAI;AACrD;AAEO,SAAS,oCACd,MACA,QAC8B;AAC9B,QAAM,UAAkC,CAAC;AACzC,QAAM,mBAA6B,CAAC;AACpC,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,IAAI,GAAG;AAChD,QAAI,UAAU,+BAA+B;AAC3C,cAAQ,IAAI,IAAI;AAChB;AAAA,IACF;AACA,UAAM,cAAc,SAAS,sBAAsB,MAAM,MAAM,IAAI;AACnE,QAAI,gBAAgB,QAAQ,gBAAgB,8BAA+B,QAAO,EAAE,IAAI,OAAO,QAAQ,KAAK;AAC5G,YAAQ,IAAI,IAAI;AAChB,QAAI,0BAA0B,MAAM,WAAW,EAAG,kBAAiB,KAAK,IAAI;AAAA,EAC9E;AACA,SAAO,EAAE,IAAI,MAAM,SAAS,iBAAiB;AAC/C;AAMO,SAAS,+BAA+B,OAIhB;AAC7B,QAAM,EAAE,SAAS,MAAM,qBAAqB,IAAI;AAChD,MAAI,gBAA2D,KAAK;AACpE,MAAI,wBAAkC,CAAC;AAEvC,MAAI,KAAK,kBAAkB,QAAW;AACpC,4BAAwB,OAAO,QAAQ,QAAQ,iBAAiB,CAAC,CAAC,EAC/D,OAAO,CAAC,CAAC,MAAM,KAAK,MAAM,0BAA0B,MAAM,KAAK,CAAC,EAChE,IAAI,CAAC,CAAC,IAAI,MAAM,IAAI;AAAA,EACzB,WAAW,KAAK,kBAAkB,MAAM;AACtC,UAAM,WAAW,oCAAoC,KAAK,eAAe,QAAQ,aAAa;AAC9F,QAAI,CAAC,SAAS,GAAI,QAAO,EAAE,IAAI,OAAO,QAAQ,8BAA8B,QAAQ,SAAS,OAAO;AACpG,oBAAgB,SAAS;AACzB,4BAAwB,SAAS;AAAA,EACnC;AAEA,QAAM,aAAa,KAAK,QAAQ,UAAa,KAAK,QAAQ,QAAQ;AAClE,MAAI,CAAC,wBAAwB,cAAc,sBAAsB,SAAS,GAAG;AAC3E,WAAO,EAAE,IAAI,OAAO,QAAQ,mCAAmC,QAAQ,sBAAsB,CAAC,EAAE;AAAA,EAClG;AAEA,SAAO,EAAE,IAAI,MAAM,cAAc;AACnC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/webhooks",
3
- "version": "0.10.1-develop.7618.1.ba9531c18e",
3
+ "version": "0.10.1-develop.7619.1.97f9a4bda8",
4
4
  "license": "MIT",
5
5
  "description": "Webhooks module for Open Mercato — Standard Webhooks compliant outbound/inbound delivery",
6
6
  "type": "module",
@@ -70,20 +70,20 @@
70
70
  }
71
71
  },
72
72
  "dependencies": {
73
- "@open-mercato/cache": "0.10.1-develop.7618.1.ba9531c18e",
74
- "@open-mercato/core": "0.10.1-develop.7618.1.ba9531c18e",
75
- "@open-mercato/queue": "0.10.1-develop.7618.1.ba9531c18e",
76
- "@open-mercato/ui": "0.10.1-develop.7618.1.ba9531c18e",
73
+ "@open-mercato/cache": "0.10.1-develop.7619.1.97f9a4bda8",
74
+ "@open-mercato/core": "0.10.1-develop.7619.1.97f9a4bda8",
75
+ "@open-mercato/queue": "0.10.1-develop.7619.1.97f9a4bda8",
76
+ "@open-mercato/ui": "0.10.1-develop.7619.1.97f9a4bda8",
77
77
  "svix": "^1.99.1"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@mikro-orm/postgresql": "^7.0.14",
81
- "@open-mercato/shared": "0.10.1-develop.7618.1.ba9531c18e",
81
+ "@open-mercato/shared": "0.10.1-develop.7619.1.97f9a4bda8",
82
82
  "react": "^19.0.0",
83
83
  "react-dom": "^19.0.0"
84
84
  },
85
85
  "devDependencies": {
86
- "@open-mercato/shared": "0.10.1-develop.7618.1.ba9531c18e",
86
+ "@open-mercato/shared": "0.10.1-develop.7619.1.97f9a4bda8",
87
87
  "@types/jest": "^30.0.0",
88
88
  "@types/react": "^19.2.18",
89
89
  "@types/react-dom": "^19.2.5",
@@ -0,0 +1,231 @@
1
+ import { expect, test, type Page } 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
+ type ProvisionedWebhooksUser,
10
+ } from './helpers/fixtures';
11
+
12
+ /**
13
+ * TC-WEBHOOK-013: credential-like custom header values are hidden from callers without webhooks.secrets
14
+ * Source: https://github.com/open-mercato/open-mercato/issues/7237
15
+ *
16
+ * A viewer (webhooks.view) and a manager without webhooks.secrets receive `••••••••` for
17
+ * credential-like custom headers, while ordinary headers stay readable and an admin
18
+ * (webhooks.*) still receives the stored values. Echoing the marker back keeps the stored
19
+ * value, a marker without a stored value is refused, and a manager without webhooks.secrets
20
+ * cannot keep hidden values while changing the webhook URL.
21
+ */
22
+
23
+ const REDACTED = '••••••••';
24
+ const DESTINATION_TOKEN = 'Bearer tc-webhook-013-destination-token';
25
+
26
+ type WebhookDetail = { name: string; url: string; customHeaders: Record<string, string> | null };
27
+
28
+ async function readDetail(request: Parameters<typeof apiRequest>[0], token: string, id: string): Promise<WebhookDetail> {
29
+ const response = await apiRequest(request, 'GET', `/api/webhooks/${id}`, { token });
30
+ expect(response.status()).toBe(200);
31
+ return (await response.json()) as WebhookDetail;
32
+ }
33
+
34
+ async function loginWithCredentials(page: Page, email: string, password: string): Promise<void> {
35
+ const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
36
+ await page.context().addCookies([
37
+ { name: 'om_demo_notice_ack', value: 'ack', url: baseUrl, sameSite: 'Lax' },
38
+ { name: 'om_cookie_notice_ack', value: 'ack', url: baseUrl, sameSite: 'Lax' },
39
+ { name: 'om_feedback_suppress', value: '1', url: baseUrl, sameSite: 'Lax' },
40
+ ]);
41
+ await page.goto('/login');
42
+ await page.waitForSelector('form[data-auth-ready="1"]', { state: 'visible', timeout: 30_000 });
43
+ await page.getByLabel('Email').fill(email);
44
+ const passwordInput = page.getByLabel('Password', { exact: true });
45
+ await passwordInput.fill(password);
46
+ await passwordInput.press('Enter');
47
+ await expect(page).toHaveURL(/\/backend(?:\/.*)?$/, { timeout: 30_000 });
48
+ }
49
+
50
+ test.describe('TC-WEBHOOK-013: credential custom headers are hidden without webhooks.secrets', () => {
51
+ test('redacts on read, keeps stored values on write, and refuses unsafe writes', async ({ request }) => {
52
+ test.setTimeout(60_000);
53
+ const adminToken = await getAuthToken(request);
54
+ const scope = getTokenScope(adminToken);
55
+
56
+ let webhookId: string | null = null;
57
+ let viewer: ProvisionedWebhooksUser | null = null;
58
+ let manager: ProvisionedWebhooksUser | null = null;
59
+
60
+ try {
61
+ viewer = await provisionWebhooksUser(request, adminToken, {
62
+ tenantId: scope.tenantId,
63
+ organizationId: scope.organizationId,
64
+ features: ['webhooks.view'],
65
+ slug: 'cred-viewer',
66
+ });
67
+ manager = await provisionWebhooksUser(request, adminToken, {
68
+ tenantId: scope.tenantId,
69
+ organizationId: scope.organizationId,
70
+ features: ['webhooks.view', 'webhooks.manage'],
71
+ slug: 'cred-manager',
72
+ });
73
+
74
+ const created = await createWebhookFixture(request, adminToken, {
75
+ name: `Webhook credential headers ${Date.now()}`,
76
+ url: 'https://example.com/tc-webhook-013',
77
+ subscribedEvents: ['catalog.product.created'],
78
+ customHeaders: { Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'qa' },
79
+ });
80
+ webhookId = created.id;
81
+
82
+ const viewerDetail = await readDetail(request, viewer.token, created.id);
83
+ expect(viewerDetail.customHeaders).toEqual({ Authorization: REDACTED, 'x-api-key': REDACTED, 'x-source': 'qa' });
84
+
85
+ const aliasResponse = await apiRequest(request, 'GET', `/api/webhooks/webhooks/${created.id}`, { token: viewer.token });
86
+ expect(aliasResponse.status()).toBe(200);
87
+ expect(((await aliasResponse.json()) as WebhookDetail).customHeaders?.Authorization).toBe(REDACTED);
88
+
89
+ const managerDetail = await readDetail(request, manager.token, created.id);
90
+ expect(managerDetail.customHeaders?.Authorization).toBe(REDACTED);
91
+
92
+ const adminDetail = await readDetail(request, adminToken, created.id);
93
+ expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'qa' });
94
+
95
+ const renamed = `Webhook credential headers renamed ${Date.now()}`;
96
+ const echoUpdate = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
97
+ token: manager.token,
98
+ data: { name: renamed, customHeaders: { ...managerDetail.customHeaders, 'x-source': 'erp' } },
99
+ });
100
+ expect(echoUpdate.status()).toBe(200);
101
+ expect(((await echoUpdate.json()) as WebhookDetail).customHeaders?.Authorization).toBe(REDACTED);
102
+
103
+ const afterEcho = await readDetail(request, adminToken, created.id);
104
+ expect(afterEcho.name).toBe(renamed);
105
+ expect(afterEcho.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-api-key': 'tc-013-api-key', 'x-source': 'erp' });
106
+
107
+ const orphanMarker = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
108
+ token: manager.token,
109
+ data: { customHeaders: { 'x-new-token': REDACTED } },
110
+ });
111
+ expect(orphanMarker.status()).toBe(400);
112
+ expect(((await orphanMarker.json()) as { error: string }).error).toContain('x-new-token');
113
+
114
+ const moveWithHidden = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
115
+ token: manager.token,
116
+ data: { url: 'https://other.example.net/tc-webhook-013' },
117
+ });
118
+ expect(moveWithHidden.status()).toBe(400);
119
+
120
+ const moveEchoingMarker = await apiRequest(request, 'PUT', `/api/webhooks/webhooks/${created.id}`, {
121
+ token: manager.token,
122
+ data: { url: 'https://other.example.net/tc-webhook-013', customHeaders: managerDetail.customHeaders },
123
+ });
124
+ expect(moveEchoingMarker.status()).toBe(400);
125
+
126
+ const samePathMove = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
127
+ token: manager.token,
128
+ data: { url: 'https://example.com/tc-webhook-013-other-bin', customHeaders: managerDetail.customHeaders },
129
+ });
130
+ expect(samePathMove.status()).toBe(400);
131
+
132
+ const unchanged = await readDetail(request, adminToken, created.id);
133
+ expect(unchanged.url).toBe('https://example.com/tc-webhook-013');
134
+ expect(unchanged.customHeaders).toEqual(afterEcho.customHeaders);
135
+
136
+ const moveReentered = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
137
+ token: manager.token,
138
+ data: {
139
+ url: 'https://other.example.net/tc-webhook-013',
140
+ customHeaders: { Authorization: 'Bearer re-entered', 'x-source': 'erp' },
141
+ },
142
+ });
143
+ expect(moveReentered.status()).toBe(200);
144
+
145
+ const adminMove = await apiRequest(request, 'PUT', `/api/webhooks/${created.id}`, {
146
+ token: adminToken,
147
+ data: { url: 'https://example.com/tc-webhook-013', customHeaders: { Authorization: REDACTED, 'x-source': 'erp' } },
148
+ });
149
+ expect(adminMove.status()).toBe(200);
150
+ expect(((await adminMove.json()) as WebhookDetail).customHeaders).toEqual({ Authorization: 'Bearer re-entered', 'x-source': 'erp' });
151
+ } finally {
152
+ await deleteWebhookIfExists(request, adminToken, webhookId);
153
+ await cleanupWebhooksUser(request, adminToken, viewer);
154
+ await cleanupWebhooksUser(request, adminToken, manager);
155
+ }
156
+ });
157
+
158
+ test('shows hidden values in the UI and keeps them when a manager saves the edit form', async ({ browser, request }) => {
159
+ test.setTimeout(120_000);
160
+ const adminToken = await getAuthToken(request);
161
+ const scope = getTokenScope(adminToken);
162
+
163
+ let webhookId: string | null = null;
164
+ let viewer: ProvisionedWebhooksUser | null = null;
165
+ let manager: ProvisionedWebhooksUser | null = null;
166
+
167
+ try {
168
+ viewer = await provisionWebhooksUser(request, adminToken, {
169
+ tenantId: scope.tenantId,
170
+ organizationId: scope.organizationId,
171
+ features: ['webhooks.view'],
172
+ slug: 'cred-ui-viewer',
173
+ });
174
+ manager = await provisionWebhooksUser(request, adminToken, {
175
+ tenantId: scope.tenantId,
176
+ organizationId: scope.organizationId,
177
+ features: ['webhooks.view', 'webhooks.manage'],
178
+ slug: 'cred-ui-manager',
179
+ });
180
+
181
+ const created = await createWebhookFixture(request, adminToken, {
182
+ name: `Webhook credential UI ${Date.now()}`,
183
+ url: 'https://example.com/tc-webhook-013-ui',
184
+ subscribedEvents: ['catalog.product.created'],
185
+ customHeaders: { Authorization: DESTINATION_TOKEN, 'x-source': 'qa' },
186
+ });
187
+ webhookId = created.id;
188
+
189
+ const viewerContext = await browser.newContext();
190
+ try {
191
+ const viewerPage = await viewerContext.newPage();
192
+ await loginWithCredentials(viewerPage, viewer.email, viewer.password);
193
+ await viewerPage.goto(`/backend/webhooks/${created.id}`);
194
+ const headersCard = viewerPage.locator('pre').filter({ hasText: '"x-source": "qa"' });
195
+ await expect(headersCard).toContainText(`"Authorization": "${REDACTED}"`);
196
+ await expect(viewerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);
197
+ } finally {
198
+ await viewerContext.close();
199
+ }
200
+
201
+ const renamed = `Webhook credential UI renamed ${Date.now()}`;
202
+ const managerContext = await browser.newContext();
203
+ try {
204
+ const managerPage = await managerContext.newPage();
205
+ await loginWithCredentials(managerPage, manager.email, manager.password);
206
+ await managerPage.goto(`/backend/webhooks/${created.id}`);
207
+ await expect(managerPage.locator('pre').filter({ hasText: '"x-source": "qa"' })).toContainText(REDACTED);
208
+ await managerPage.getByRole('button', { name: 'Actions' }).hover();
209
+ await managerPage.getByRole('menuitem', { name: 'Edit' }).click();
210
+ await expect(managerPage.getByText(`Hidden values (${REDACTED}) are kept when you save`)).toBeVisible();
211
+ await expect(managerPage.getByText(DESTINATION_TOKEN)).toHaveCount(0);
212
+ await expect(managerPage.getByPlaceholder('{"key": "value"}')).toHaveValue(new RegExp(`"Authorization": "${REDACTED}"`));
213
+ const nameInput = managerPage.getByPlaceholder('My Webhook');
214
+ await nameInput.fill(renamed);
215
+ await expect(nameInput).toHaveValue(renamed);
216
+ await managerPage.getByRole('button', { name: 'Save' }).first().click();
217
+ await expect(managerPage.locator('pre').filter({ hasText: '"x-source": "qa"' })).toBeVisible({ timeout: 15_000 });
218
+ } finally {
219
+ await managerContext.close();
220
+ }
221
+
222
+ const adminDetail = await readDetail(request, adminToken, created.id);
223
+ expect(adminDetail.name).toBe(renamed);
224
+ expect(adminDetail.customHeaders).toEqual({ Authorization: DESTINATION_TOKEN, 'x-source': 'qa' });
225
+ } finally {
226
+ await deleteWebhookIfExists(request, adminToken, webhookId);
227
+ await cleanupWebhooksUser(request, adminToken, viewer);
228
+ await cleanupWebhooksUser(request, adminToken, manager);
229
+ }
230
+ });
231
+ });
@@ -1,11 +1,14 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
5
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
4
6
  import { resolveSingleOrganizationIdOrDeny } from '@open-mercato/shared/lib/auth/organizationScope'
5
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
8
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
9
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
10
  import { WebhookDeliveryEntity, WebhookEntity } from '../data/entities'
11
+ import { redactWebhookCustomHeaders, type WebhookCustomHeadersUpdate } from '../lib/custom-headers'
9
12
 
10
13
  export type WebhookRequestScope = {
11
14
  container: Awaited<ReturnType<typeof createRequestContainer>>
@@ -13,6 +16,7 @@ export type WebhookRequestScope = {
13
16
  tenantId: string
14
17
  organizationId: string | null
15
18
  allowedOrganizationIds: string[] | null
19
+ auth?: AuthContext
16
20
  }
17
21
 
18
22
  export function json(payload: unknown, init: ResponseInit = { status: 200 }): Response {
@@ -46,9 +50,23 @@ export async function resolveWebhookRequestScope(request: Request): Promise<Webh
46
50
  tenantId,
47
51
  organizationId,
48
52
  allowedOrganizationIds,
53
+ auth,
49
54
  }
50
55
  }
51
56
 
57
+ export async function canRevealWebhookCredentialHeaders(
58
+ container: WebhookRequestScope['container'],
59
+ auth: AuthContext | undefined,
60
+ webhook: Pick<WebhookEntity, 'tenantId' | 'organizationId'>,
61
+ ): Promise<boolean> {
62
+ if (!auth?.sub) return false
63
+ const rbacService = container.resolve('rbacService') as RbacService
64
+ return rbacService.userHasAllFeatures(auth.sub, ['webhooks.secrets'], {
65
+ tenantId: webhook.tenantId,
66
+ organizationId: webhook.organizationId,
67
+ })
68
+ }
69
+
52
70
  export async function findScopedWebhook(
53
71
  em: EntityManager,
54
72
  scope: Pick<WebhookRequestScope, 'tenantId' | 'organizationId' | 'allowedOrganizationIds'>,
@@ -91,6 +109,24 @@ export async function findScopedDelivery(
91
109
  return delivery
92
110
  }
93
111
 
112
+ export async function translateWebhookCustomHeadersRejection(
113
+ rejection: Extract<WebhookCustomHeadersUpdate, { ok: false }>,
114
+ ): Promise<string> {
115
+ const { translate } = await resolveTranslations()
116
+ if (rejection.reason === 'credentialHeadersRequireReentry') {
117
+ return translate(
118
+ 'webhooks.errors.credentialHeadersRequireReentry',
119
+ 'Header "{header}" has a hidden value. Enter it again or remove it when you change the webhook URL.',
120
+ { header: rejection.header },
121
+ )
122
+ }
123
+ return translate(
124
+ 'webhooks.errors.redactedHeaderWithoutValue',
125
+ 'Header "{header}" has a hidden value but no saved value to keep. Enter the header value.',
126
+ { header: rejection.header },
127
+ )
128
+ }
129
+
94
130
  export function serializeWebhookListItem(item: WebhookEntity) {
95
131
  return {
96
132
  id: item.id,
@@ -110,10 +146,15 @@ export function serializeWebhookListItem(item: WebhookEntity) {
110
146
  }
111
147
  }
112
148
 
113
- export function serializeWebhookDetail(item: WebhookEntity) {
149
+ export function serializeWebhookDetail(
150
+ item: WebhookEntity,
151
+ options: { revealCredentialHeaders?: boolean } = {},
152
+ ) {
114
153
  return {
115
154
  ...serializeWebhookListItem(item),
116
- customHeaders: item.customHeaders ?? null,
155
+ customHeaders: options.revealCredentialHeaders
156
+ ? item.customHeaders ?? null
157
+ : redactWebhookCustomHeaders(item.customHeaders),
117
158
  strategyConfig: item.strategyConfig ?? null,
118
159
  timeoutMs: item.timeoutMs,
119
160
  rateLimitPerMinute: item.rateLimitPerMinute,