@open-mercato/webhooks 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7237.1.635faf2c7a

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 (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/modules/webhooks/api/webhooks/[id]/route.js +3 -0
  4. package/dist/modules/webhooks/api/webhooks/[id]/route.js.map +2 -2
  5. package/dist/modules/webhooks/api/webhooks/[id]/test/route.js +1 -1
  6. package/dist/modules/webhooks/api/webhooks/[id]/test/route.js.map +2 -2
  7. package/dist/modules/webhooks/api/webhooks/route.js +13 -0
  8. package/dist/modules/webhooks/api/webhooks/route.js.map +2 -2
  9. package/dist/modules/webhooks/lib/delivery.js +8 -1
  10. package/dist/modules/webhooks/lib/delivery.js.map +2 -2
  11. package/dist/modules/webhooks/lib/queue.js +1 -1
  12. package/dist/modules/webhooks/lib/queue.js.map +2 -2
  13. package/dist/modules/webhooks/lib/subscription-cache.js +93 -0
  14. package/dist/modules/webhooks/lib/subscription-cache.js.map +7 -0
  15. package/dist/modules/webhooks/subscribers/outbound-dispatch.js +34 -2
  16. package/dist/modules/webhooks/subscribers/outbound-dispatch.js.map +2 -2
  17. package/dist/modules/webhooks/workers/webhook-delivery.js +1 -1
  18. package/dist/modules/webhooks/workers/webhook-delivery.js.map +2 -2
  19. package/package.json +7 -6
  20. package/src/modules/webhooks/api/webhooks/[id]/__tests__/subscription-cache-invalidation.test.ts +107 -0
  21. package/src/modules/webhooks/api/webhooks/[id]/route.ts +5 -0
  22. package/src/modules/webhooks/api/webhooks/[id]/test/route.ts +1 -1
  23. package/src/modules/webhooks/api/webhooks/__tests__/subscription-cache-invalidation.test.ts +93 -0
  24. package/src/modules/webhooks/api/webhooks/route.ts +14 -0
  25. package/src/modules/webhooks/lib/__tests__/subscription-cache-invalidation.test.ts +207 -0
  26. package/src/modules/webhooks/lib/delivery.ts +12 -2
  27. package/src/modules/webhooks/lib/queue.ts +1 -1
  28. package/src/modules/webhooks/lib/subscription-cache.ts +135 -0
  29. package/src/modules/webhooks/subscribers/__tests__/outbound-dispatch.subscription-cache.test.ts +289 -0
  30. package/src/modules/webhooks/subscribers/outbound-dispatch.ts +49 -6
  31. package/src/modules/webhooks/workers/__tests__/webhook-delivery.test.ts +5 -1
  32. package/src/modules/webhooks/workers/webhook-delivery.ts +1 -1
@@ -0,0 +1,207 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createCacheService, type CacheStrategy } from '@open-mercato/cache'
3
+ import {
4
+ getCachedActiveWebhooks,
5
+ invalidateWebhookSubscriptionCacheFor,
6
+ setCachedActiveWebhooks,
7
+ } from '../subscription-cache'
8
+ import { processWebhookDeliveryJob } from '../delivery'
9
+
10
+ jest.mock('../../events', () => ({
11
+ emitWebhooksEvent: jest.fn(async () => undefined),
12
+ }))
13
+
14
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
15
+ findOneWithDecryption: jest.fn(),
16
+ }))
17
+
18
+ jest.mock('../integration-state', () => ({
19
+ isWebhookIntegrationEnabled: jest.fn(async () => true),
20
+ WEBHOOK_INTEGRATION_DISABLED_MESSAGE: 'disabled',
21
+ }))
22
+
23
+ jest.mock('@open-mercato/shared/lib/webhooks', () => ({
24
+ buildWebhookHeaders: jest.fn(() => ({})),
25
+ generateMessageId: jest.fn(() => 'msg-1'),
26
+ }))
27
+
28
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
29
+
30
+ const findOneWithDecryptionMock = findOneWithDecryption as jest.MockedFunction<typeof findOneWithDecryption>
31
+
32
+ async function seed(cache: CacheStrategy): Promise<void> {
33
+ await setCachedActiveWebhooks(
34
+ cache,
35
+ 'tenant-1',
36
+ 'org-1',
37
+ [{ id: 'w-1', tenantId: 'tenant-1', organizationId: 'org-1', subscribedEvents: ['a.b.c'] }],
38
+ 60_000,
39
+ )
40
+ }
41
+
42
+ describe('invalidateWebhookSubscriptionCacheFor', () => {
43
+ it('accepts a bare resolve function', async () => {
44
+ const cache = createCacheService({ strategy: 'memory' })
45
+ await seed(cache)
46
+ await invalidateWebhookSubscriptionCacheFor((token: string) => (token === 'cache' ? cache : null), 'tenant-1')
47
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
48
+ })
49
+
50
+ it('accepts a container', async () => {
51
+ const cache = createCacheService({ strategy: 'memory' })
52
+ await seed(cache)
53
+ await invalidateWebhookSubscriptionCacheFor({ resolve: (token: string) => (token === 'cache' ? cache : null) }, 'tenant-1')
54
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
55
+ })
56
+
57
+ it('is a no-op when no cache resolves, when the resolver throws, and when the tenant is missing', async () => {
58
+ const cache = createCacheService({ strategy: 'memory' })
59
+ await seed(cache)
60
+
61
+ await expect(invalidateWebhookSubscriptionCacheFor(undefined, 'tenant-1')).resolves.toBeUndefined()
62
+ await expect(invalidateWebhookSubscriptionCacheFor(() => null, 'tenant-1')).resolves.toBeUndefined()
63
+ await expect(invalidateWebhookSubscriptionCacheFor(() => { throw new Error('unavailable') }, 'tenant-1')).resolves.toBeUndefined()
64
+ await expect(invalidateWebhookSubscriptionCacheFor({ resolve: () => cache }, ' ')).resolves.toBeUndefined()
65
+
66
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
67
+ })
68
+
69
+ it('swallows a cache backend failure instead of throwing', async () => {
70
+ const brokenCache = {
71
+ get: jest.fn(async () => null),
72
+ set: jest.fn(async () => undefined),
73
+ deleteByTags: jest.fn(async () => { throw new Error('redis down') }),
74
+ }
75
+ await expect(invalidateWebhookSubscriptionCacheFor(() => brokenCache, 'tenant-1')).resolves.toBeUndefined()
76
+ expect(brokenCache.deleteByTags).toHaveBeenCalled()
77
+ })
78
+ })
79
+
80
+ describe('processWebhookDeliveryJob auto-disable invalidates the subscription cache', () => {
81
+ const originalFetch = globalThis.fetch
82
+
83
+ beforeEach(() => {
84
+ jest.clearAllMocks()
85
+ process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS = '1'
86
+ globalThis.fetch = jest.fn(async () => new Response('nope', { status: 500 })) as unknown as typeof fetch
87
+ })
88
+
89
+ afterEach(() => {
90
+ globalThis.fetch = originalFetch
91
+ delete process.env.OM_WEBHOOKS_ALLOW_PRIVATE_URLS
92
+ })
93
+
94
+ function buildDelivery() {
95
+ return {
96
+ id: 'delivery-1',
97
+ tenantId: 'tenant-1',
98
+ organizationId: 'org-1',
99
+ webhookId: 'w-1',
100
+ status: 'pending',
101
+ payload: { type: 'a.b.c', timestamp: new Date().toISOString(), data: {} },
102
+ enqueuedAt: new Date(),
103
+ eventType: 'a.b.c',
104
+ maxAttempts: 1,
105
+ attemptNumber: 0,
106
+ messageId: 'msg-1',
107
+ responseBody: null,
108
+ responseHeaders: null,
109
+ responseStatus: null,
110
+ nextRetryAt: null,
111
+ errorMessage: null,
112
+ lastAttemptAt: null,
113
+ deliveredAt: null,
114
+ durationMs: null,
115
+ }
116
+ }
117
+
118
+ function buildWebhook(autoDisableThreshold: number) {
119
+ return {
120
+ id: 'w-1',
121
+ url: 'http://127.0.0.1:3001/hook',
122
+ isActive: true,
123
+ timeoutMs: 1000,
124
+ httpMethod: 'POST',
125
+ customHeaders: null,
126
+ consecutiveFailures: 0,
127
+ lastFailureAt: null,
128
+ lastSuccessAt: null,
129
+ autoDisableThreshold,
130
+ maxRetries: 1,
131
+ secret: 'secret',
132
+ previousSecret: null,
133
+ tenantId: 'tenant-1',
134
+ organizationId: 'org-1',
135
+ }
136
+ }
137
+
138
+ function buildEm(delivery: Record<string, unknown>) {
139
+ return {
140
+ findOne: jest.fn(async () => delivery),
141
+ flush: jest.fn(async () => undefined),
142
+ } as unknown as EntityManager
143
+ }
144
+
145
+ it('drops the cached tenant list when the webhook is auto-disabled', async () => {
146
+ const cache = createCacheService({ strategy: 'memory' })
147
+ await seed(cache)
148
+
149
+ const webhook = buildWebhook(1)
150
+ findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
151
+
152
+ await processWebhookDeliveryJob(buildEm(buildDelivery()), {
153
+ deliveryId: 'delivery-1',
154
+ tenantId: 'tenant-1',
155
+ organizationId: 'org-1',
156
+ }, { scheduleRetries: false, resolver: () => cache })
157
+
158
+ expect(webhook.isActive).toBe(false)
159
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).toBeNull()
160
+ })
161
+
162
+ it('commits isActive=false before emitting and invalidating, so no concurrent read can repopulate a stale entry', async () => {
163
+ const webhook = buildWebhook(1)
164
+ const delivery = buildDelivery()
165
+ const calls: string[] = []
166
+
167
+ const cache = {
168
+ get: jest.fn(async () => null),
169
+ set: jest.fn(async () => undefined),
170
+ deleteByTags: jest.fn(async () => { calls.push('deleteByTags') }),
171
+ }
172
+ const em = {
173
+ findOne: jest.fn(async () => delivery),
174
+ flush: jest.fn(async () => { calls.push(`flush:isActive=${webhook.isActive}`) }),
175
+ } as unknown as EntityManager
176
+
177
+ findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
178
+
179
+ await processWebhookDeliveryJob(em, {
180
+ deliveryId: 'delivery-1',
181
+ tenantId: 'tenant-1',
182
+ organizationId: 'org-1',
183
+ }, { scheduleRetries: false, resolver: () => cache })
184
+
185
+ const invalidateIndex = calls.indexOf('deleteByTags')
186
+ expect(invalidateIndex).toBeGreaterThan(-1)
187
+ expect(calls.slice(0, invalidateIndex)).toContain('flush:isActive=false')
188
+ expect(calls.slice(invalidateIndex)).not.toContain('flush:isActive=true')
189
+ })
190
+
191
+ it('keeps the cached tenant list when the failure does not trip auto-disable', async () => {
192
+ const cache = createCacheService({ strategy: 'memory' })
193
+ await seed(cache)
194
+
195
+ const webhook = buildWebhook(0)
196
+ findOneWithDecryptionMock.mockResolvedValueOnce(webhook as never)
197
+
198
+ await processWebhookDeliveryJob(buildEm(buildDelivery()), {
199
+ deliveryId: 'delivery-1',
200
+ tenantId: 'tenant-1',
201
+ organizationId: 'org-1',
202
+ }, { scheduleRetries: false, resolver: () => cache })
203
+
204
+ expect(webhook.isActive).toBe(true)
205
+ expect(await getCachedActiveWebhooks(cache, 'tenant-1', 'org-1')).not.toBeNull()
206
+ })
207
+ })
@@ -11,6 +11,10 @@ import {
11
11
  safeWebhookFetch,
12
12
  UnsafeWebhookUrlError,
13
13
  } from './url-safety'
14
+ import {
15
+ invalidateWebhookSubscriptionCacheFor,
16
+ type WebhookCacheDependencyResolver,
17
+ } from './subscription-cache'
14
18
 
15
19
  export interface WebhookDeliveryJob {
16
20
  deliveryId: string
@@ -27,6 +31,7 @@ export interface CreateWebhookDeliveryInput {
27
31
 
28
32
  type ProcessWebhookDeliveryOptions = {
29
33
  scheduleRetries?: boolean
34
+ resolver?: WebhookCacheDependencyResolver
30
35
  }
31
36
 
32
37
  type WebhookBody = {
@@ -211,6 +216,7 @@ export async function processWebhookDeliveryJob(
211
216
  delivery,
212
217
  canRetry: shouldRetryStatus(response.status),
213
218
  scheduleRetries: options.scheduleRetries !== false,
219
+ resolver: options.resolver,
214
220
  fallbackMessage: `HTTP ${response.status}`,
215
221
  })
216
222
 
@@ -254,6 +260,7 @@ export async function processWebhookDeliveryJob(
254
260
  delivery,
255
261
  canRetry: true,
256
262
  scheduleRetries: options.scheduleRetries !== false,
263
+ resolver: options.resolver,
257
264
  fallbackMessage: delivery.errorMessage,
258
265
  })
259
266
 
@@ -318,6 +325,7 @@ type HandleFailedDeliveryInput = {
318
325
  canRetry: boolean
319
326
  scheduleRetries: boolean
320
327
  fallbackMessage: string
328
+ resolver?: WebhookCacheDependencyResolver
321
329
  }
322
330
 
323
331
  async function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<void> {
@@ -364,17 +372,19 @@ async function handleFailedDelivery(input: HandleFailedDeliveryInput): Promise<v
364
372
  })
365
373
  }
366
374
 
375
+ await input.em.flush()
376
+
367
377
  if (webhook.autoDisableThreshold > 0 && webhook.consecutiveFailures >= webhook.autoDisableThreshold) {
368
378
  webhook.isActive = false
379
+ await input.em.flush()
369
380
  await emitWebhooksEvent('webhooks.webhook.disabled', {
370
381
  webhookId: webhook.id,
371
382
  organizationId: webhook.organizationId,
372
383
  tenantId: webhook.tenantId,
373
384
  consecutiveFailures: webhook.consecutiveFailures,
374
385
  })
386
+ await invalidateWebhookSubscriptionCacheFor(input.resolver, webhook.tenantId)
375
387
  }
376
-
377
- await input.em.flush()
378
388
  }
379
389
 
380
390
  function normalizeWebhookBody(eventType: string, payload: Record<string, unknown>): WebhookBody {
@@ -48,7 +48,7 @@ async function ensureLocalWebhookQueueWorkerStarted(): Promise<void> {
48
48
 
49
49
  const container = await createRequestContainer()
50
50
  const em = (container.resolve('em') as EntityManager).fork()
51
- await processWebhookDeliveryJob(em, job.payload)
51
+ await processWebhookDeliveryJob(em, job.payload, { resolver: container })
52
52
  })
53
53
  })().catch((error) => {
54
54
  delete globalStore[LOCAL_WORKER_PROMISE_KEY]
@@ -0,0 +1,135 @@
1
+ import type { CacheStrategy } from '@open-mercato/cache'
2
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+
5
+ const logger = createLogger('webhooks').child({ component: 'subscription-cache' })
6
+
7
+ const DEFAULT_TTL_MS = 60_000
8
+ const GLOBAL_SCOPE_SEGMENT = 'global'
9
+
10
+ export type CachedActiveWebhook = {
11
+ id: string
12
+ tenantId: string
13
+ organizationId: string | null
14
+ subscribedEvents: string[]
15
+ }
16
+
17
+ export type WebhookSubscriptionCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>
18
+
19
+ export function isWebhookSubscriptionCache(value: unknown): value is WebhookSubscriptionCache {
20
+ if (!value || typeof value !== 'object') return false
21
+ const candidate = value as Partial<WebhookSubscriptionCache>
22
+ return (
23
+ typeof candidate.get === 'function'
24
+ && typeof candidate.set === 'function'
25
+ && typeof candidate.deleteByTags === 'function'
26
+ )
27
+ }
28
+
29
+ export type WebhookCacheDependencyResolver =
30
+ | ((token: string) => unknown)
31
+ | { resolve: (token: string) => unknown }
32
+
33
+ export function resolveWebhookSubscriptionCache(resolve: (token: string) => unknown): WebhookSubscriptionCache | null {
34
+ try {
35
+ const cache = resolve('cache')
36
+ return isWebhookSubscriptionCache(cache) ? cache : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ function toResolveFunction(source: WebhookCacheDependencyResolver | null | undefined): ((token: string) => unknown) | null {
43
+ if (typeof source === 'function') return source
44
+ if (source && typeof source.resolve === 'function') return source.resolve.bind(source)
45
+ return null
46
+ }
47
+
48
+ export function getWebhookSubscriptionCacheTtlMs(): number {
49
+ const raw = process.env.OM_WEBHOOKS_SUBSCRIPTION_CACHE_TTL_MS
50
+ if (raw === undefined || raw.trim() === '') return DEFAULT_TTL_MS
51
+ const parsed = Number.parseInt(raw, 10)
52
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_TTL_MS
53
+ return parsed
54
+ }
55
+
56
+ function normalizeScopeSegment(organizationId: string | null | undefined): string {
57
+ return organizationId ?? GLOBAL_SCOPE_SEGMENT
58
+ }
59
+
60
+ export function getWebhookSubscriptionCacheKey(tenantId: string, organizationId: string | null | undefined): string {
61
+ return `webhooks:subscriptions:${tenantId}:${normalizeScopeSegment(organizationId)}`
62
+ }
63
+
64
+ export function getWebhookSubscriptionCacheTag(tenantId: string): string {
65
+ return `webhooks:subscriptions:tenant:${tenantId}`
66
+ }
67
+
68
+ function isCachedActiveWebhookList(value: unknown): value is CachedActiveWebhook[] {
69
+ return Array.isArray(value) && value.every((entry) => (
70
+ entry !== null
71
+ && typeof entry === 'object'
72
+ && typeof (entry as CachedActiveWebhook).id === 'string'
73
+ && typeof (entry as CachedActiveWebhook).tenantId === 'string'
74
+ && Array.isArray((entry as CachedActiveWebhook).subscribedEvents)
75
+ ))
76
+ }
77
+
78
+ export async function getCachedActiveWebhooks(
79
+ cache: WebhookSubscriptionCache | null,
80
+ tenantId: string,
81
+ organizationId: string | null | undefined,
82
+ ): Promise<CachedActiveWebhook[] | null> {
83
+ if (!cache) return null
84
+ try {
85
+ const value = await runWithCacheTenant(tenantId, () => cache.get(getWebhookSubscriptionCacheKey(tenantId, organizationId)))
86
+ return isCachedActiveWebhookList(value) ? value : null
87
+ } catch (error) {
88
+ logger.warn('Failed to read webhook subscription cache', { err: error })
89
+ return null
90
+ }
91
+ }
92
+
93
+ export async function setCachedActiveWebhooks(
94
+ cache: WebhookSubscriptionCache | null,
95
+ tenantId: string,
96
+ organizationId: string | null | undefined,
97
+ webhooks: CachedActiveWebhook[],
98
+ ttlMs: number,
99
+ ): Promise<void> {
100
+ if (!cache || ttlMs <= 0) return
101
+ try {
102
+ await runWithCacheTenant(tenantId, () => cache.set(
103
+ getWebhookSubscriptionCacheKey(tenantId, organizationId),
104
+ webhooks,
105
+ { ttl: ttlMs, tags: [getWebhookSubscriptionCacheTag(tenantId)] },
106
+ ))
107
+ } catch (error) {
108
+ logger.warn('Failed to write webhook subscription cache', { err: error })
109
+ }
110
+ }
111
+
112
+ export async function invalidateWebhookSubscriptionCache(
113
+ cache: WebhookSubscriptionCache | null,
114
+ tenantId: string | null | undefined,
115
+ ): Promise<void> {
116
+ if (!cache) return
117
+ const normalizedTenantId = tenantId?.trim()
118
+ if (!normalizedTenantId) return
119
+ try {
120
+ await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags([getWebhookSubscriptionCacheTag(normalizedTenantId)]))
121
+ } catch (error) {
122
+ logger.warn('Failed to invalidate webhook subscription cache', { err: error })
123
+ }
124
+ }
125
+
126
+ export async function invalidateWebhookSubscriptionCacheFor(
127
+ source: WebhookCacheDependencyResolver | null | undefined,
128
+ tenantId: string | null | undefined,
129
+ ): Promise<void> {
130
+ const normalizedTenantId = tenantId?.trim()
131
+ if (!normalizedTenantId) return
132
+ const resolve = toResolveFunction(source)
133
+ if (!resolve) return
134
+ await invalidateWebhookSubscriptionCache(resolveWebhookSubscriptionCache(resolve), normalizedTenantId)
135
+ }