@open-mercato/webhooks 0.6.7-develop.6726.1.983ae8a07e → 0.6.7-develop.6749.1.6b54c56dfe

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 (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/webhooks/api/inbound/[endpointId]/route.js +113 -4
  3. package/dist/modules/webhooks/api/inbound/[endpointId]/route.js.map +3 -3
  4. package/dist/modules/webhooks/data/entities.js +101 -1
  5. package/dist/modules/webhooks/data/entities.js.map +2 -2
  6. package/dist/modules/webhooks/encryption.js +7 -0
  7. package/dist/modules/webhooks/encryption.js.map +2 -2
  8. package/dist/modules/webhooks/events.js +2 -0
  9. package/dist/modules/webhooks/events.js.map +2 -2
  10. package/dist/modules/webhooks/generators.js +57 -0
  11. package/dist/modules/webhooks/generators.js.map +7 -0
  12. package/dist/modules/webhooks/lib/inbound-dispatch.js +105 -0
  13. package/dist/modules/webhooks/lib/inbound-dispatch.js.map +7 -0
  14. package/dist/modules/webhooks/lib/inbound-registry.js +76 -0
  15. package/dist/modules/webhooks/lib/inbound-registry.js.map +7 -0
  16. package/dist/modules/webhooks/lib/module-webhook-registry.js +12 -0
  17. package/dist/modules/webhooks/lib/module-webhook-registry.js.map +7 -0
  18. package/dist/modules/webhooks/lib/queue.js +47 -0
  19. package/dist/modules/webhooks/lib/queue.js.map +2 -2
  20. package/dist/modules/webhooks/migrations/Migration20260617141327_webhooks.js +16 -0
  21. package/dist/modules/webhooks/migrations/Migration20260617141327_webhooks.js.map +7 -0
  22. package/dist/modules/webhooks/workers/inbound-dispatch.js +29 -0
  23. package/dist/modules/webhooks/workers/inbound-dispatch.js.map +7 -0
  24. package/generated/entities/inbound_endpoint_config_entity/index.ts +8 -0
  25. package/generated/entities/webhook_ingestion_entity/index.ts +16 -0
  26. package/generated/entities.ids.generated.ts +3 -1
  27. package/generated/entity-fields-registry.ts +28 -0
  28. package/package.json +6 -6
  29. package/src/modules/webhooks/api/inbound/[endpointId]/__tests__/route.unify.test.ts +108 -0
  30. package/src/modules/webhooks/api/inbound/[endpointId]/route.ts +136 -4
  31. package/src/modules/webhooks/data/entities.ts +84 -0
  32. package/src/modules/webhooks/encryption.ts +7 -0
  33. package/src/modules/webhooks/events.ts +2 -0
  34. package/src/modules/webhooks/generators.ts +57 -0
  35. package/src/modules/webhooks/lib/__tests__/inbound-dispatch.test.ts +186 -0
  36. package/src/modules/webhooks/lib/__tests__/inbound-registry.test.ts +102 -0
  37. package/src/modules/webhooks/lib/__tests__/module-webhook-registry.test.ts +53 -0
  38. package/src/modules/webhooks/lib/inbound-dispatch.ts +147 -0
  39. package/src/modules/webhooks/lib/inbound-registry.ts +92 -0
  40. package/src/modules/webhooks/lib/module-webhook-registry.ts +33 -0
  41. package/src/modules/webhooks/lib/queue.ts +60 -0
  42. package/src/modules/webhooks/migrations/.snapshot-open-mercato.json +907 -338
  43. package/src/modules/webhooks/migrations/Migration20260617141327_webhooks.ts +16 -0
  44. package/src/modules/webhooks/workers/inbound-dispatch.ts +31 -0
@@ -6,11 +6,31 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
7
  import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_ERROR_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'
8
8
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
9
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import type { InboundWebhookRequest } from '@open-mercato/shared/lib/webhooks'
9
11
  import { emitWebhooksEvent } from '../../../events'
10
12
  import { getWebhookEndpointAdapter } from '../../../lib/adapter-registry'
13
+ import { getWebhookSource } from '../../../lib/inbound-registry'
14
+ import { enqueueInboundDispatch } from '../../../lib/queue'
11
15
  import { isWebhookIntegrationEnabled, WEBHOOK_INTEGRATION_DISABLED_MESSAGE } from '../../../lib/integration-state'
12
16
  import { json } from '../../helpers'
13
- import { WebhookInboundReceiptEntity } from '../../../data/entities'
17
+ import { InboundEndpointConfigEntity, WebhookIngestionEntity, WebhookInboundReceiptEntity } from '../../../data/entities'
18
+
19
+ type IntegrationCredentialsService = {
20
+ resolve: (
21
+ integrationId: string,
22
+ scope: { organizationId: string; tenantId: string },
23
+ ) => Promise<Record<string, unknown> | null>
24
+ }
25
+
26
+ function toStringCredentials(credentials: Record<string, unknown> | null): Record<string, string> {
27
+ const result: Record<string, string> = {}
28
+ if (!credentials) return result
29
+ for (const [key, value] of Object.entries(credentials)) {
30
+ if (typeof value === 'string') result[key] = value
31
+ }
32
+ return result
33
+ }
14
34
 
15
35
  export const metadata = {
16
36
  POST: { requireAuth: false },
@@ -29,10 +49,11 @@ const errorSchema = z.object({ error: z.string() })
29
49
 
30
50
  export async function POST(request: Request, context: RouteContext): Promise<Response> {
31
51
  const params = await context.params
32
- const adapter = getWebhookEndpointAdapter(params.endpointId)
52
+ const source = getWebhookSource(params.endpointId)
53
+ const adapter = source ? undefined : getWebhookEndpointAdapter(params.endpointId)
33
54
  const { translate } = await resolveTranslations()
34
55
 
35
- if (!adapter) {
56
+ if (!source && !adapter) {
36
57
  return json({ error: 'Webhook endpoint not found' }, { status: 404 })
37
58
  }
38
59
 
@@ -51,6 +72,116 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
51
72
  if (rateLimitResponse) return rateLimitResponse
52
73
  }
53
74
 
75
+ if (source) {
76
+ const rawBody = await request.text()
77
+ const sourceHeaders = Object.fromEntries(request.headers.entries())
78
+ let parsedBody: Record<string, unknown> = {}
79
+ try {
80
+ const parsed = JSON.parse(rawBody)
81
+ if (parsed && typeof parsed === 'object') parsedBody = parsed as Record<string, unknown>
82
+ } catch {
83
+ parsedBody = {}
84
+ }
85
+ const inboundRequest: InboundWebhookRequest = { body: rawBody, headers: sourceHeaders, parsedBody }
86
+
87
+ const credentialsService = tryResolve<IntegrationCredentialsService>(container, 'integrationCredentialsService')
88
+ const configs = await findWithDecryption(
89
+ em,
90
+ InboundEndpointConfigEntity,
91
+ { sourceKey: params.endpointId, isActive: true },
92
+ {},
93
+ )
94
+
95
+ let verifiedScope: { organizationId: string; tenantId: string } | null = null
96
+ for (const config of configs) {
97
+ const scope = { organizationId: config.organizationId, tenantId: config.tenantId }
98
+ const credentials = credentialsService
99
+ ? await credentialsService.resolve(`webhook_source_${params.endpointId}`, scope)
100
+ : null
101
+ let valid = false
102
+ try {
103
+ valid = await source.verifier(inboundRequest, toStringCredentials(credentials))
104
+ } catch {
105
+ valid = false
106
+ }
107
+ if (valid) {
108
+ verifiedScope = scope
109
+ break
110
+ }
111
+ }
112
+
113
+ if (!verifiedScope) {
114
+ return json({ error: 'Signature verification failed' }, { status: 401 })
115
+ }
116
+
117
+ const eventType = source.eventTypeExtractor(parsedBody, sourceHeaders)
118
+ const messageId = source.messageIdExtractor?.(parsedBody, sourceHeaders)
119
+ ?? resolveInboundReceiptMessageId({
120
+ endpointId: params.endpointId,
121
+ providerKey: params.endpointId,
122
+ headers: sourceHeaders,
123
+ body: rawBody,
124
+ })
125
+
126
+ try {
127
+ em.persist(em.create(WebhookInboundReceiptEntity, {
128
+ endpointId: params.endpointId,
129
+ messageId,
130
+ providerKey: params.endpointId,
131
+ eventType,
132
+ tenantId: verifiedScope.tenantId,
133
+ organizationId: verifiedScope.organizationId,
134
+ createdAt: new Date(),
135
+ }))
136
+ await em.flush()
137
+ } catch (error) {
138
+ if (isUniqueViolation(error)) {
139
+ return json({ ok: true, duplicate: true })
140
+ }
141
+ throw error
142
+ }
143
+
144
+ const ingestion = em.create(WebhookIngestionEntity, {
145
+ sourceKey: params.endpointId,
146
+ eventType,
147
+ externalMessageId: messageId,
148
+ payload: parsedBody,
149
+ headers: sourceHeaders,
150
+ status: 'received',
151
+ handlerCount: 0,
152
+ organizationId: verifiedScope.organizationId,
153
+ tenantId: verifiedScope.tenantId,
154
+ createdAt: new Date(),
155
+ updatedAt: new Date(),
156
+ })
157
+ em.persist(ingestion)
158
+ await em.flush()
159
+
160
+ await enqueueInboundDispatch({
161
+ ingestionId: ingestion.id,
162
+ sourceKey: params.endpointId,
163
+ eventType,
164
+ tenantId: verifiedScope.tenantId,
165
+ organizationId: verifiedScope.organizationId,
166
+ })
167
+
168
+ await emitWebhooksEvent('webhooks.inbound.received', {
169
+ providerKey: params.endpointId,
170
+ endpointId: params.endpointId,
171
+ messageId,
172
+ eventType,
173
+ payload: parsedBody,
174
+ tenantId: verifiedScope.tenantId,
175
+ organizationId: verifiedScope.organizationId,
176
+ }, { persistent: true })
177
+
178
+ return json({ ok: true })
179
+ }
180
+
181
+ if (!adapter) {
182
+ return json({ error: 'Webhook endpoint not found' }, { status: 404 })
183
+ }
184
+
54
185
  const body = await request.text()
55
186
  const headers = Object.fromEntries(request.headers.entries())
56
187
  let verified: Awaited<ReturnType<typeof adapter.verifyWebhook>>
@@ -126,11 +257,12 @@ export const openApi: OpenApiRouteDoc = {
126
257
  methods: {
127
258
  POST: {
128
259
  summary: 'Receive inbound webhook',
129
- description: 'Endpoint ids currently resolve to registered adapter provider keys.',
260
+ description: 'The endpoint id resolves to a registered webhook source first (module-level handler dispatch), otherwise to a legacy adapter provider key.',
130
261
  pathParams: z.object({ endpointId: z.string().min(1) }),
131
262
  responses: [{ status: 200, description: 'Inbound webhook accepted', schema: inboundResponseSchema }],
132
263
  errors: [
133
264
  { status: 400, description: 'Verification failed', schema: errorSchema },
265
+ { status: 401, description: 'Signature verification failed (source flow)', schema: errorSchema },
134
266
  { status: 404, description: 'Endpoint not found', schema: errorSchema },
135
267
  { status: 429, description: 'Rate limit exceeded', schema: errorSchema },
136
268
  { status: 503, description: 'Webhook integration disabled', schema: errorSchema },
@@ -1,4 +1,5 @@
1
1
  import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
2
+ import type { WebhookHandlerResult, WebhookIngestionStatus } from '@open-mercato/shared/lib/webhooks'
2
3
 
3
4
  @Entity({ tableName: 'webhooks' })
4
5
  @Index({ properties: ['organizationId', 'tenantId', 'isActive'] })
@@ -184,3 +185,86 @@ export class WebhookInboundReceiptEntity {
184
185
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
185
186
  createdAt: Date = new Date()
186
187
  }
188
+
189
+ @Entity({ tableName: 'webhook_ingestions' })
190
+ @Index({ properties: ['sourceKey', 'status', 'createdAt'] })
191
+ @Index({ properties: ['organizationId', 'tenantId', 'createdAt'] })
192
+ @Index({ properties: ['externalMessageId'] })
193
+ export class WebhookIngestionEntity {
194
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
195
+ id!: string
196
+
197
+ @Property({ name: 'source_key', type: 'text' })
198
+ sourceKey!: string
199
+
200
+ @Property({ name: 'event_type', type: 'text' })
201
+ eventType!: string
202
+
203
+ @Property({ name: 'external_message_id', type: 'text', nullable: true })
204
+ externalMessageId?: string | null
205
+
206
+ @Property({ name: 'payload', type: 'json' })
207
+ payload!: Record<string, unknown>
208
+
209
+ @Property({ name: 'headers', type: 'json', nullable: true })
210
+ headers?: Record<string, string> | null
211
+
212
+ @Property({ name: 'status', type: 'text', default: 'received' })
213
+ status: WebhookIngestionStatus = 'received'
214
+
215
+ @Property({ name: 'error_message', type: 'text', nullable: true })
216
+ errorMessage?: string | null
217
+
218
+ @Property({ name: 'processed_at', type: Date, nullable: true })
219
+ processedAt?: Date | null
220
+
221
+ @Property({ name: 'handler_count', type: 'int', default: 0 })
222
+ handlerCount: number = 0
223
+
224
+ @Property({ name: 'handler_results', type: 'json', nullable: true })
225
+ handlerResults?: WebhookHandlerResult[] | null
226
+
227
+ @Property({ name: 'duration_ms', type: 'int', nullable: true })
228
+ durationMs?: number | null
229
+
230
+ @Property({ name: 'organization_id', type: 'uuid' })
231
+ organizationId!: string
232
+
233
+ @Property({ name: 'tenant_id', type: 'uuid' })
234
+ tenantId!: string
235
+
236
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
237
+ createdAt: Date = new Date()
238
+
239
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
240
+ updatedAt: Date = new Date()
241
+ }
242
+
243
+ @Entity({ tableName: 'webhook_inbound_configs' })
244
+ @Unique({ name: 'webhook_inbound_configs_source_scope_unique', properties: ['sourceKey', 'organizationId', 'tenantId'] })
245
+ @Index({ properties: ['sourceKey', 'isActive'] })
246
+ export class InboundEndpointConfigEntity {
247
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
248
+ id!: string
249
+
250
+ @Property({ name: 'source_key', type: 'text' })
251
+ sourceKey!: string
252
+
253
+ @Property({ name: 'is_active', type: 'boolean', default: true })
254
+ isActive: boolean = true
255
+
256
+ @Property({ name: 'integration_id', type: 'text', nullable: true })
257
+ integrationId?: string | null
258
+
259
+ @Property({ name: 'organization_id', type: 'uuid' })
260
+ organizationId!: string
261
+
262
+ @Property({ name: 'tenant_id', type: 'uuid' })
263
+ tenantId!: string
264
+
265
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
266
+ createdAt: Date = new Date()
267
+
268
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
269
+ updatedAt: Date = new Date()
270
+ }
@@ -8,6 +8,13 @@ export const defaultEncryptionMaps: ModuleEncryptionMap[] = [
8
8
  { field: 'previous_secret' },
9
9
  ],
10
10
  },
11
+ {
12
+ entityId: 'webhooks:webhook_ingestion_entity',
13
+ fields: [
14
+ { field: 'payload' },
15
+ { field: 'headers' },
16
+ ],
17
+ },
11
18
  ]
12
19
 
13
20
  export default defaultEncryptionMaps
@@ -10,6 +10,8 @@ const events = [
10
10
  { id: 'webhooks.delivery.exhausted', label: 'Webhook Delivery Retries Exhausted', entity: 'delivery', category: 'lifecycle' as const, clientBroadcast: true },
11
11
  { id: 'webhooks.webhook.disabled', label: 'Webhook Auto-Disabled', entity: 'webhook', category: 'lifecycle' as const, clientBroadcast: true },
12
12
  { id: 'webhooks.inbound.received', label: 'Inbound Webhook Received', entity: 'inbound', category: 'custom' as const },
13
+ { id: 'webhooks.inbound.processed', label: 'Inbound Webhook Processed', entity: 'inbound', category: 'lifecycle' as const },
14
+ { id: 'webhooks.inbound.handler_failed', label: 'Inbound Handler Failed', entity: 'inbound', category: 'lifecycle' as const, clientBroadcast: true },
13
15
  { id: 'webhooks.secret.rotated', label: 'Webhook Secret Rotated', entity: 'webhook', category: 'lifecycle' as const },
14
16
  ] as const
15
17
 
@@ -0,0 +1,57 @@
1
+ import type { GeneratorPlugin } from '@open-mercato/shared/modules/generators'
2
+
3
+ function buildSourcesOutput({ importSection, entriesLiteral }: { importSection: string; entriesLiteral: string }): string {
4
+ return `// AUTO-GENERATED by mercato generate registry
5
+ import type { WebhookSourceConfig } from '@open-mercato/shared/lib/webhooks'
6
+ ${importSection ? `${importSection}\n` : ''}type WebhookSourceModuleEntry = { moduleId: string; sources: WebhookSourceConfig[] }
7
+
8
+ export const webhookSourceEntries: WebhookSourceModuleEntry[] = [
9
+ ${entriesLiteral ? ` ${entriesLiteral}\n` : ''}]
10
+ `
11
+ }
12
+
13
+ function buildHandlersOutput({ importSection, entriesLiteral }: { importSection: string; entriesLiteral: string }): string {
14
+ return `// AUTO-GENERATED by mercato generate registry
15
+ import type { WebhookHandlerRegistryEntry } from '@open-mercato/shared/lib/webhooks'
16
+ ${importSection ? `${importSection}\n` : ''}type WebhookHandlerModuleEntry = { moduleId: string; handlers: WebhookHandlerRegistryEntry[] }
17
+
18
+ export const webhookHandlerEntries: WebhookHandlerModuleEntry[] = [
19
+ ${entriesLiteral ? ` ${entriesLiteral}\n` : ''}]
20
+ `
21
+ }
22
+
23
+ const sourcesPlugin: GeneratorPlugin = {
24
+ id: 'webhooks.sources',
25
+ conventionFile: 'webhook-sources.ts',
26
+ importPrefix: 'WEBHOOK_SOURCES',
27
+ configExpr: (importName: string, moduleId: string) =>
28
+ `{ moduleId: '${moduleId}', sources: ((${importName}.default ?? ${importName}.webhookSources ?? []) as WebhookSourceConfig[]) }`,
29
+ outputFileName: 'webhook-sources.generated.ts',
30
+ buildOutput: buildSourcesOutput,
31
+ bootstrapRegistration: {
32
+ entriesExportName: 'webhookSourceEntries',
33
+ registrationImports: [
34
+ `import { registerWebhookSourceEntries } from '@open-mercato/webhooks/modules/webhooks/lib/module-webhook-registry'`,
35
+ ],
36
+ buildCall: (name: string) => `registerWebhookSourceEntries(${name})`,
37
+ },
38
+ }
39
+
40
+ const handlersPlugin: GeneratorPlugin = {
41
+ id: 'webhooks.handlers',
42
+ conventionFile: 'webhook-handlers.ts',
43
+ importPrefix: 'WEBHOOK_HANDLERS',
44
+ configExpr: (importName: string, moduleId: string) =>
45
+ `{ moduleId: '${moduleId}', handlers: ((${importName}.default ?? ${importName}.webhookHandlers ?? []) as WebhookHandlerRegistryEntry[]) }`,
46
+ outputFileName: 'webhook-handlers.generated.ts',
47
+ buildOutput: buildHandlersOutput,
48
+ bootstrapRegistration: {
49
+ entriesExportName: 'webhookHandlerEntries',
50
+ registrationImports: [
51
+ `import { registerWebhookHandlerEntries } from '@open-mercato/webhooks/modules/webhooks/lib/module-webhook-registry'`,
52
+ ],
53
+ buildCall: (name: string) => `registerWebhookHandlerEntries(${name})`,
54
+ },
55
+ }
56
+
57
+ export const generatorPlugins: GeneratorPlugin[] = [sourcesPlugin, handlersPlugin]
@@ -0,0 +1,186 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { WebhookHandlerContext, WebhookHandlerPayload } from '@open-mercato/shared/lib/webhooks'
3
+
4
+ const findOneWithDecryption = jest.fn()
5
+ const emitWebhooksEvent = jest.fn(async () => undefined)
6
+
7
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
8
+ findOneWithDecryption: (...args: unknown[]) => findOneWithDecryption(...args),
9
+ }))
10
+ jest.mock('../../events', () => ({
11
+ emitWebhooksEvent: (...args: unknown[]) => emitWebhooksEvent(...args),
12
+ }))
13
+
14
+ import { processInboundDispatchJob, type InboundDispatchJob } from '../inbound-dispatch'
15
+ import {
16
+ clearWebhookHandlers,
17
+ registerWebhookHandler,
18
+ } from '../inbound-registry'
19
+
20
+ type IngestionRow = {
21
+ id: string
22
+ status: string
23
+ payload: Record<string, unknown>
24
+ headers: Record<string, string> | null
25
+ handlerCount: number
26
+ handlerResults: unknown
27
+ processedAt: Date | null
28
+ durationMs: number | null
29
+ errorMessage: string | null
30
+ tenantId: string
31
+ organizationId: string
32
+ }
33
+
34
+ function makeIngestion(overrides: Partial<IngestionRow> = {}): IngestionRow {
35
+ return {
36
+ id: 'ing-1',
37
+ status: 'received',
38
+ payload: { id: 'evt_1', type: 'payment_intent.succeeded' },
39
+ headers: { 'stripe-signature': 'sig' },
40
+ handlerCount: 0,
41
+ handlerResults: null,
42
+ processedAt: null,
43
+ durationMs: null,
44
+ errorMessage: null,
45
+ tenantId: 't1',
46
+ organizationId: 'o1',
47
+ ...overrides,
48
+ }
49
+ }
50
+
51
+ const job: InboundDispatchJob = {
52
+ ingestionId: 'ing-1',
53
+ sourceKey: 'stripe',
54
+ eventType: 'payment_intent.succeeded',
55
+ tenantId: 't1',
56
+ organizationId: 'o1',
57
+ }
58
+
59
+ const em = { flush: jest.fn(async () => undefined) } as unknown as EntityManager
60
+ const ctx: WebhookHandlerContext = { resolve: <T,>() => undefined as T }
61
+
62
+ beforeEach(() => {
63
+ clearWebhookHandlers()
64
+ findOneWithDecryption.mockReset()
65
+ emitWebhooksEvent.mockClear()
66
+ ;(em.flush as jest.Mock).mockClear()
67
+ })
68
+
69
+ it('runs all matching handlers and marks the ingestion processed', async () => {
70
+ const ingestion = makeIngestion()
71
+ findOneWithDecryption.mockResolvedValue(ingestion)
72
+ const calls: string[] = []
73
+ registerWebhookHandler({
74
+ meta: { source: 'stripe', event: 'payment_intent.*', id: 'payments:a' },
75
+ handler: async () => ({ default: async () => { calls.push('a') } }),
76
+ })
77
+ registerWebhookHandler({
78
+ meta: { source: 'stripe', event: '*', id: 'audit:b' },
79
+ handler: async () => ({ default: async () => { calls.push('b') } }),
80
+ })
81
+
82
+ await processInboundDispatchJob(em, job, ctx)
83
+
84
+ expect(calls.sort()).toEqual(['a', 'b'])
85
+ expect(ingestion.status).toBe('processed')
86
+ expect(ingestion.handlerCount).toBe(2)
87
+ expect(emitWebhooksEvent).toHaveBeenCalledWith(
88
+ 'webhooks.inbound.processed',
89
+ expect.objectContaining({ ingestionId: 'ing-1', handlerCount: 2, failedCount: 0 }),
90
+ )
91
+ })
92
+
93
+ it('isolates a failing handler, marks failed, and still runs the others', async () => {
94
+ const ingestion = makeIngestion()
95
+ findOneWithDecryption.mockResolvedValue(ingestion)
96
+ const calls: string[] = []
97
+ registerWebhookHandler({
98
+ meta: { source: 'stripe', event: 'payment_intent.succeeded', id: 'payments:boom' },
99
+ handler: async () => ({ default: async () => { throw new Error('handler exploded') } }),
100
+ })
101
+ registerWebhookHandler({
102
+ meta: { source: 'stripe', event: '*', id: 'audit:ok' },
103
+ handler: async () => ({ default: async () => { calls.push('ok') } }),
104
+ })
105
+
106
+ await processInboundDispatchJob(em, job, ctx)
107
+
108
+ expect(calls).toEqual(['ok'])
109
+ expect(ingestion.status).toBe('failed')
110
+ expect(ingestion.errorMessage).toBe('1/2 handlers failed')
111
+ expect(emitWebhooksEvent).toHaveBeenCalledWith(
112
+ 'webhooks.inbound.handler_failed',
113
+ expect.objectContaining({ handlerId: 'payments:boom', errorMessage: 'handler exploded' }),
114
+ )
115
+ })
116
+
117
+ it('is idempotent when the ingestion is already processed', async () => {
118
+ findOneWithDecryption.mockResolvedValue(makeIngestion({ status: 'processed' }))
119
+ const handler = jest.fn(async () => ({ default: async () => undefined }))
120
+ registerWebhookHandler({ meta: { source: 'stripe', event: '*', id: 'x' }, handler })
121
+
122
+ await processInboundDispatchJob(em, job, ctx)
123
+
124
+ expect(handler).not.toHaveBeenCalled()
125
+ expect(em.flush).not.toHaveBeenCalled()
126
+ expect(emitWebhooksEvent).not.toHaveBeenCalled()
127
+ })
128
+
129
+ it('returns early when the ingestion is missing', async () => {
130
+ findOneWithDecryption.mockResolvedValue(null)
131
+ await processInboundDispatchJob(em, job, ctx)
132
+ expect(em.flush).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('passes the handler the payload and headers from the ingestion, not from the job', async () => {
136
+ const ingestion = makeIngestion({
137
+ payload: { id: 'evt_from_row', type: 'payment_intent.succeeded' },
138
+ headers: { 'stripe-signature': 'sig_from_row' },
139
+ })
140
+ findOneWithDecryption.mockResolvedValue(ingestion)
141
+ const seen: WebhookHandlerPayload[] = []
142
+ registerWebhookHandler({
143
+ meta: { source: 'stripe', event: '*', id: 'audit:capture' },
144
+ handler: async () => ({ default: async (payload: WebhookHandlerPayload) => { seen.push(payload) } }),
145
+ })
146
+
147
+ await processInboundDispatchJob(em, job, ctx)
148
+
149
+ expect(seen).toHaveLength(1)
150
+ expect(seen[0].data).toEqual({ id: 'evt_from_row', type: 'payment_intent.succeeded' })
151
+ expect(seen[0].headers).toEqual({ 'stripe-signature': 'sig_from_row' })
152
+ })
153
+
154
+ it('re-runs only the handlers that did not already succeed when retried after a partial failure', async () => {
155
+ const ingestion = makeIngestion({
156
+ status: 'failed',
157
+ handlerCount: 2,
158
+ errorMessage: '1/2 handlers failed',
159
+ handlerResults: [
160
+ { handlerId: 'audit:ok', module: 'audit', status: 'success', durationMs: 3, startedAt: '2026-06-17T00:00:00.000Z' },
161
+ { handlerId: 'payments:boom', module: 'payments', status: 'failed', errorMessage: 'handler exploded', durationMs: 1, startedAt: '2026-06-17T00:00:00.000Z' },
162
+ ],
163
+ })
164
+ findOneWithDecryption.mockResolvedValue(ingestion)
165
+ const calls: string[] = []
166
+ registerWebhookHandler({
167
+ meta: { source: 'stripe', event: '*', id: 'audit:ok' },
168
+ handler: async () => ({ default: async () => { calls.push('audit:ok') } }),
169
+ })
170
+ registerWebhookHandler({
171
+ meta: { source: 'stripe', event: 'payment_intent.succeeded', id: 'payments:boom' },
172
+ handler: async () => ({ default: async () => { calls.push('payments:boom') } }),
173
+ })
174
+
175
+ await processInboundDispatchJob(em, job, ctx)
176
+
177
+ expect(calls).toEqual(['payments:boom'])
178
+ expect(ingestion.status).toBe('processed')
179
+ expect(ingestion.errorMessage).toBeNull()
180
+ expect(ingestion.handlerResults).toEqual(
181
+ expect.arrayContaining([
182
+ expect.objectContaining({ handlerId: 'audit:ok', status: 'success' }),
183
+ expect.objectContaining({ handlerId: 'payments:boom', status: 'success' }),
184
+ ]),
185
+ )
186
+ })
@@ -0,0 +1,102 @@
1
+ import type {
2
+ WebhookHandler,
3
+ WebhookHandlerRegistryEntry,
4
+ WebhookSourceConfig,
5
+ } from '@open-mercato/shared/lib/webhooks'
6
+ import {
7
+ clearWebhookHandlers,
8
+ clearWebhookSources,
9
+ getWebhookSource,
10
+ listWebhookHandlers,
11
+ listWebhookSources,
12
+ registerWebhookHandler,
13
+ registerWebhookSource,
14
+ resolveWebhookHandlers,
15
+ setWebhookHandlers,
16
+ setWebhookSources,
17
+ } from '../inbound-registry'
18
+
19
+ const noopHandler: WebhookHandler = async () => undefined
20
+
21
+ function makeSource(key: string): WebhookSourceConfig {
22
+ return {
23
+ key,
24
+ label: key,
25
+ verifier: async () => true,
26
+ eventTypeExtractor: (body) => String((body as { type?: string }).type ?? ''),
27
+ }
28
+ }
29
+
30
+ function makeEntry(source: string, event: string, id: string): WebhookHandlerRegistryEntry {
31
+ return {
32
+ meta: { source, event, id },
33
+ handler: async () => ({ default: noopHandler }),
34
+ }
35
+ }
36
+
37
+ beforeEach(() => {
38
+ clearWebhookSources()
39
+ clearWebhookHandlers()
40
+ })
41
+
42
+ describe('webhook source registry', () => {
43
+ it('registers and resolves a source by key', () => {
44
+ registerWebhookSource(makeSource('stripe'))
45
+ expect(getWebhookSource('stripe')?.key).toBe('stripe')
46
+ expect(getWebhookSource('missing')).toBeUndefined()
47
+ expect(listWebhookSources()).toHaveLength(1)
48
+ })
49
+
50
+ it('unregister removes only the matching source instance', () => {
51
+ const unregister = registerWebhookSource(makeSource('stripe'))
52
+ registerWebhookSource(makeSource('resend'))
53
+ unregister()
54
+ expect(getWebhookSource('stripe')).toBeUndefined()
55
+ expect(getWebhookSource('resend')?.key).toBe('resend')
56
+ })
57
+
58
+ it('setWebhookSources replaces the whole registry', () => {
59
+ registerWebhookSource(makeSource('old'))
60
+ setWebhookSources([makeSource('stripe'), makeSource('resend')])
61
+ expect(getWebhookSource('old')).toBeUndefined()
62
+ expect(listWebhookSources().map((s) => s.key).sort()).toEqual(['resend', 'stripe'])
63
+ })
64
+ })
65
+
66
+ describe('resolveWebhookHandlers', () => {
67
+ it('matches handlers by source key', () => {
68
+ registerWebhookHandler(makeEntry('stripe', '*', 'a'))
69
+ registerWebhookHandler(makeEntry('resend', '*', 'b'))
70
+ const matched = resolveWebhookHandlers('stripe', 'payment_intent.succeeded')
71
+ expect(matched.map((m) => m.meta.id)).toEqual(['a'])
72
+ })
73
+
74
+ it('supports exact, wildcard, and prefix-wildcard event patterns', () => {
75
+ registerWebhookHandler(makeEntry('stripe', 'payment_intent.succeeded', 'exact'))
76
+ registerWebhookHandler(makeEntry('stripe', 'payment_intent.*', 'prefix'))
77
+ registerWebhookHandler(makeEntry('stripe', '*', 'all'))
78
+ registerWebhookHandler(makeEntry('stripe', 'charge.refunded', 'other'))
79
+
80
+ const matched = resolveWebhookHandlers('stripe', 'payment_intent.succeeded')
81
+ expect(matched.map((m) => m.meta.id).sort()).toEqual(['all', 'exact', 'prefix'])
82
+ })
83
+
84
+ it('returns multiple handlers for the same source + event', () => {
85
+ registerWebhookHandler(makeEntry('stripe', 'payment_intent.succeeded', 'h1'))
86
+ registerWebhookHandler(makeEntry('stripe', 'payment_intent.succeeded', 'h2'))
87
+ expect(resolveWebhookHandlers('stripe', 'payment_intent.succeeded')).toHaveLength(2)
88
+ })
89
+
90
+ it('returns no handlers when nothing matches', () => {
91
+ registerWebhookHandler(makeEntry('stripe', 'charge.refunded', 'x'))
92
+ expect(resolveWebhookHandlers('stripe', 'payment_intent.succeeded')).toEqual([])
93
+ expect(resolveWebhookHandlers('paypal', 'charge.refunded')).toEqual([])
94
+ })
95
+
96
+ it('setWebhookHandlers replaces the whole handler registry', () => {
97
+ registerWebhookHandler(makeEntry('stripe', '*', 'old'))
98
+ setWebhookHandlers([makeEntry('resend', 'email.received', 'new')])
99
+ expect(listWebhookHandlers().map((e) => e.meta.id)).toEqual(['new'])
100
+ expect(resolveWebhookHandlers('resend', 'email.received').map((e) => e.meta.id)).toEqual(['new'])
101
+ })
102
+ })