@open-mercato/shared 0.8.1-develop.7297.1.67605e74f9 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +12 -5
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/modules/registry.ts
CHANGED
|
@@ -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
|
}
|