@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -4,21 +4,25 @@ import type { Locale } from '../config'
4
4
  // mock has to be in place before the module under test is loaded.
5
5
  const cookieStore = { value: undefined as string | undefined }
6
6
  const headerStore = { acceptLanguage: '' }
7
+ const nextHeadersCalls = { cookies: 0, headers: 0 }
8
+ let caseReadsTheRequest = true
7
9
 
8
- jest.mock(
9
- 'next/headers',
10
- () => ({
11
- cookies: async () => ({
10
+ jest.mock('next/headers', () => ({
11
+ cookies: async () => {
12
+ nextHeadersCalls.cookies += 1
13
+ return {
12
14
  get: (name: string) =>
13
15
  name === 'locale' && cookieStore.value ? { value: cookieStore.value } : undefined,
14
- }),
15
- headers: async () => ({
16
+ }
17
+ },
18
+ headers: async () => {
19
+ nextHeadersCalls.headers += 1
20
+ return {
16
21
  get: (name: string) =>
17
22
  name.toLowerCase() === 'accept-language' ? headerStore.acceptLanguage : null,
18
- }),
19
- }),
20
- { virtual: true },
21
- )
23
+ }
24
+ },
25
+ }))
22
26
 
23
27
  import { detectLocale } from '../server'
24
28
  import { clearRegisteredLocales, registerLocales } from '../locale-registry'
@@ -30,9 +34,14 @@ describe('detectLocale with a narrowed supported set', () => {
30
34
  cookieStore.value = undefined
31
35
  headerStore.acceptLanguage = ''
32
36
  delete process.env.OM_FORCE_LOCALE
37
+ nextHeadersCalls.cookies = 0
38
+ nextHeadersCalls.headers = 0
39
+ caseReadsTheRequest = true
40
+ clearRegisteredLocales()
33
41
  })
34
42
 
35
43
  afterEach(() => {
44
+ if (caseReadsTheRequest) expect(nextHeadersCalls.cookies + nextHeadersCalls.headers).toBeGreaterThan(0)
36
45
  clearRegisteredLocales()
37
46
  })
38
47
 
@@ -91,6 +100,7 @@ describe('detectLocale with a narrowed supported set', () => {
91
100
  })
92
101
 
93
102
  it('still lets OM_FORCE_LOCALE win over the narrowed set', async () => {
103
+ caseReadsTheRequest = false
94
104
  process.env.OM_FORCE_LOCALE = 'ko'
95
105
 
96
106
  try {
@@ -0,0 +1,116 @@
1
+ import type { RouteMatchParams } from '../../modules/registry'
2
+
3
+ const matchedApiRoutePaths = new WeakMap<Request, string>()
4
+ const ROUTE_IDENTITY_HEADER = 'x-open-mercato-route-identity'
5
+ const MAX_ROUTE_IDENTITY_TOKENS = 4096
6
+ const matchedApiRouteTokens = new Map<string, string>()
7
+
8
+ function normalizePath(path: string): string {
9
+ return (path.startsWith('/') ? path : `/${path}`).replace(/\/+$/, '') || '/'
10
+ }
11
+
12
+ function hasValidPathEncoding(request: Request): boolean {
13
+ try {
14
+ const pathname = new URL(request.url).pathname
15
+ for (const segment of pathname.split('/')) decodeURIComponent(segment)
16
+ return true
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ function buildCanonicalMatchedPath(pattern: string, params: RouteMatchParams): string | null {
23
+ const segments = normalizePath(pattern).split('/').slice(1)
24
+ const canonicalSegments: string[] = []
25
+
26
+ for (const segment of segments) {
27
+ const catchAll = segment.match(/^\[\.\.\.(.+)\]$/)
28
+ const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)\]\]$/)
29
+ const dynamic = segment.match(/^\[(.+)\]$/)
30
+ const paramName = catchAll?.[1] ?? optionalCatchAll?.[1] ?? dynamic?.[1]
31
+
32
+ if (!paramName) {
33
+ canonicalSegments.push(segment)
34
+ continue
35
+ }
36
+
37
+ const value = params[paramName]
38
+ if (Array.isArray(value)) {
39
+ canonicalSegments.push(...value)
40
+ continue
41
+ }
42
+ if (typeof value === 'string') {
43
+ canonicalSegments.push(value)
44
+ continue
45
+ }
46
+ if (optionalCatchAll) continue
47
+ return null
48
+ }
49
+
50
+ return `/${canonicalSegments.join('/')}`
51
+ }
52
+
53
+ function createRouteIdentityToken(): string | null {
54
+ const cryptoApi = globalThis.crypto
55
+ if (!cryptoApi) return null
56
+ if (typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
57
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(24))
58
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
59
+ }
60
+
61
+ function rememberRouteIdentity(token: string, canonicalPath: string): void {
62
+ matchedApiRouteTokens.set(token, canonicalPath)
63
+ while (matchedApiRouteTokens.size > MAX_ROUTE_IDENTITY_TOKENS) {
64
+ const oldestToken = matchedApiRouteTokens.keys().next().value
65
+ if (typeof oldestToken !== 'string') return
66
+ matchedApiRouteTokens.delete(oldestToken)
67
+ }
68
+ }
69
+
70
+ export function bindMatchedApiRoutePath(
71
+ request: Request,
72
+ pattern: string,
73
+ params: RouteMatchParams,
74
+ ): boolean {
75
+ if (!hasValidPathEncoding(request)) return false
76
+ const canonicalPath = buildCanonicalMatchedPath(pattern, params)
77
+ if (canonicalPath === null) return false
78
+ const token = createRouteIdentityToken()
79
+ if (token === null) return false
80
+ try {
81
+ request.headers.set(ROUTE_IDENTITY_HEADER, token)
82
+ } catch {
83
+ return false
84
+ }
85
+ rememberRouteIdentity(token, canonicalPath)
86
+ matchedApiRoutePaths.set(request, canonicalPath)
87
+ return true
88
+ }
89
+
90
+ export function getMatchedApiRoutePath(request: Request): string | undefined {
91
+ const directPath = matchedApiRoutePaths.get(request)
92
+ if (directPath !== undefined) return directPath
93
+ const token = request.headers.get(ROUTE_IDENTITY_HEADER)
94
+ if (!token) return undefined
95
+ return matchedApiRouteTokens.get(token)
96
+ }
97
+
98
+ function toInterceptorRoutePath(pathname: string): string {
99
+ if (pathname.startsWith('/api/')) return pathname.slice(5)
100
+ if (pathname === '/api') return ''
101
+ return pathname.replace(/^\/+/, '')
102
+ }
103
+
104
+ export function resolveApiInterceptorRoutePath(request: Request): string | null {
105
+ const matchedPath = getMatchedApiRoutePath(request)
106
+ if (matchedPath !== undefined) return toInterceptorRoutePath(matchedPath)
107
+ if (request.headers.has(ROUTE_IDENTITY_HEADER)) return null
108
+
109
+ try {
110
+ const pathname = new URL(request.url).pathname
111
+ for (const segment of pathname.split('/')) decodeURIComponent(segment)
112
+ return toInterceptorRoutePath(pathname)
113
+ } catch {
114
+ return null
115
+ }
116
+ }
@@ -2,6 +2,8 @@ export type TelemetryTraceCarrier = Record<string, string>
2
2
 
3
3
  export type TelemetrySpanAttributes = Record<string, string | number | boolean | undefined>
4
4
 
5
+ export type TelemetryMetricLabels = Record<string, string | number | boolean | undefined>
6
+
5
7
  export type TelemetrySpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer'
6
8
 
7
9
  /** The subset of the telemetry package's `Span` that bridge consumers need. */
@@ -43,6 +45,12 @@ export type TelemetryRuntime = {
43
45
  * running `fn` untraced.
44
46
  */
45
47
  withSpan?<T>(name: string, fn: (span: TelemetrySpan) => T, options?: TelemetrySpanOptions): T
48
+ recordHistogram?(
49
+ name: string,
50
+ value: number,
51
+ labels?: TelemetryMetricLabels,
52
+ unit?: string,
53
+ ): void
46
54
  recordHttpDuration(method: string, route: string, status: number, startedAt: number): void
47
55
  reportError(
48
56
  error: unknown,
@@ -17,6 +17,10 @@ import {
17
17
  getFrontendRouteManifests,
18
18
  resolvePageRouteMetadata,
19
19
  } from '../registry'
20
+ import {
21
+ getMatchedApiRoutePath,
22
+ resolveApiInterceptorRoutePath,
23
+ } from '../../lib/modules/api-route-identity'
20
24
 
21
25
  describe('CLI Modules Registry', () => {
22
26
  // Clear the registry before each test
@@ -628,6 +632,94 @@ describe('findApiRouteManifestMatch — specificity (issue #1870)', () => {
628
632
  const match = findApiRouteManifestMatch(unsorted, 'GET', '/api/things/new')
629
633
  expect(match?.route.path).toBe('/api/things/new')
630
634
  })
635
+
636
+ it.each([
637
+ ['normal spelling', 'http://localhost/api/auth/users/AbC-123?view=full', '/auth/users/AbC-123'],
638
+ ['mixed-case alias', 'http://localhost/api/AUTH/users/AbC-123?view=full', '/AUTH/users/AbC-123'],
639
+ ['percent-encoded alias', 'http://localhost/api/%61uth/users/AbC-123?view=full', '/auth/users/AbC-123'],
640
+ ])('binds the authored static route as canonical identity for %s', (_label, url, dispatcherPath) => {
641
+ const request = new Request(url)
642
+ const routes = [apiEntry('/auth/users/[id]')]
643
+ const match = findApiRouteManifestMatch(routes, 'GET', dispatcherPath, request)
644
+
645
+ expect(match?.params).toEqual({ id: 'AbC-123' })
646
+ expect(getMatchedApiRoutePath(request)).toBe('/auth/users/AbC-123')
647
+ expect(resolveApiInterceptorRoutePath(request.clone())).toBe('auth/users/AbC-123')
648
+ expect(resolveApiInterceptorRoutePath(new Request(request.url, request))).toBe('auth/users/AbC-123')
649
+ expect(new URL(request.url).searchParams.get('view')).toBe('full')
650
+ })
651
+
652
+ it('preserves catch-all parameter values in the canonical identity', () => {
653
+ const request = new Request('http://localhost/api/DOCS/guides/Getting-Started')
654
+ const routes = [apiEntry('/docs/[...slug]')]
655
+ const match = findApiRouteManifestMatch(
656
+ routes,
657
+ 'GET',
658
+ '/DOCS/guides/Getting-Started',
659
+ request,
660
+ )
661
+
662
+ expect(match?.params).toEqual({ slug: ['guides', 'Getting-Started'] })
663
+ expect(getMatchedApiRoutePath(request)).toBe('/docs/guides/Getting-Started')
664
+ expect(resolveApiInterceptorRoutePath(request.clone())).toBe('docs/guides/Getting-Started')
665
+ expect(resolveApiInterceptorRoutePath(new Request(request.url, request))).toBe('docs/guides/Getting-Started')
666
+ })
667
+
668
+ it('fails closed for an attacker-supplied route identity token', () => {
669
+ const request = new Request('http://localhost/api/AUTH/users/AbC-123', {
670
+ headers: { 'x-open-mercato-route-identity': 'attacker-controlled' },
671
+ })
672
+
673
+ expect(resolveApiInterceptorRoutePath(request)).toBeNull()
674
+ expect(resolveApiInterceptorRoutePath(request.clone())).toBeNull()
675
+ })
676
+
677
+ it('bounds clone-capability state and fails closed after eviction', () => {
678
+ const routes = [apiEntry('/auth/users/[id]')]
679
+ let firstClone: Request | null = null
680
+ let lastClone: Request | null = null
681
+
682
+ for (let index = 0; index < 4200; index += 1) {
683
+ const request = new Request(`http://localhost/api/auth/users/user-${index}`)
684
+ expect(findApiRouteManifestMatch(
685
+ routes,
686
+ 'GET',
687
+ `/auth/users/user-${index}`,
688
+ request,
689
+ )).toBeDefined()
690
+ if (index === 0) firstClone = request.clone()
691
+ if (index === 4199) lastClone = request.clone()
692
+ }
693
+
694
+ expect(firstClone).not.toBeNull()
695
+ expect(lastClone).not.toBeNull()
696
+ expect(resolveApiInterceptorRoutePath(firstClone!)).toBeNull()
697
+ expect(resolveApiInterceptorRoutePath(lastClone!)).toBe('auth/users/user-4199')
698
+ })
699
+
700
+ it('fails closed before binding a route when the request path has malformed encoding', () => {
701
+ const request = new Request('http://localhost/api/auth/%ZZ')
702
+ const match = findApiRouteManifestMatch(
703
+ [apiEntry('/auth/[id]')],
704
+ 'GET',
705
+ '/auth/%ZZ',
706
+ request,
707
+ )
708
+
709
+ expect(match).toBeUndefined()
710
+ expect(getMatchedApiRoutePath(request)).toBeUndefined()
711
+ })
712
+
713
+ it('keeps the three-argument matcher contract for non-request consumers', () => {
714
+ const match = findApiRouteManifestMatch(
715
+ [apiEntry('/auth/users/[id]')],
716
+ 'GET',
717
+ '/AUTH/users/AbC-123',
718
+ )
719
+
720
+ expect(match?.route.path).toBe('/auth/users/[id]')
721
+ expect(match?.params).toEqual({ id: 'AbC-123' })
722
+ })
631
723
  })
632
724
 
633
725
  describe('registerFrontendRouteManifests — sorts on registration (issue #1870)', () => {
@@ -0,0 +1,53 @@
1
+ import { createModuleEvents, isCoalescedBroadcastEvent } from '../factory'
2
+
3
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
4
+
5
+ describe('broadcastCoalescing declarations', () => {
6
+ const globalScope = globalThis as Record<string, unknown>
7
+ const originalRegistry = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
8
+
9
+ beforeEach(() => {
10
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
11
+ })
12
+
13
+ afterAll(() => {
14
+ if (originalRegistry === undefined) delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
15
+ else globalScope[GLOBAL_EVENT_REGISTRY_KEY] = originalRegistry
16
+ })
17
+
18
+ it('recognizes an opted-in browser event and nothing else', () => {
19
+ createModuleEvents({
20
+ moduleId: 'coalesce_decl_test',
21
+ events: [
22
+ { id: 'coalesce_decl_test.bulk', label: 'Bulk', clientBroadcast: true, broadcastCoalescing: true },
23
+ { id: 'coalesce_decl_test.portal', label: 'Portal', portalBroadcast: true, broadcastCoalescing: true },
24
+ { id: 'coalesce_decl_test.plain_browser', label: 'Plain Browser', clientBroadcast: true },
25
+ { id: 'coalesce_decl_test.local', label: 'Local' },
26
+ ] as const,
27
+ })
28
+
29
+ expect(isCoalescedBroadcastEvent('coalesce_decl_test.bulk')).toBe(true)
30
+ expect(isCoalescedBroadcastEvent('coalesce_decl_test.portal')).toBe(true)
31
+ expect(isCoalescedBroadcastEvent('coalesce_decl_test.plain_browser')).toBe(false)
32
+ expect(isCoalescedBroadcastEvent('coalesce_decl_test.local')).toBe(false)
33
+ expect(isCoalescedBroadcastEvent('coalesce_decl_test.never_declared')).toBe(false)
34
+ })
35
+
36
+ it('rejects coalescing private cross-process coordination', () => {
37
+ expect(() => createModuleEvents({
38
+ moduleId: 'coalesce_decl_test',
39
+ events: [
40
+ { id: 'coalesce_decl_test.private', label: 'Private', crossProcessBroadcast: true, broadcastCoalescing: true },
41
+ ] as const,
42
+ })).toThrow(/crossProcessBroadcast/)
43
+ })
44
+
45
+ it('rejects coalescing an event with no browser delivery to coalesce', () => {
46
+ expect(() => createModuleEvents({
47
+ moduleId: 'coalesce_decl_test',
48
+ events: [
49
+ { id: 'coalesce_decl_test.orphan', label: 'Orphan', broadcastCoalescing: true },
50
+ ] as const,
51
+ })).toThrow(/clientBroadcast or portalBroadcast/)
52
+ })
53
+ })
@@ -212,6 +212,20 @@ export function isPrivateCrossProcessEventEmitter(
212
212
  && event.module === emitterModuleId
213
213
  }
214
214
 
215
+ /**
216
+ * Check whether an event opted into coalesced browser delivery.
217
+ * Read by the event bus before it hands a browser dispatch to the coalescer.
218
+ */
219
+ export function isCoalescedBroadcastEvent(eventId: string): boolean {
220
+ const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
221
+ if (event?.broadcastCoalescing !== true) return false
222
+ // Private cross-process coordination must never be delayed, and an event with
223
+ // no browser sink has nothing to coalesce. Both are rejected at declaration
224
+ // time, so this is defence in depth for events registered by other paths.
225
+ if (event.crossProcessBroadcast === true) return false
226
+ return event.clientBroadcast === true || event.portalBroadcast === true
227
+ }
228
+
215
229
  /**
216
230
  * Check if an event has portalBroadcast enabled.
217
231
  * Used by the portal SSE endpoint to filter events for the Portal Event Bridge.
@@ -308,6 +322,23 @@ export function createModuleEvents<
308
322
  }),
309
323
  )
310
324
 
325
+ for (const event of fullEvents) {
326
+ if (event.broadcastCoalescing !== true) continue
327
+ if (event.crossProcessBroadcast === true) {
328
+ throw new Error(
329
+ `[internal] Event "${event.id}" declared by module "${moduleId}" combines crossProcessBroadcast with ` +
330
+ 'broadcastCoalescing. Private cross-process coordination must be delivered immediately — delaying it ' +
331
+ 'would let another process serve stale data. Drop one of the two flags.',
332
+ )
333
+ }
334
+ if (event.clientBroadcast !== true && event.portalBroadcast !== true) {
335
+ throw new Error(
336
+ `[internal] Event "${event.id}" declared by module "${moduleId}" sets broadcastCoalescing without ` +
337
+ 'clientBroadcast or portalBroadcast. There is no browser delivery to coalesce.',
338
+ )
339
+ }
340
+ }
341
+
311
342
  // Register all event IDs and definitions in the global registry.
312
343
  for (const event of fullEvents) {
313
344
  addDeclaredEvent(event)
@@ -75,6 +75,19 @@ export interface EventDefinition {
75
75
  crossProcessBroadcast?: boolean
76
76
  /** When true, this event is bridged to the customer portal via SSE (Portal Event Bridge). Default: false */
77
77
  portalBroadcast?: boolean
78
+ /**
79
+ * When true, browser deliveries of this event coalesce: within
80
+ * OM_BROADCAST_COALESCE_INTERVAL_MS only the newest payload per
81
+ * audience (event, tenant, organizations, recipients) reaches the SSE bridges,
82
+ * and a trailing flush guarantees the last one is delivered. Subscribers, webhooks and the queue are
83
+ * unaffected — the domain event still fires once per record.
84
+ *
85
+ * Declare it only on events whose browser consumers react to the fact that
86
+ * something changed (a list refresh), never to each occurrence. Cannot be
87
+ * combined with crossProcessBroadcast: delaying private coordination would let
88
+ * another process serve stale data. Default: false
89
+ */
90
+ broadcastCoalescing?: boolean
78
91
  /**
79
92
  * Optional declared payload typing. Platform-emitted CRUD events
80
93
  * (`*.created`/`*.updated`/`*.deleted` with category `crud`) receive a
@@ -165,6 +165,7 @@ export type NotificationDto = {
165
165
  labelKey?: string
166
166
  variant?: string
167
167
  icon?: string
168
+ href?: string
168
169
  }>
169
170
  primaryActionId?: string
170
171
  sourceModule?: string | null
@@ -12,6 +12,7 @@ import {
12
12
  composeApiRouteOverrides,
13
13
  composePageRouteOverrides,
14
14
  } from './overrides'
15
+ import { bindMatchedApiRoutePath } from '../lib/modules/api-route-identity'
15
16
 
16
17
  const logger = createLogger('shared').child({ component: 'cli-registry' })
17
18
 
@@ -414,12 +415,14 @@ export function findRouteManifestMatch<T extends { pattern?: string; path?: stri
414
415
  export function findApiRouteManifestMatch<T extends { path: string; methods: HttpMethod[] }>(
415
416
  routes: T[],
416
417
  method: HttpMethod,
417
- pathname: string
418
+ pathname: string,
419
+ request?: Request,
418
420
  ): { route: T; params: RouteMatchParams } | undefined {
419
421
  for (const route of ensureSortedRoutes(routes)) {
420
422
  if (!route.methods.includes(method)) continue
421
423
  const params = matchRoutePattern(route.path, pathname)
422
424
  if (params) {
425
+ if (request && !bindMatchedApiRoutePath(request, route.path, params)) return undefined
423
426
  return { route, params }
424
427
  }
425
428
  }