@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
@@ -0,0 +1,53 @@
1
+ import type { WebhookHandler } from '@open-mercato/shared/lib/webhooks'
2
+ import {
3
+ registerWebhookHandlerEntries,
4
+ registerWebhookSourceEntries,
5
+ } from '../module-webhook-registry'
6
+ import {
7
+ clearWebhookHandlers,
8
+ clearWebhookSources,
9
+ listWebhookHandlers,
10
+ listWebhookSources,
11
+ } from '../inbound-registry'
12
+
13
+ const noopHandler: WebhookHandler = async () => undefined
14
+
15
+ beforeEach(() => {
16
+ clearWebhookSources()
17
+ clearWebhookHandlers()
18
+ })
19
+
20
+ it('flattens module source entries into the source registry', () => {
21
+ registerWebhookSourceEntries([
22
+ {
23
+ moduleId: 'gateway_stripe',
24
+ sources: [
25
+ { key: 'stripe', label: 'Stripe', verifier: async () => true, eventTypeExtractor: () => '' },
26
+ ],
27
+ },
28
+ { moduleId: 'inbox_ops', sources: [
29
+ { key: 'resend', label: 'Resend', verifier: async () => true, eventTypeExtractor: () => '' },
30
+ ] },
31
+ ])
32
+ expect(listWebhookSources().map((s) => s.key).sort()).toEqual(['resend', 'stripe'])
33
+ })
34
+
35
+ it('flattens module handler entries into the handler registry', () => {
36
+ registerWebhookHandlerEntries([
37
+ {
38
+ moduleId: 'gateway_stripe',
39
+ handlers: [
40
+ { meta: { source: 'stripe', event: 'payment_intent.succeeded', id: 'a' }, handler: async () => ({ default: noopHandler }) },
41
+ { meta: { source: 'stripe', event: 'charge.refunded', id: 'b' }, handler: async () => ({ default: noopHandler }) },
42
+ ],
43
+ },
44
+ ])
45
+ expect(listWebhookHandlers().map((e) => e.meta.id).sort()).toEqual(['a', 'b'])
46
+ })
47
+
48
+ it('tolerates entries with no sources/handlers', () => {
49
+ registerWebhookSourceEntries([{ moduleId: 'empty', sources: [] }])
50
+ registerWebhookHandlerEntries([{ moduleId: 'empty', handlers: [] }])
51
+ expect(listWebhookSources()).toEqual([])
52
+ expect(listWebhookHandlers()).toEqual([])
53
+ })
@@ -0,0 +1,147 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import type {
4
+ WebhookHandlerContext,
5
+ WebhookHandlerPayload,
6
+ WebhookHandlerResult,
7
+ } from '@open-mercato/shared/lib/webhooks'
8
+ import { WebhookIngestionEntity } from '../data/entities'
9
+ import { emitWebhooksEvent } from '../events'
10
+ import { resolveWebhookHandlers } from './inbound-registry'
11
+
12
+ const MAX_HANDLER_RESULTS = 50
13
+ const MAX_ERROR_MESSAGE_LENGTH = 1024
14
+
15
+ export type InboundDispatchJob = {
16
+ ingestionId: string
17
+ sourceKey: string
18
+ eventType: string
19
+ tenantId: string
20
+ organizationId: string
21
+ }
22
+
23
+ function truncateError(message: string): string {
24
+ return message.length > MAX_ERROR_MESSAGE_LENGTH
25
+ ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}…`
26
+ : message
27
+ }
28
+
29
+ function moduleOfHandler(handlerId: string): string {
30
+ const separatorIndex = handlerId.indexOf(':')
31
+ return separatorIndex > 0 ? handlerId.slice(0, separatorIndex) : handlerId
32
+ }
33
+
34
+ /**
35
+ * Process a queued inbound-dispatch job: load the ingestion, run every matching
36
+ * handler independently (a failing handler never blocks the others), record
37
+ * per-handler results, and emit lifecycle events. The payload and headers are
38
+ * read from the (decrypted) ingestion row rather than carried on the job, so the
39
+ * queue never stores the raw webhook body. Idempotent — a job whose ingestion is
40
+ * already `processed` returns early, and a retry after a partial failure re-runs
41
+ * only the handlers that have not already succeeded.
42
+ */
43
+ export async function processInboundDispatchJob(
44
+ em: EntityManager,
45
+ job: InboundDispatchJob,
46
+ ctx: WebhookHandlerContext,
47
+ ): Promise<void> {
48
+ const startedAtMs = Date.now()
49
+ const ingestion = await findOneWithDecryption(
50
+ em,
51
+ WebhookIngestionEntity,
52
+ {
53
+ id: job.ingestionId,
54
+ tenantId: job.tenantId,
55
+ organizationId: job.organizationId,
56
+ },
57
+ {},
58
+ )
59
+
60
+ if (!ingestion || ingestion.status === 'processed') return
61
+
62
+ ingestion.status = 'processing'
63
+ await em.flush()
64
+
65
+ const handlers = resolveWebhookHandlers(job.sourceKey, job.eventType)
66
+ const payload: WebhookHandlerPayload = {
67
+ data: ingestion.payload,
68
+ eventType: job.eventType,
69
+ sourceKey: job.sourceKey,
70
+ headers: ingestion.headers ?? {},
71
+ ingestionId: job.ingestionId,
72
+ tenantId: job.tenantId,
73
+ organizationId: job.organizationId,
74
+ }
75
+
76
+ const previousResults = Array.isArray(ingestion.handlerResults) ? ingestion.handlerResults : []
77
+ const alreadySucceeded = new Map(
78
+ previousResults
79
+ .filter((result) => result?.status === 'success')
80
+ .map((result) => [result.handlerId, result]),
81
+ )
82
+
83
+ const results: WebhookHandlerResult[] = []
84
+ let failedCount = 0
85
+
86
+ for (const entry of handlers) {
87
+ const previousSuccess = alreadySucceeded.get(entry.meta.id)
88
+ if (previousSuccess) {
89
+ results.push(previousSuccess)
90
+ continue
91
+ }
92
+
93
+ const handlerStartedMs = Date.now()
94
+ const handlerStartedAt = new Date(handlerStartedMs).toISOString()
95
+ try {
96
+ const mod = await entry.handler()
97
+ await mod.default(payload, ctx)
98
+ results.push({
99
+ handlerId: entry.meta.id,
100
+ module: moduleOfHandler(entry.meta.id),
101
+ status: 'success',
102
+ durationMs: Date.now() - handlerStartedMs,
103
+ startedAt: handlerStartedAt,
104
+ })
105
+ } catch (error) {
106
+ failedCount += 1
107
+ const message = truncateError(error instanceof Error ? error.message : String(error))
108
+ results.push({
109
+ handlerId: entry.meta.id,
110
+ module: moduleOfHandler(entry.meta.id),
111
+ status: 'failed',
112
+ errorMessage: message,
113
+ durationMs: Date.now() - handlerStartedMs,
114
+ startedAt: handlerStartedAt,
115
+ })
116
+ await emitWebhooksEvent('webhooks.inbound.handler_failed', {
117
+ ingestionId: job.ingestionId,
118
+ sourceKey: job.sourceKey,
119
+ eventType: job.eventType,
120
+ handlerId: entry.meta.id,
121
+ errorMessage: message,
122
+ tenantId: job.tenantId,
123
+ organizationId: job.organizationId,
124
+ })
125
+ }
126
+ }
127
+
128
+ ingestion.handlerCount = handlers.length
129
+ ingestion.handlerResults = results.slice(0, MAX_HANDLER_RESULTS)
130
+ ingestion.status = failedCount > 0 ? 'failed' : 'processed'
131
+ ingestion.processedAt = new Date()
132
+ ingestion.durationMs = Date.now() - startedAtMs
133
+ ingestion.errorMessage = failedCount > 0
134
+ ? `${failedCount}/${handlers.length} handlers failed`
135
+ : null
136
+ await em.flush()
137
+
138
+ await emitWebhooksEvent('webhooks.inbound.processed', {
139
+ ingestionId: job.ingestionId,
140
+ sourceKey: job.sourceKey,
141
+ eventType: job.eventType,
142
+ handlerCount: handlers.length,
143
+ failedCount,
144
+ tenantId: job.tenantId,
145
+ organizationId: job.organizationId,
146
+ })
147
+ }
@@ -0,0 +1,92 @@
1
+ import { matchWebhookEventPattern } from '@open-mercato/shared/lib/events/patterns'
2
+ import type {
3
+ WebhookHandlerRegistryEntry,
4
+ WebhookSourceConfig,
5
+ } from '@open-mercato/shared/lib/webhooks'
6
+
7
+ const WEBHOOK_SOURCES_KEY = '__openMercatoWebhookSources__'
8
+ const WEBHOOK_HANDLERS_KEY = '__openMercatoWebhookHandlers__'
9
+
10
+ type GlobalState = typeof globalThis & {
11
+ [WEBHOOK_SOURCES_KEY]?: Map<string, WebhookSourceConfig>
12
+ [WEBHOOK_HANDLERS_KEY]?: WebhookHandlerRegistryEntry[]
13
+ }
14
+
15
+ function getSourceRegistry(): Map<string, WebhookSourceConfig> {
16
+ const globalState = globalThis as GlobalState
17
+ if (!globalState[WEBHOOK_SOURCES_KEY]) {
18
+ globalState[WEBHOOK_SOURCES_KEY] = new Map<string, WebhookSourceConfig>()
19
+ }
20
+ return globalState[WEBHOOK_SOURCES_KEY]
21
+ }
22
+
23
+ function getHandlerRegistry(): WebhookHandlerRegistryEntry[] {
24
+ const globalState = globalThis as GlobalState
25
+ if (!globalState[WEBHOOK_HANDLERS_KEY]) {
26
+ globalState[WEBHOOK_HANDLERS_KEY] = []
27
+ }
28
+ return globalState[WEBHOOK_HANDLERS_KEY]
29
+ }
30
+
31
+ export function registerWebhookSource(config: WebhookSourceConfig): () => void {
32
+ const registry = getSourceRegistry()
33
+ registry.set(config.key, config)
34
+ return () => {
35
+ if (registry.get(config.key) === config) registry.delete(config.key)
36
+ }
37
+ }
38
+
39
+ export function setWebhookSources(configs: WebhookSourceConfig[]): void {
40
+ const registry = getSourceRegistry()
41
+ registry.clear()
42
+ for (const config of configs) registry.set(config.key, config)
43
+ }
44
+
45
+ export function getWebhookSource(sourceKey: string): WebhookSourceConfig | undefined {
46
+ return getSourceRegistry().get(sourceKey)
47
+ }
48
+
49
+ export function listWebhookSources(): WebhookSourceConfig[] {
50
+ return Array.from(getSourceRegistry().values())
51
+ }
52
+
53
+ export function clearWebhookSources(): void {
54
+ getSourceRegistry().clear()
55
+ }
56
+
57
+ export function registerWebhookHandler(entry: WebhookHandlerRegistryEntry): () => void {
58
+ const registry = getHandlerRegistry()
59
+ registry.push(entry)
60
+ return () => {
61
+ const index = registry.indexOf(entry)
62
+ if (index >= 0) registry.splice(index, 1)
63
+ }
64
+ }
65
+
66
+ export function setWebhookHandlers(entries: WebhookHandlerRegistryEntry[]): void {
67
+ const registry = getHandlerRegistry()
68
+ registry.length = 0
69
+ registry.push(...entries)
70
+ }
71
+
72
+ export function listWebhookHandlers(): WebhookHandlerRegistryEntry[] {
73
+ return [...getHandlerRegistry()]
74
+ }
75
+
76
+ export function clearWebhookHandlers(): void {
77
+ getHandlerRegistry().length = 0
78
+ }
79
+
80
+ /**
81
+ * Resolve the handlers that match an inbound webhook's source key and event type.
82
+ * Event matching reuses the outbound prefix-wildcard semantics (`*`, `payment_intent.*`).
83
+ */
84
+ export function resolveWebhookHandlers(
85
+ sourceKey: string,
86
+ eventType: string,
87
+ ): WebhookHandlerRegistryEntry[] {
88
+ return getHandlerRegistry().filter((entry) => {
89
+ if (entry.meta.source !== sourceKey) return false
90
+ return matchWebhookEventPattern(eventType, entry.meta.event)
91
+ })
92
+ }
@@ -0,0 +1,33 @@
1
+ import type {
2
+ WebhookHandlerRegistryEntry,
3
+ WebhookSourceConfig,
4
+ } from '@open-mercato/shared/lib/webhooks'
5
+ import { setWebhookHandlers, setWebhookSources } from './inbound-registry'
6
+
7
+ export type WebhookSourceModuleEntry = {
8
+ moduleId: string
9
+ sources: WebhookSourceConfig[]
10
+ }
11
+
12
+ export type WebhookHandlerModuleEntry = {
13
+ moduleId: string
14
+ handlers: WebhookHandlerRegistryEntry[]
15
+ }
16
+
17
+ /**
18
+ * Bootstrap-time registration for module-declared webhook sources.
19
+ * Driven by the `webhooks.sources` generator plugin via
20
+ * `bootstrap-registrations.generated.ts` — modules contribute a
21
+ * `webhook-sources.ts` and are wired here without bootstrap.ts edits.
22
+ */
23
+ export function registerWebhookSourceEntries(entries: WebhookSourceModuleEntry[]): void {
24
+ setWebhookSources(entries.flatMap((entry) => entry.sources ?? []))
25
+ }
26
+
27
+ /**
28
+ * Bootstrap-time registration for module-declared webhook handlers.
29
+ * Driven by the `webhooks.handlers` generator plugin.
30
+ */
31
+ export function registerWebhookHandlerEntries(entries: WebhookHandlerModuleEntry[]): void {
32
+ setWebhookHandlers(entries.flatMap((entry) => entry.handlers ?? []))
33
+ }
@@ -2,13 +2,17 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { createModuleQueue, type Queue } from '@open-mercato/queue'
3
3
  import { createLogger } from '@open-mercato/shared/lib/logger'
4
4
  import type { WebhookDeliveryJob } from './delivery'
5
+ import type { InboundDispatchJob } from './inbound-dispatch'
5
6
 
6
7
  const logger = createLogger('webhooks')
7
8
 
8
9
  const queues = new Map<string, Queue<WebhookDeliveryJob>>()
10
+ const inboundQueues = new Map<string, Queue<InboundDispatchJob>>()
9
11
  const LOCAL_WORKER_PROMISE_KEY = '__openMercatoWebhookLocalWorkerPromise__'
12
+ const LOCAL_INBOUND_WORKER_PROMISE_KEY = '__openMercatoWebhookLocalInboundWorkerPromise__'
10
13
 
11
14
  export const WEBHOOK_DELIVERIES_QUEUE = 'webhook-deliveries'
15
+ export const WEBHOOK_INBOUND_DISPATCH_QUEUE = 'webhook-inbound-dispatch'
12
16
 
13
17
  export function getWebhookQueue(queueName: string = WEBHOOK_DELIVERIES_QUEUE): Queue<WebhookDeliveryJob> {
14
18
  const existing = queues.get(queueName)
@@ -61,3 +65,59 @@ export async function enqueueWebhookDelivery(job: WebhookDeliveryJob, delayMs?:
61
65
  await ensureLocalWebhookQueueWorkerStarted()
62
66
  return jobId
63
67
  }
68
+
69
+ export function getInboundDispatchQueue(
70
+ queueName: string = WEBHOOK_INBOUND_DISPATCH_QUEUE,
71
+ ): Queue<InboundDispatchJob> {
72
+ const existing = inboundQueues.get(queueName)
73
+ if (existing) return existing
74
+
75
+ const concurrency = Math.max(1, Number.parseInt(process.env.WEBHOOK_INBOUND_QUEUE_CONCURRENCY ?? '5', 10) || 5)
76
+ const created = createModuleQueue<InboundDispatchJob>(queueName, { concurrency })
77
+
78
+ inboundQueues.set(queueName, created)
79
+ return created
80
+ }
81
+
82
+ async function ensureLocalInboundQueueWorkerStarted(): Promise<void> {
83
+ if (process.env.QUEUE_STRATEGY === 'async') return
84
+
85
+ const globalStore = globalThis as typeof globalThis & {
86
+ [LOCAL_INBOUND_WORKER_PROMISE_KEY]?: Promise<void>
87
+ }
88
+
89
+ if (globalStore[LOCAL_INBOUND_WORKER_PROMISE_KEY]) {
90
+ await globalStore[LOCAL_INBOUND_WORKER_PROMISE_KEY]
91
+ return
92
+ }
93
+
94
+ globalStore[LOCAL_INBOUND_WORKER_PROMISE_KEY] = (async () => {
95
+ const queue = getInboundDispatchQueue()
96
+
97
+ await queue.process(async (job) => {
98
+ const [{ createRequestContainer }, { processInboundDispatchJob }] = await Promise.all([
99
+ import('@open-mercato/shared/lib/di/container'),
100
+ import('./inbound-dispatch'),
101
+ ])
102
+
103
+ const container = await createRequestContainer()
104
+ const em = (container.resolve('em') as EntityManager).fork()
105
+ await processInboundDispatchJob(em, job.payload, {
106
+ resolve: <T,>(name: string) => container.resolve(name) as T,
107
+ })
108
+ })
109
+ })().catch((error) => {
110
+ delete globalStore[LOCAL_INBOUND_WORKER_PROMISE_KEY]
111
+ logger.error('Failed to start local inbound dispatch worker', { err: error })
112
+ throw error
113
+ })
114
+
115
+ await globalStore[LOCAL_INBOUND_WORKER_PROMISE_KEY]
116
+ }
117
+
118
+ export async function enqueueInboundDispatch(job: InboundDispatchJob): Promise<string> {
119
+ const queue = getInboundDispatchQueue()
120
+ const jobId = await queue.enqueue(job)
121
+ await ensureLocalInboundQueueWorkerStarted()
122
+ return jobId
123
+ }