@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
@@ -15,6 +15,7 @@ import type {
15
15
  import { getEnrichersForEntity } from './enricher-registry'
16
16
  import { logEnricherTiming } from '../umes/enricher-timing'
17
17
  import { createLogger } from '../logger'
18
+ import { getTelemetryRuntime } from '../telemetry/runtime'
18
19
  import { authorizeFeatures } from '../../security/featurePolicy'
19
20
 
20
21
  const logger = createLogger('shared').child({ component: 'umes' })
@@ -22,7 +23,82 @@ const logger = createLogger('shared').child({ component: 'umes' })
22
23
  const DEFAULT_TIMEOUT = 2000
23
24
  const SLOW_WARN_MS = 100
24
25
  const SLOW_ERROR_MS = 500
26
+ const LOG_THROTTLE_INTERVAL_MS = 30_000
25
27
  const DEFAULT_CACHE_TTL_MS = 60_000
28
+ const ENRICHER_DURATION_METRIC = 'om.enricher.duration'
29
+
30
+ type SlowLogState = {
31
+ lastLoggedAt: number
32
+ suppressedCount: number
33
+ maxElapsedMs: number
34
+ }
35
+
36
+ const slowLogStateByEnricher = new Map<string, SlowLogState>()
37
+ const metricFailureLoggedAtByEnricher = new Map<string, number>()
38
+
39
+ function logMetricRecordingFailure(enricherId: string, err: unknown, failedAt: number): void {
40
+ const lastLoggedAt = metricFailureLoggedAtByEnricher.get(enricherId)
41
+ if (lastLoggedAt !== undefined && failedAt - lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) return
42
+ metricFailureLoggedAtByEnricher.set(enricherId, failedAt)
43
+ logger.warn('Enricher duration metric recording failed', {
44
+ enricherId,
45
+ metric: ENRICHER_DURATION_METRIC,
46
+ err,
47
+ })
48
+ }
49
+
50
+ function recordEnricherDuration(
51
+ enricherId: string,
52
+ moduleId: string,
53
+ targetEntity: string,
54
+ elapsedMs: number,
55
+ finishedAt: number,
56
+ ): void {
57
+ logEnricherTiming(enricherId, moduleId, targetEntity, elapsedMs)
58
+ try {
59
+ getTelemetryRuntime()?.recordHistogram?.(
60
+ ENRICHER_DURATION_METRIC,
61
+ elapsedMs / 1000,
62
+ { 'enricher.id': enricherId },
63
+ 's',
64
+ )
65
+ } catch (err) {
66
+ logMetricRecordingFailure(enricherId, err, finishedAt)
67
+ }
68
+ }
69
+
70
+ function logSlowEnricher(enricherId: string, elapsedMs: number, finishedAt: number): void {
71
+ if (elapsedMs <= SLOW_WARN_MS) return
72
+
73
+ if (elapsedMs <= SLOW_ERROR_MS) {
74
+ logger.debug('Enricher exceeded slow threshold', {
75
+ enricherId,
76
+ elapsedMs,
77
+ thresholdMs: SLOW_WARN_MS,
78
+ })
79
+ return
80
+ }
81
+
82
+ const state = slowLogStateByEnricher.get(enricherId)
83
+ if (state && finishedAt - state.lastLoggedAt < LOG_THROTTLE_INTERVAL_MS) {
84
+ state.suppressedCount += 1
85
+ state.maxElapsedMs = Math.max(state.maxElapsedMs, elapsedMs)
86
+ return
87
+ }
88
+
89
+ logger.error('Enricher exceeded slow threshold', {
90
+ enricherId,
91
+ elapsedMs,
92
+ thresholdMs: SLOW_ERROR_MS,
93
+ suppressedCount: state?.suppressedCount ?? 0,
94
+ maxElapsedMs: Math.max(state?.maxElapsedMs ?? 0, elapsedMs),
95
+ })
96
+ slowLogStateByEnricher.set(enricherId, {
97
+ lastLoggedAt: finishedAt,
98
+ suppressedCount: 0,
99
+ maxElapsedMs: 0,
100
+ })
101
+ }
26
102
 
27
103
  function timeoutPromise(ms: number): Promise<never> {
28
104
  return new Promise((_, reject) =>
@@ -378,13 +454,10 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
378
454
  )
379
455
  }
380
456
 
381
- const elapsedMs = Date.now() - startTime
382
- if (elapsedMs > SLOW_ERROR_MS) {
383
- logger.error('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_ERROR_MS })
384
- } else if (elapsedMs > SLOW_WARN_MS) {
385
- logger.warn('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS })
386
- }
387
- logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)
457
+ const finishedAt = Date.now()
458
+ const elapsedMs = finishedAt - startTime
459
+ logSlowEnricher(enricher.id, elapsedMs, finishedAt)
460
+ recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)
388
461
 
389
462
  currentItems = result
390
463
  if (shouldUseCache && cacheKey) {
@@ -482,8 +555,9 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
482
555
  timeoutPromise(timeout),
483
556
  ])
484
557
 
485
- const elapsedMs = Date.now() - startTime
486
- logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)
558
+ const finishedAt = Date.now()
559
+ const elapsedMs = finishedAt - startTime
560
+ recordEnricherDuration(enricher.id, entry.moduleId, targetEntity, elapsedMs, finishedAt)
487
561
 
488
562
  currentRecord = result
489
563
  if (shouldUseCache && cacheKey) {
@@ -69,6 +69,7 @@ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCache
69
69
  import type { EnricherContext } from './response-enricher'
70
70
  import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
71
71
  import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
72
+ import { resolveApiInterceptorRoutePath } from '../modules/api-route-identity'
72
73
  import { mergeIdFilter, parseIdsParam, isIdsParamProvided } from './ids'
73
74
  import { buildQueryParams } from './query-params'
74
75
  import { mergeAdvancedFilters } from './advanced-filter-integration'
@@ -790,17 +791,6 @@ function snapshotEntity(entity: unknown): Record<string, unknown> | undefined {
790
791
  return safeClone(entity) as Record<string, unknown>
791
792
  }
792
793
 
793
- function normalizeInterceptorRoutePath(request: Request): string {
794
- try {
795
- const pathname = new URL(request.url).pathname
796
- if (pathname.startsWith('/api/')) return pathname.slice(5)
797
- if (pathname === '/api') return ''
798
- return pathname.replace(/^\/+/, '')
799
- } catch {
800
- return ''
801
- }
802
- }
803
-
804
794
  function toInterceptorHeaders(headers: Headers): Record<string, string> {
805
795
  const output: Record<string, string> = {}
806
796
  headers.forEach((value, key) => {
@@ -1029,7 +1019,7 @@ function safeClone<T>(value: T): T {
1029
1019
  }
1030
1020
 
1031
1021
  function collectScopeOrganizationIds(ctx: CrudCtx): Array<string | null> {
1032
- if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
1022
+ if (Array.isArray(ctx.organizationIds)) {
1033
1023
  return Array.from(new Set(ctx.organizationIds))
1034
1024
  }
1035
1025
  const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
@@ -1059,7 +1049,7 @@ function buildCrudCacheKey(
1059
1049
  const scopeIds = collectScopeOrganizationIds(ctx)
1060
1050
  const scopeSegment = scopeIds.length
1061
1051
  ? scopeIds.map((id) => normalizeTagSegment(id)).sort((a, b) => a.localeCompare(b)).join(',')
1062
- : 'none'
1052
+ : 'empty'
1063
1053
  const segments = [
1064
1054
  'crud',
1065
1055
  normalizeTagSegment(resource),
@@ -1417,12 +1407,20 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1417
1407
  if (!interceptorContext) {
1418
1408
  return { errorResponse: null, requestPayload, metadataByInterceptor: {} }
1419
1409
  }
1410
+ const routePath = resolveApiInterceptorRoutePath(args.request)
1411
+ if (routePath === null) {
1412
+ return {
1413
+ errorResponse: json({ error: 'Bad request' }, { status: 400 }),
1414
+ requestPayload,
1415
+ metadataByInterceptor: {},
1416
+ }
1417
+ }
1420
1418
  const contextWithHeaders = {
1421
1419
  ...interceptorContext,
1422
1420
  extensionHeaders: parseExtensionHeaders(requestPayload.headers),
1423
1421
  }
1424
1422
  const result = await runApiInterceptorsBefore({
1425
- routePath: normalizeInterceptorRoutePath(args.request),
1423
+ routePath,
1426
1424
  method: args.method,
1427
1425
  request: requestPayload,
1428
1426
  context: contextWithHeaders,
@@ -1445,8 +1443,12 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1445
1443
  }): Promise<{ ok: boolean; statusCode: number; body: Record<string, unknown>; headers: Record<string, string> } | null> {
1446
1444
  const interceptorContext = await buildInterceptorContext(args.ctx)
1447
1445
  if (!interceptorContext) return { ok: true, statusCode: args.statusCode, body: args.body, headers: args.headers ?? {} }
1446
+ const routePath = resolveApiInterceptorRoutePath(args.request)
1447
+ if (routePath === null) {
1448
+ return { ok: false, statusCode: 400, body: { error: 'Bad request' }, headers: {} }
1449
+ }
1448
1450
  const result = await runApiInterceptorsAfter({
1449
- routePath: normalizeInterceptorRoutePath(args.request),
1451
+ routePath,
1450
1452
  method: args.method,
1451
1453
  request: args.requestPayload,
1452
1454
  response: {
@@ -1523,10 +1525,16 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1523
1525
  orgId: scopedOrgId ?? null,
1524
1526
  }
1525
1527
  : null
1526
- const fallbackOrgId = scopedOrgId ?? rawAuth?.orgId ?? null
1528
+ const fallbackOrgId = scope ? scopedOrgId : (rawAuth?.orgId ?? null)
1529
+ const hasExplicitEmptyScope = Boolean(scope && (
1530
+ (Array.isArray(scope.filterIds) && scope.filterIds.length === 0)
1531
+ || (Array.isArray(scope.allowedIds) && scope.allowedIds.length === 0)
1532
+ ))
1527
1533
  const rawScopeIds = scope?.filterIds
1528
1534
  const scopedIds = Array.isArray(rawScopeIds) ? rawScopeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) : null
1529
- if (!scope) {
1535
+ if (hasExplicitEmptyScope) {
1536
+ organizationIds = []
1537
+ } else if (!scope) {
1530
1538
  organizationIds = fallbackOrgId ? [fallbackOrgId] : null
1531
1539
  } else if (scopedIds === null) {
1532
1540
  organizationIds = scope.allowedIds === null ? null : (fallbackOrgId ? [fallbackOrgId] : null)
@@ -1537,7 +1545,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1537
1545
  let canUseFallback = false
1538
1546
  if (allowedIds === null) {
1539
1547
  canUseFallback = true
1540
- } else if (allowedIds.includes(fallbackOrgId) || allowedIds.length === 0) {
1548
+ } else if (allowedIds.includes(fallbackOrgId)) {
1541
1549
  canUseFallback = true
1542
1550
  }
1543
1551
  if (canUseFallback) {
@@ -1629,6 +1637,25 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1629
1637
  let validated = opts.list.schema.parse(rawQueryParams)
1630
1638
  profiler.mark('query_validated')
1631
1639
 
1640
+ if (ormCfg.orgField && Array.isArray(ctx.organizationIds) && ctx.organizationIds.length === 0) {
1641
+ profiler.mark('scope_blocked')
1642
+ logForbidden({
1643
+ resourceKind,
1644
+ action: 'list',
1645
+ reason: 'organization_scope_empty',
1646
+ userId: ctx.auth?.sub ?? null,
1647
+ tenantId: ctx.auth?.tenantId ?? null,
1648
+ organizationIds: ctx.organizationIds,
1649
+ })
1650
+ const page = Number((validated as Record<string, unknown>).page ?? 1) || 1
1651
+ const pageSize = Math.min(
1652
+ Math.max(Number((validated as Record<string, unknown>).pageSize ?? 50) || 50, 1),
1653
+ 100,
1654
+ )
1655
+ finishProfile({ result: 'scope_blocked', itemCount: 0, total: 0 })
1656
+ return json({ items: [], total: 0, page, pageSize, totalPages: 0 })
1657
+ }
1658
+
1632
1659
  const beforeInterceptors = await applyInterceptorsBefore({
1633
1660
  ctx,
1634
1661
  request,
@@ -1695,7 +1722,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1695
1722
  if (Array.isArray(payload)) return
1696
1723
  const items = Array.isArray((payload as any).items) ? (payload as any).items : []
1697
1724
  const tags = new Set<string>()
1698
- const scopeOrgIds = collectScopeOrganizationIds(ctx)
1725
+ const scopeOrgIds = ormCfg.orgField ? collectScopeOrganizationIds(ctx) : [null]
1699
1726
  const crudSegment = deriveCrudSegmentTag(resourceKind, request)
1700
1727
  for (const target of resourceTargets) {
1701
1728
  for (const tag of buildCollectionTags(target, tenantForScope, scopeOrgIds)) {
@@ -0,0 +1,102 @@
1
+ import { registerEntityIds } from '../../encryption/entityIds'
2
+
3
+ const setRecordCustomFieldsMock = jest.fn(async () => undefined)
4
+
5
+ jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
6
+ setRecordCustomFields: (...args: unknown[]) => setRecordCustomFieldsMock(...args),
7
+ }))
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
10
+ const { DefaultDataEngine, clearDataEngineTableExistsCache } = require('../engine')
11
+
12
+ const ENTITY_ID = 'example:todo'
13
+ const RECORD_ID = '11111111-1111-4111-8111-111111111111'
14
+
15
+ function buildDb() {
16
+ const chain = {
17
+ select: (_s: unknown) => chain,
18
+ values: (_v: unknown) => chain,
19
+ set: (_v: unknown) => chain,
20
+ where: (_c: string, _o: string, _v: unknown) => chain,
21
+ onConflict: (callback: (builder: { columns: (c: string[]) => { doUpdateSet: (v: unknown) => unknown } }) => unknown) => {
22
+ callback({ columns: (_c) => ({ doUpdateSet: (_v) => ({}) }) })
23
+ return chain
24
+ },
25
+ executeTakeFirst: async () => ({ present: 1, doc: { id: RECORD_ID }, numUpdatedRows: 1n }),
26
+ execute: async () => [],
27
+ }
28
+ return {
29
+ selectFrom: (_table: string) => chain,
30
+ insertInto: (_table: string) => chain,
31
+ updateTable: (_table: string) => chain,
32
+ deleteFrom: (_table: string) => chain,
33
+ }
34
+ }
35
+
36
+ function buildEngine() {
37
+ const em = {
38
+ getKysely: () => buildDb(),
39
+ getMetadata: () => ({ find: () => undefined, getAll: () => [] }),
40
+ find: async () => [],
41
+ persist: () => undefined,
42
+ flush: async () => undefined,
43
+ }
44
+ return new DefaultDataEngine(em as never, {} as never)
45
+ }
46
+
47
+ describe('DataEngine EAV backcompat pins organizationId (#6034 review)', () => {
48
+ const previousFlag = process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM
49
+
50
+ beforeEach(() => {
51
+ registerEntityIds({ example: { todo: ENTITY_ID } })
52
+ clearDataEngineTableExistsCache()
53
+ setRecordCustomFieldsMock.mockClear()
54
+ process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM = 'true'
55
+ })
56
+
57
+ afterEach(() => {
58
+ registerEntityIds({})
59
+ if (previousFlag === undefined) delete process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM
60
+ else process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM = previousFlag
61
+ })
62
+
63
+ // custom_entities_storage upserts on (entity_type, entity_id, organization_id), so the same
64
+ // recordId can be a live record in two organizations of one tenant — the recordId-is-globally-
65
+ // unique invariant `setRecordCustomFields` otherwise relies on does not hold here. Both backcompat
66
+ // call sites must pin the reconciling delete/lookup to their own organization.
67
+ test('createCustomEntityRecord passes pinOrganizationId: true to setRecordCustomFields', async () => {
68
+ const engine = buildEngine()
69
+
70
+ await engine.createCustomEntityRecord({
71
+ entityId: ENTITY_ID,
72
+ recordId: RECORD_ID,
73
+ organizationId: 'org-a',
74
+ tenantId: 'tenant-1',
75
+ values: { priority: 3 },
76
+ })
77
+
78
+ expect(setRecordCustomFieldsMock).toHaveBeenCalledTimes(1)
79
+ expect(setRecordCustomFieldsMock.mock.calls[0]?.[1]).toMatchObject({
80
+ organizationId: 'org-a',
81
+ pinOrganizationId: true,
82
+ })
83
+ })
84
+
85
+ test('updateCustomEntityRecord passes pinOrganizationId: true to setRecordCustomFields', async () => {
86
+ const engine = buildEngine()
87
+
88
+ await engine.updateCustomEntityRecord({
89
+ entityId: ENTITY_ID,
90
+ recordId: RECORD_ID,
91
+ organizationId: 'org-a',
92
+ tenantId: 'tenant-1',
93
+ values: { priority: 3 },
94
+ })
95
+
96
+ expect(setRecordCustomFieldsMock).toHaveBeenCalledTimes(1)
97
+ expect(setRecordCustomFieldsMock.mock.calls[0]?.[1]).toMatchObject({
98
+ organizationId: 'org-a',
99
+ pinOrganizationId: true,
100
+ })
101
+ })
102
+ })
@@ -98,6 +98,9 @@ export interface DataEngine {
98
98
  tenantId?: string | null
99
99
  values: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>
100
100
  notify?: boolean // default true -> emit '<module>.<entity>.updated'
101
+ // Forwarded to `setRecordCustomFields` — see its doc for when a caller must pass
102
+ // true (a recordId that is not unique across organizations for this entityId).
103
+ pinOrganizationId?: boolean
101
104
  }): Promise<void>
102
105
 
103
106
  // Storage for user-defined entities (doc-based)
@@ -287,6 +290,7 @@ export class DefaultDataEngine implements DataEngine {
287
290
  tenantId,
288
291
  values: sanitizedValues,
289
292
  encryptionService,
293
+ pinOrganizationId: opts.pinOrganizationId === true,
290
294
  })
291
295
  if (opts.notify !== false) {
292
296
  let bus: EventBus | null = null
@@ -473,6 +477,10 @@ export class DefaultDataEngine implements DataEngine {
473
477
  tenantId: tenantId,
474
478
  values: normalizeCustomFieldValues(sanitizedValues),
475
479
  notify: opts.notify, // defaults to true
480
+ // custom_entities_storage upserts on (entity_type, entity_id, organization_id),
481
+ // so `id` is unique only within this organization, not across organizations —
482
+ // pin the reconciling delete/lookup to this organization (#6034 review).
483
+ pinOrganizationId: true,
476
484
  })
477
485
  }
478
486
 
@@ -544,6 +552,10 @@ export class DefaultDataEngine implements DataEngine {
544
552
  tenantId: tenantId,
545
553
  values: normalizeCustomFieldValues(sanitizedValues),
546
554
  notify: opts.notify, // defaults to true
555
+ // custom_entities_storage upserts on (entity_type, entity_id, organization_id),
556
+ // so `id` is unique only within this organization, not across organizations —
557
+ // pin the reconciling delete/lookup to this organization (#6034 review).
558
+ pinOrganizationId: true,
547
559
  })
548
560
  }
549
561
  }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ IMMUTABLE_UNACCENT_FUNCTION,
4
+ buildAccentInsensitiveContainsPatternSql,
5
+ buildAccentInsensitivePatternSql,
6
+ buildAccentInsensitiveSearchExpression,
7
+ buildImmutableUnaccentFunctionSql,
8
+ } from '../accentInsensitiveSearch'
9
+
10
+ describe('accent-insensitive search SQL', () => {
11
+ it('builds a NULL-safe, space-separated concatenation wrapped in the immutable unaccent function', () => {
12
+ expect(buildAccentInsensitiveSearchExpression(['title', 'sku'])).toBe(
13
+ `${IMMUTABLE_UNACCENT_FUNCTION}(coalesce("title", '') || ' ' || coalesce("sku", ''))`,
14
+ )
15
+ })
16
+
17
+ it('wraps a single column without a separator', () => {
18
+ expect(buildAccentInsensitiveSearchExpression(['title'])).toBe(
19
+ `${IMMUTABLE_UNACCENT_FUNCTION}(coalesce("title", ''))`,
20
+ )
21
+ })
22
+
23
+ it('rejects an empty column list rather than emitting an unparsable call', () => {
24
+ expect(() => buildAccentInsensitiveSearchExpression([])).toThrow()
25
+ })
26
+
27
+ it('unaccents the bound pattern with the same function, so both sides of the comparison match', () => {
28
+ expect(buildAccentInsensitivePatternSql()).toBe(`${IMMUTABLE_UNACCENT_FUNCTION}(?)`)
29
+ })
30
+
31
+ // unaccent folds fullwidth % _ \ into ASCII LIKE metacharacters, so the raw
32
+ // term must be folded first and escaped afterwards (#6465).
33
+ it('escapes LIKE metacharacters after unaccent folds the raw term, backslash first', () => {
34
+ const sql = buildAccentInsensitiveContainsPatternSql()
35
+ expect(sql).toBe(
36
+ `'%' || replace(replace(replace(${IMMUTABLE_UNACCENT_FUNCTION}(?), chr(92), chr(92) || chr(92)), '%', chr(92) || '%'), '_', chr(92) || '_') || '%'`,
37
+ )
38
+ expect(sql.split('?')).toHaveLength(2)
39
+ expect(sql).not.toContain('\\')
40
+ })
41
+
42
+ // The wrapper may only be declared IMMUTABLE because the dictionary is pinned:
43
+ // the single-argument unaccent() resolves it through search_path and is STABLE,
44
+ // which PostgreSQL refuses inside an index expression.
45
+ it('pins the text-search dictionary instead of relying on search_path', () => {
46
+ const sql = buildImmutableUnaccentFunctionSql()
47
+ expect(sql).toContain(`create or replace function ${IMMUTABLE_UNACCENT_FUNCTION}(text)`)
48
+ expect(sql).toContain(`public.unaccent('public.unaccent'::regdictionary, $1)`)
49
+ expect(sql).toContain('immutable')
50
+ })
51
+ })
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SQL text for accent-insensitive `ILIKE` matching in PostgreSQL.
3
+ *
4
+ * A query predicate and the expression index backing it must be spelled
5
+ * identically or the planner silently ignores the index, so both sides build
6
+ * their SQL from the helpers here instead of repeating the literal. These are
7
+ * pure string builders — they assume nothing about which migration installed
8
+ * the function, only that both sides agree on its name.
9
+ *
10
+ * The function itself is created by a migration (currently
11
+ * `packages/core/src/modules/catalog/migrations/`); promote that migration to a
12
+ * platform-level one when a second module needs the same matching.
13
+ */
14
+
15
+ /**
16
+ * Schema-qualified name of the IMMUTABLE `unaccent()` wrapper the expressions
17
+ * below call.
18
+ *
19
+ * PostgreSQL's built-in `unaccent()` is STABLE, because the single-argument form
20
+ * resolves its text-search dictionary through `search_path`, and a STABLE
21
+ * function cannot appear in an index expression. The wrapper pins the dictionary
22
+ * with `'public.unaccent'::regdictionary` so it is genuinely immutable rather
23
+ * than merely declared so — but the wrapper's own *call site* is still resolved
24
+ * through `search_path` unless it is schema-qualified too, so both the index
25
+ * expression and the runtime query predicate reference `public.` explicitly
26
+ * rather than relying on the caller's `search_path` containing it.
27
+ */
28
+ export const IMMUTABLE_UNACCENT_FUNCTION = 'public.om_immutable_unaccent'
29
+
30
+ /** DDL creating the wrapper. Kept here so the migration and the query agree. */
31
+ export const buildImmutableUnaccentFunctionSql = (): string =>
32
+ `create or replace function ${IMMUTABLE_UNACCENT_FUNCTION}(text)
33
+ returns text as $$
34
+ select public.unaccent('public.unaccent'::regdictionary, $1)
35
+ $$ language sql immutable parallel safe strict;`
36
+
37
+ /**
38
+ * `unaccent`ed concatenation of `columns`, NULL-safe and space-separated.
39
+ *
40
+ * Column names are quoted but not table-qualified: the caller's query must
41
+ * reference a single table, which is also what the index expression sees.
42
+ */
43
+ export const buildAccentInsensitiveSearchExpression = (columns: readonly string[]): string => {
44
+ if (!columns.length) throw new Error('[internal] buildAccentInsensitiveSearchExpression requires at least one column')
45
+ const concatenated = columns.map((column) => `coalesce("${column}", '')`).join(` || ' ' || `)
46
+ return `${IMMUTABLE_UNACCENT_FUNCTION}(${concatenated})`
47
+ }
48
+
49
+ /**
50
+ * Right-hand side of the comparison: the bound pattern, unaccented the same way.
51
+ *
52
+ * @deprecated `unaccent` folds look-alikes such as fullwidth `% _ \` into the
53
+ * ASCII LIKE metacharacters, so a pattern escaped before this call comes out of
54
+ * it with live wildcards (#6465). Use `buildAccentInsensitiveContainsPatternSql`,
55
+ * which binds the raw term and escapes it after folding.
56
+ */
57
+ export const buildAccentInsensitivePatternSql = (): string => `${IMMUTABLE_UNACCENT_FUNCTION}(?)`
58
+
59
+ const escapeLikeMetacharactersSql = (expression: string): string =>
60
+ `replace(replace(replace(${expression}, chr(92), chr(92) || chr(92)), '%', chr(92) || '%'), '_', chr(92) || '_')`
61
+
62
+ /**
63
+ * Right-hand side of a "contains" comparison for a RAW (unescaped) search term
64
+ * bound as the single `?`: the term is unaccented first and only then
65
+ * LIKE-escaped, so characters that `unaccent` folds into `%`, `_` or `\` are
66
+ * matched literally. `chr(92)` spells the backslash independently of
67
+ * `standard_conforming_strings`.
68
+ */
69
+ export const buildAccentInsensitiveContainsPatternSql = (): string =>
70
+ `'%' || ${escapeLikeMetacharactersSql(`${IMMUTABLE_UNACCENT_FUNCTION}(?)`)} || '%'`
@@ -107,6 +107,14 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
107
107
  jest.restoreAllMocks()
108
108
  })
109
109
 
110
+ function makePolicyEm() {
111
+ return {
112
+ execute: jest.fn(async () => [
113
+ { entity_id: entityId, tenant_id: null, organization_id: null, fields_json: [{ field: 'secret' }] },
114
+ ]),
115
+ }
116
+ }
117
+
110
118
  function makeCreatingKms() {
111
119
  const created: string[] = []
112
120
  const kms: KmsService = {
@@ -136,10 +144,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
136
144
 
137
145
  it('dedupes concurrent first-time DEK creation so no row is orphaned', async () => {
138
146
  const { kms, created } = makeCreatingKms()
139
- const service = new TenantDataEncryptionService({} as never, { kms })
147
+ const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
140
148
  jest.spyOn(service, 'isEnabled').mockReturnValue(true)
141
- ;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
142
- jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
143
149
  const tenantId = uniqueTenant('race')
144
150
 
145
151
  const rows = await Promise.all(
@@ -176,10 +182,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
176
182
 
177
183
  it('provisions no DEK when the caller opts out, so a preview leaves KMS untouched', async () => {
178
184
  const { kms, created } = makeCreatingKms()
179
- const service = new TenantDataEncryptionService({} as never, { kms })
185
+ const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
180
186
  jest.spyOn(service, 'isEnabled').mockReturnValue(true)
181
- ;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
182
- jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
183
187
  const tenantId = uniqueTenant('no-create')
184
188
 
185
189
  const row = await service.encryptEntityPayload(
@@ -198,10 +202,8 @@ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
198
202
 
199
203
  it('still provisions on the default path so existing write callers are unaffected', async () => {
200
204
  const { kms, created } = makeCreatingKms()
201
- const service = new TenantDataEncryptionService({} as never, { kms })
205
+ const service = new TenantDataEncryptionService(makePolicyEm() as never, { kms })
202
206
  jest.spyOn(service, 'isEnabled').mockReturnValue(true)
203
- ;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
204
- jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
205
207
  const tenantId = uniqueTenant('default-create')
206
208
 
207
209
  const row = await service.encryptEntityPayload(entityId, { secret: 'value' }, tenantId)
@@ -0,0 +1,96 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { TenantDataEncryptionService } from '../../tenantDataEncryptionService'
3
+
4
+ type PolicyState = {
5
+ exact: Array<{ field: string }>
6
+ aggregate: Array<{ field: string }>
7
+ }
8
+
9
+ type WorkerCommand = {
10
+ id: number
11
+ action: 'readExact' | 'readAggregate' | 'invalidate'
12
+ }
13
+
14
+ const entityId = 'test:process_policy'
15
+ const tenantId = 'tenant-process-policy'
16
+ const organizationId = 'org-process-policy'
17
+ const statePath = process.env.ENCRYPTION_POLICY_STATE_PATH
18
+ const cacheFailurePath = process.env.ENCRYPTION_POLICY_CACHE_FAILURE_PATH
19
+ const exactReadBlockPath = process.env.ENCRYPTION_POLICY_EXACT_BLOCK_PATH
20
+
21
+ if (!statePath || !cacheFailurePath || !exactReadBlockPath) {
22
+ throw new Error('[internal] Process policy fixture paths are required')
23
+ }
24
+
25
+ async function readPolicyState(): Promise<PolicyState> {
26
+ return JSON.parse(await readFile(statePath, 'utf8')) as PolicyState
27
+ }
28
+
29
+ async function cacheIsUnavailable(): Promise<boolean> {
30
+ return (await readFile(cacheFailurePath, 'utf8')).trim() === 'unavailable'
31
+ }
32
+
33
+ let hasBlockedExactRead = false
34
+
35
+ // One statement resolves a scope: `[entity, tenant]` is the tenant-wide read that also returns
36
+ // every organization-scoped row for the all-organizations aggregate, `[entity, tenant, org]` the
37
+ // exact read.
38
+ const execute = async (_sql: string, params: readonly unknown[] = []) => {
39
+ const state = await readPolicyState()
40
+ if (params.length === 2) {
41
+ return [{ entity_id: entityId, tenant_id: tenantId, organization_id: 'org-aggregate', fields_json: state.aggregate }]
42
+ }
43
+ if (params[2] === organizationId) {
44
+ if (!hasBlockedExactRead && (await readFile(exactReadBlockPath, 'utf8')).trim() === 'blocked') {
45
+ hasBlockedExactRead = true
46
+ process.send?.({ exactReadBlocked: true })
47
+ while ((await readFile(exactReadBlockPath, 'utf8')).trim() === 'blocked') {
48
+ await new Promise((resolve) => setTimeout(resolve, 5))
49
+ }
50
+ }
51
+ return [{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: state.exact }]
52
+ }
53
+ return []
54
+ }
55
+
56
+ const cache = {
57
+ get: async () => ({ entityId, fields: [{ field: 'stale_shared_cache' }] }),
58
+ set: async () => {
59
+ if (await cacheIsUnavailable()) throw new Error('cache publication failed')
60
+ },
61
+ delete: async () => {
62
+ if (await cacheIsUnavailable()) throw new Error('cache invalidation failed')
63
+ return true
64
+ },
65
+ }
66
+
67
+ const service = new TenantDataEncryptionService(
68
+ { execute, getConnection: () => ({ execute }) } as never,
69
+ {
70
+ cache: cache as never,
71
+ kms: {
72
+ getTenantDek: async () => null,
73
+ createTenantDek: async () => null,
74
+ isHealthy: () => true,
75
+ },
76
+ } as never,
77
+ )
78
+
79
+ process.on('message', async (command: WorkerCommand) => {
80
+ try {
81
+ const value = command.action === 'readExact'
82
+ ? await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
83
+ : command.action === 'readAggregate'
84
+ ? await service.getEncryptedFieldNames(entityId, tenantId, null)
85
+ : await service.invalidateMap(entityId, tenantId, organizationId)
86
+ process.send?.({ id: command.id, ok: true, value })
87
+ } catch (error) {
88
+ process.send?.({
89
+ id: command.id,
90
+ ok: false,
91
+ error: error instanceof Error ? error.message : String(error),
92
+ })
93
+ }
94
+ })
95
+
96
+ process.send?.({ ready: true })