@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/webhooks/api/inbound/[endpointId]/route.js +113 -4
- package/dist/modules/webhooks/api/inbound/[endpointId]/route.js.map +3 -3
- package/dist/modules/webhooks/data/entities.js +101 -1
- package/dist/modules/webhooks/data/entities.js.map +2 -2
- package/dist/modules/webhooks/encryption.js +7 -0
- package/dist/modules/webhooks/encryption.js.map +2 -2
- package/dist/modules/webhooks/events.js +2 -0
- package/dist/modules/webhooks/events.js.map +2 -2
- package/dist/modules/webhooks/generators.js +57 -0
- package/dist/modules/webhooks/generators.js.map +7 -0
- package/dist/modules/webhooks/lib/inbound-dispatch.js +105 -0
- package/dist/modules/webhooks/lib/inbound-dispatch.js.map +7 -0
- package/dist/modules/webhooks/lib/inbound-registry.js +76 -0
- package/dist/modules/webhooks/lib/inbound-registry.js.map +7 -0
- package/dist/modules/webhooks/lib/module-webhook-registry.js +12 -0
- package/dist/modules/webhooks/lib/module-webhook-registry.js.map +7 -0
- package/dist/modules/webhooks/lib/queue.js +47 -0
- package/dist/modules/webhooks/lib/queue.js.map +2 -2
- package/dist/modules/webhooks/migrations/Migration20260617141327_webhooks.js +16 -0
- package/dist/modules/webhooks/migrations/Migration20260617141327_webhooks.js.map +7 -0
- package/dist/modules/webhooks/workers/inbound-dispatch.js +29 -0
- package/dist/modules/webhooks/workers/inbound-dispatch.js.map +7 -0
- package/generated/entities/inbound_endpoint_config_entity/index.ts +8 -0
- package/generated/entities/webhook_ingestion_entity/index.ts +16 -0
- package/generated/entities.ids.generated.ts +3 -1
- package/generated/entity-fields-registry.ts +28 -0
- package/package.json +6 -6
- package/src/modules/webhooks/api/inbound/[endpointId]/__tests__/route.unify.test.ts +108 -0
- package/src/modules/webhooks/api/inbound/[endpointId]/route.ts +136 -4
- package/src/modules/webhooks/data/entities.ts +84 -0
- package/src/modules/webhooks/encryption.ts +7 -0
- package/src/modules/webhooks/events.ts +2 -0
- package/src/modules/webhooks/generators.ts +57 -0
- package/src/modules/webhooks/lib/__tests__/inbound-dispatch.test.ts +186 -0
- package/src/modules/webhooks/lib/__tests__/inbound-registry.test.ts +102 -0
- package/src/modules/webhooks/lib/__tests__/module-webhook-registry.test.ts +53 -0
- package/src/modules/webhooks/lib/inbound-dispatch.ts +147 -0
- package/src/modules/webhooks/lib/inbound-registry.ts +92 -0
- package/src/modules/webhooks/lib/module-webhook-registry.ts +33 -0
- package/src/modules/webhooks/lib/queue.ts +60 -0
- package/src/modules/webhooks/migrations/.snapshot-open-mercato.json +907 -338
- package/src/modules/webhooks/migrations/Migration20260617141327_webhooks.ts +16 -0
- 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
|
|
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: '
|
|
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
|
+
})
|