@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.
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
@@ -0,0 +1,51 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { EncryptedFieldRule, EncryptionMapRecord } from './tenantDataEncryptionService'
3
+
4
+ export type ResolvedEncryptionPolicy = {
5
+ map: EncryptionMapRecord | null
6
+ fields: EncryptedFieldRule[]
7
+ }
8
+
9
+ type PolicyMemo = {
10
+ generation: number
11
+ entries: Map<string, Promise<ResolvedEncryptionPolicy>>
12
+ }
13
+
14
+ const policyMemos = new WeakMap<object, PolicyMemo>()
15
+ let policyMemoGeneration = 0
16
+
17
+ function resolvePolicyMemoOwners(em: EntityManager): object[] {
18
+ const candidate = em as unknown as { getTransactionContext?: () => unknown }
19
+ const context = typeof candidate.getTransactionContext === 'function'
20
+ ? candidate.getTransactionContext()
21
+ : undefined
22
+ return context && typeof context === 'object' ? [context, em] : [em]
23
+ }
24
+
25
+ /**
26
+ * The encryption-policy reads of one unit of work: keyed by the EntityManager's open transaction
27
+ * when it has one, otherwise by the EntityManager itself, so the memo can never outlive either.
28
+ */
29
+ export function getEncryptionPolicyMemo(em: EntityManager): Map<string, Promise<ResolvedEncryptionPolicy>> {
30
+ const [owner] = resolvePolicyMemoOwners(em)
31
+ const existing = policyMemos.get(owner)
32
+ if (existing && existing.generation === policyMemoGeneration) return existing.entries
33
+ const memo: PolicyMemo = { generation: policyMemoGeneration, entries: new Map() }
34
+ policyMemos.set(owner, memo)
35
+ return memo.entries
36
+ }
37
+
38
+ /** Retire every policy memo of this process; called after an encryption map is invalidated. */
39
+ export function invalidateEncryptionPolicyMemos(): void {
40
+ policyMemoGeneration += 1
41
+ }
42
+
43
+ /**
44
+ * Forget the encryption-policy reads memoized for an EntityManager (and its open transaction).
45
+ * Encryption-map writers call it right after writing through that EntityManager so later
46
+ * encrypt/decrypt decisions in the same unit of work see the new policy.
47
+ */
48
+ export function forgetEncryptionPolicyMemo(em: EntityManager | null | undefined): void {
49
+ if (!em) return
50
+ for (const owner of resolvePolicyMemoOwners(em)) policyMemos.delete(owner)
51
+ }
@@ -1,7 +1,9 @@
1
- import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'
2
- import { ReferenceKind } from '@mikro-orm/core'
1
+ import type { EntityMetadata, EventArgs, EventSubscriber, FlushEventArgs } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { DriverException, ReferenceKind } from '@mikro-orm/core'
3
4
  import { resolveEntityIdFromMetadata } from './entityIds'
4
5
  import { TenantDataEncryptionService, parseDecryptedFieldValue } from './tenantDataEncryptionService'
6
+ import { forgetEncryptionPolicyMemo } from './policyMemo'
5
7
  import { isTenantDataEncryptionEnabled } from './toggles'
6
8
  import { isEncryptionDebugEnabled } from './toggles'
7
9
  import { resolveTenantEncryptionService } from './customFieldValues'
@@ -90,6 +92,26 @@ function getSubscriberForService(service: TenantDataEncryptionService): TenantEn
90
92
  const toSnakeCase = (value: string): string =>
91
93
  value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()
92
94
 
95
+ function isDatabaseError(err: unknown): boolean {
96
+ if (err instanceof DriverException) return true
97
+ if (!err || typeof err !== 'object') return false
98
+ const candidate = err as { code?: unknown; severity?: unknown; sqlState?: unknown }
99
+ if (typeof candidate.sqlState === 'string') return true
100
+ return typeof candidate.code === 'string' && typeof candidate.severity === 'string'
101
+ }
102
+
103
+ function isInTransaction(em: unknown): boolean {
104
+ if (!em || typeof em !== 'object') return false
105
+ const candidate = em as { isInTransaction?: () => boolean; getTransactionContext?: () => unknown }
106
+ try {
107
+ if (typeof candidate.isInTransaction === 'function') return candidate.isInTransaction()
108
+ if (typeof candidate.getTransactionContext === 'function') return candidate.getTransactionContext() != null
109
+ } catch {
110
+ return false
111
+ }
112
+ return false
113
+ }
114
+
93
115
  export class TenantEncryptionSubscriber implements EventSubscriber<any> {
94
116
  constructor(private readonly service: TenantDataEncryptionService) {}
95
117
 
@@ -247,7 +269,13 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
247
269
  return
248
270
  }
249
271
  const { tenantId, organizationId } = resolveScope(target)
250
- const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)
272
+ // Hand the flushing EntityManager to the policy read so it runs on this
273
+ // transaction's own connection instead of acquiring a second pooled one
274
+ // (an uncached lookup from inside an open write transaction deadlocks the
275
+ // pool once every slot is held by such a transaction).
276
+ const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId, {
277
+ em: em as EntityManager | undefined,
278
+ })
251
279
  const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'
252
280
  ? resolvedMeta.properties
253
281
  : {}
@@ -349,7 +377,9 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
349
377
  // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
350
378
  // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).
351
379
  const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false
352
- const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)
380
+ const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId, {
381
+ em: em as EntityManager | undefined,
382
+ })
353
383
  Object.assign(target, decrypted)
354
384
  this.restoreDecryptedJsonColumns(target, resolvedMeta)
355
385
  if (syncOriginal && !hadPendingChanges) {
@@ -426,6 +456,9 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
426
456
  }
427
457
  }
428
458
  } catch (err) {
459
+ // A failed statement aborts the surrounding Postgres transaction; swallowing it here would
460
+ // only move the failure to the caller's next statement ("current transaction is aborted").
461
+ if (isDatabaseError(err) && isInTransaction(em)) throw err
429
462
  debug('⚠️ subscriber.deep_decrypt.error', {
430
463
  entityId,
431
464
  message: (err as Error)?.message ?? String(err),
@@ -458,6 +491,10 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
458
491
  await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })
459
492
  }
460
493
 
494
+ afterFlush(args: FlushEventArgs) {
495
+ forgetEncryptionPolicyMemo(args.em as unknown as EntityManager)
496
+ }
497
+
461
498
  async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {
462
499
  const entities = Array.isArray(args.entities) ? args.entities : []
463
500
  for (const entity of entities) {
@@ -11,6 +11,11 @@ import {
11
11
  import { createKmsService, type KmsService, type TenantDek } from './kms'
12
12
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
13
13
  import { createLogger } from '../logger'
14
+ import {
15
+ getEncryptionPolicyMemo,
16
+ invalidateEncryptionPolicyMemos,
17
+ type ResolvedEncryptionPolicy,
18
+ } from './policyMemo'
14
19
  import type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'
15
20
 
16
21
  const logger = createLogger('shared').child({ component: 'tenant-encryption' })
@@ -36,15 +41,19 @@ type SqlConnection = {
36
41
  execute(sql: string, params?: readonly unknown[]): Promise<unknown>
37
42
  }
38
43
 
39
- const MAP_MISS_TTL_MS = 5 * 60 * 1000
40
44
  // Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked
41
45
  // up by long-lived processes without a restart (#2746). The service-level cache
42
46
  // previously had no TTL and shadowed the KMS's own 15-minute expiry.
43
47
  const DEK_CACHE_TTL_MS = 15 * 60 * 1000
44
- // Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations
45
- // aggregate, so a process that only ever hits its own memory entry (no shared cache configured,
46
- // or already warm) still picks up a runtime map edit within the same bound (#6066).
47
- const AGGREGATE_CACHE_TTL_MS = 300 * 1000
48
+
49
+ function readScopeId(row: Record<string, unknown>, column: string, property: string): string | null {
50
+ const value = row[column] ?? row[property]
51
+ return value == null ? null : String(value).toLowerCase()
52
+ }
53
+
54
+ function sameScopeId(value: string | null, expected: string | null): boolean {
55
+ return value === (expected == null ? null : expected.toLowerCase())
56
+ }
48
57
 
49
58
  function cacheKey(key: MapCacheKey): string {
50
59
  return [
@@ -55,11 +64,12 @@ function cacheKey(key: MapCacheKey): string {
55
64
  ].join(':')
56
65
  }
57
66
 
58
- // Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs
59
- // from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a
60
- // tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).
61
- function allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {
62
- return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')
67
+ /**
68
+ * @deprecated Nothing reads this key: encryption policy is resolved from the canonical table and
69
+ * `invalidateMap` no longer publishes a version token. Kept for import compatibility.
70
+ */
71
+ export function encryptionMapPolicyVersionCacheKey(entityId: string): string {
72
+ return ['encmap-policy-version', entityId.toLowerCase()].join(':')
63
73
  }
64
74
 
65
75
  function debug(event: string, payload: Record<string, unknown>) {
@@ -227,41 +237,45 @@ export function resolveEncryptionKeyId(
227
237
  return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
228
238
  }
229
239
 
230
- function getSqlConnection(em: EntityManager): SqlConnection | null {
231
- const source = em as { getConnection?: () => unknown }
232
- const conn = source.getConnection?.()
233
- if (!conn || typeof conn !== 'object') return null
234
- const candidate = conn as { execute?: unknown }
240
+ /**
241
+ * Runs a map lookup on an EntityManager rather than on `em.getConnection()`.
242
+ *
243
+ * `AbstractSqlConnection.execute` falls back to the pool whenever it is handed no
244
+ * transaction context, so the connection-level call takes a SECOND pooled
245
+ * connection while the caller already holds one for its open write transaction.
246
+ * Policy resolution is not cached across units of work and runs for every
247
+ * encrypt/decrypt decision that misses the per-unit-of-work memo, so under
248
+ * concurrency every in-flight write waits on a connection that only another
249
+ * waiting write could release — the pool deadlocks at `DB_POOL_MAX` and never
250
+ * recovers. `SqlEntityManager.execute` forwards `getTransactionContext()`, so the
251
+ * lookup reuses the caller's own connection, provided callers hand in the
252
+ * EntityManager that owns the transaction (the flush subscriber passes
253
+ * `args.em`). Reading the policy inside that transaction is also what an atomic,
254
+ * transaction-aware map write needs.
255
+ */
256
+ function getSqlExecutor(em: EntityManager): SqlConnection | null {
257
+ const candidate = em as unknown as { execute?: unknown }
235
258
  if (typeof candidate.execute !== 'function') return null
236
- return candidate as SqlConnection
259
+ return candidate as unknown as SqlConnection
237
260
  }
238
261
 
239
262
  export class TenantDataEncryptionService {
240
- private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
241
- private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()
242
- private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
243
263
  private static globalDekCache = new Map<string, TenantDek>()
244
264
  private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
245
- private static globalMissCache = new Map<string, number>()
246
265
  private readonly kms: KmsService
247
- private readonly cache?: CacheStrategy
248
- private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
249
- private readonly aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache
250
266
  private readonly dekCache = TenantDataEncryptionService.globalDekCache
251
267
  private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
252
- private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
253
- private readonly missCache = TenantDataEncryptionService.globalMissCache
254
268
  private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>
255
269
 
256
270
  constructor(
257
271
  private em: EntityManager,
258
272
  opts?: {
273
+ /** Accepted for compatibility; encryption policy is never cached across units of work. */
259
274
  cache?: CacheStrategy
260
275
  kms?: KmsService
261
276
  defaultEncryptionMaps?: readonly ModuleEncryptionMap[]
262
277
  }
263
278
  ) {
264
- this.cache = opts?.cache
265
279
  this.kms = opts?.kms ?? createKmsService()
266
280
  this.systemDefaultMaps = new Map(
267
281
  (opts?.defaultEncryptionMaps ?? [])
@@ -313,7 +327,6 @@ export class TenantDataEncryptionService {
313
327
  if (typeof this.kms.createTenantDek !== 'function') return existing ?? null
314
328
  // Dedupe concurrent first-time creation within this process so two callers
315
329
  // can't each generate a distinct DEK and overwrite one another (#2746).
316
- // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).
317
330
  const pending = this.inflightDeks.get(tenantId)
318
331
  if (pending) return pending
319
332
  const creation = (async () => {
@@ -335,86 +348,65 @@ export class TenantDataEncryptionService {
335
348
  return dek
336
349
  }
337
350
 
338
- private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
351
+ /**
352
+ * Reads every row that can decide the policy for `key` in ONE statement: the exact scope, the
353
+ * tenant-wide scope, and the global scope — plus, at the tenant-wide scope, every
354
+ * organization-scoped map of the tenant for the all-organizations aggregate (#5949). Precedence
355
+ * and duplicate merging happen in JS so the result is identical to resolving the fallback chain
356
+ * one scope at a time.
357
+ */
358
+ private async loadPolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {
339
359
  // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.
340
- const conn = getSqlConnection(this.em)
341
- if (!conn) return null
360
+ const conn = getSqlExecutor(em ?? this.em)
361
+ if (!conn) {
362
+ throw new Error('[internal] Encryption policy lookup requires an EntityManager that can execute SQL')
363
+ }
364
+ const includeAllOrganizations = key.organizationId == null
365
+ const scopeClause = includeAllOrganizations
366
+ ? 'tenant_id is not distinct from ?'
367
+ : '(tenant_id is not distinct from ? and (organization_id = ? or organization_id is null))'
342
368
  const sql = `
343
- select entity_id, fields_json
369
+ select entity_id, tenant_id, organization_id, fields_json
344
370
  from encryption_maps
345
371
  where entity_id = ?
346
- and tenant_id is not distinct from ?
347
- and organization_id is not distinct from ?
348
372
  and is_active = true
349
373
  and deleted_at is null
350
- limit 1
374
+ and (${scopeClause} or (tenant_id is null and organization_id is null))
375
+ order by organization_id asc, created_at asc, id asc
351
376
  `
352
- const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])
353
- const row = Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === 'object'
354
- ? rows[0] as Record<string, unknown>
355
- : null
356
- if (!row) return null
357
- return {
358
- entityId: String(row.entity_id ?? row.entityId ?? key.entityId),
359
- fields: readEncryptedFieldsJson(row),
360
- }
361
- }
362
-
363
- private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {
364
- const declared = this.systemDefaultMaps.get(entityId)
365
- if (!declared) return record
366
- const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({
367
- field: field.field,
368
- hashField: field.hashField ?? null,
369
- }))
370
- const declaredFields = new Set(fields.map((field) => field.field))
371
- for (const field of record?.fields ?? []) {
372
- if (!declaredFields.has(field.field)) fields.push(field)
373
- }
374
- return {
375
- entityId,
376
- keyScope: 'system',
377
- fields,
378
- }
377
+ const params = includeAllOrganizations
378
+ ? [key.entityId, key.tenantId]
379
+ : [key.entityId, key.tenantId, key.organizationId]
380
+ const rows = await conn.execute(sql, params)
381
+ const records = Array.isArray(rows)
382
+ ? rows.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === 'object')
383
+ : []
384
+
385
+ const map = this.applySystemDefault(this.pickMostSpecificMap(key, records), key.entityId)
386
+ const mapRules = normalizeEncryptedFieldRules(map?.fields)
387
+ if (!includeAllOrganizations) return { map, fields: mapRules }
388
+ const organizationRows = records.filter((record) => (
389
+ sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), key.tenantId)
390
+ && readScopeId(record, 'organization_id', 'organizationId') !== null
391
+ ))
392
+ const organizationRules = mergeEncryptedFieldRules(
393
+ organizationRows.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record))),
394
+ )
395
+ return { map, fields: mergeEncryptedFieldRules([mapRules, organizationRules]) }
379
396
  }
380
397
 
381
- private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
382
- const shouldSkipLookup = (tag: string) => {
383
- const expiresAt = this.missCache.get(tag)
384
- if (!expiresAt) return false
385
- if (expiresAt > Date.now()) return true
386
- this.missCache.delete(tag)
387
- return false
388
- }
389
- const recordMiss = (tag: string) => {
390
- this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
391
- }
392
-
398
+ private pickMostSpecificMap(key: MapCacheKey, records: Record<string, unknown>[]): EncryptionMapRecord | null {
393
399
  const candidates: MapCacheKey[] = [
394
400
  key,
395
- { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },
401
+ { entityId: key.entityId, tenantId: key.tenantId, organizationId: null },
396
402
  { entityId: key.entityId, tenantId: null, organizationId: null },
397
403
  ]
398
404
  for (const candidate of candidates) {
399
- const tag = cacheKey(candidate)
400
- if (shouldSkipLookup(tag)) continue
401
- if (this.inflightMaps.has(tag)) {
402
- const pending = this.inflightMaps.get(tag)!
403
- const resolved = await pending
404
- if (resolved) return this.applySystemDefault(resolved, key.entityId)
405
- }
406
- const mem = this.memoryCache.get(tag)
407
- if (mem) return this.applySystemDefault(mem, key.entityId)
408
- if (this.cache && typeof this.cache.get === 'function') {
409
- const cached = await this.cache.get(tag)
410
- if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)
411
- }
412
- const pending = this.fetchMap(candidate)
413
- this.inflightMaps.set(tag, pending)
414
- const loaded = await pending
415
- this.inflightMaps.delete(tag)
416
- if (!loaded) {
417
- recordMiss(tag)
405
+ const matches = records.filter((record) => (
406
+ sameScopeId(readScopeId(record, 'tenant_id', 'tenantId'), candidate.tenantId)
407
+ && sameScopeId(readScopeId(record, 'organization_id', 'organizationId'), candidate.organizationId)
408
+ ))
409
+ if (!matches.length) {
418
410
  debug('🔍 encmap.miss', {
419
411
  entityId: candidate.entityId,
420
412
  tenantId: candidate.tenantId,
@@ -422,125 +414,75 @@ export class TenantDataEncryptionService {
422
414
  })
423
415
  continue
424
416
  }
425
- this.missCache.delete(tag)
426
- this.memoryCache.set(tag, loaded)
427
- if (this.cache && typeof this.cache.set === 'function') {
428
- await this.cache.set(tag, loaded, { ttl: 300 })
417
+ if (matches.length > 1) {
418
+ logger.warn('Duplicate live encryption maps detected; merging field rules fail-safe', {
419
+ entityId: candidate.entityId,
420
+ tenantId: candidate.tenantId,
421
+ organizationId: candidate.organizationId,
422
+ count: matches.length,
423
+ })
424
+ }
425
+ return {
426
+ entityId: String(matches[0].entity_id ?? matches[0].entityId ?? key.entityId),
427
+ fields: mergeEncryptedFieldRules(matches.map((record) => normalizeEncryptedFieldRules(readEncryptedFieldsJson(record)))),
429
428
  }
430
- return this.applySystemDefault(loaded, key.entityId)
431
- }
432
- return this.applySystemDefault(null, key.entityId)
433
- }
434
-
435
- private async fetchAllOrganizationFieldRules(
436
- entityId: string,
437
- tenantId: string | null,
438
- ): Promise<EncryptedFieldRule[]> {
439
- const conn = getSqlConnection(this.em)
440
- if (!conn) return []
441
- const sql = `
442
- select fields_json
443
- from encryption_maps
444
- where entity_id = ?
445
- and tenant_id is not distinct from ?
446
- and organization_id is not null
447
- and is_active = true
448
- and deleted_at is null
449
- `
450
- const rows = await conn.execute(sql, [entityId, tenantId])
451
- if (!Array.isArray(rows) || rows.length === 0) return []
452
- const groups: EncryptedFieldRule[][] = []
453
- for (const row of rows) {
454
- if (!row || typeof row !== 'object') continue
455
- groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))
456
429
  }
457
- return mergeEncryptedFieldRules(groups)
430
+ return null
458
431
  }
459
432
 
460
- /**
461
- * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`
462
- * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached
463
- * read this used to be would add a round-trip per flush and per load (#5949).
464
- */
465
- private async getAllOrganizationFieldRules(
466
- entityId: string,
467
- tenantId: string | null,
468
- ): Promise<EncryptedFieldRule[]> {
469
- const tag = allOrganizationsCacheKey(entityId, tenantId)
470
- const missExpiresAt = this.missCache.get(tag)
471
- if (missExpiresAt) {
472
- if (missExpiresAt > Date.now()) return []
473
- this.missCache.delete(tag)
474
- }
475
- const mem = this.aggregateMemoryCache.get(tag)
476
- if (mem) {
477
- if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields
478
- this.aggregateMemoryCache.delete(tag)
479
- }
480
- if (this.cache && typeof this.cache.get === 'function') {
481
- const cached = await this.cache.get(tag)
482
- if (cached) {
483
- const record = cached as EncryptionMapRecord
484
- this.aggregateMemoryCache.set(tag, { at: Date.now(), record })
485
- return record.fields
486
- }
487
- }
488
- const inflight = this.inflightMaps.get(tag)
489
- if (inflight) return (await inflight)?.fields ?? []
490
- const pending = (async (): Promise<EncryptionMapRecord | null> => {
491
- const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)
492
- return fields.length ? { entityId, fields } : null
493
- })()
494
- this.inflightMaps.set(tag, pending)
495
- let loaded: EncryptionMapRecord | null
496
- try {
497
- loaded = await pending
498
- } finally {
499
- this.inflightMaps.delete(tag)
500
- }
501
- if (!loaded) {
502
- this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
503
- return []
433
+ private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {
434
+ const declared = this.systemDefaultMaps.get(entityId)
435
+ if (!declared) return record
436
+ const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({
437
+ field: field.field,
438
+ hashField: field.hashField ?? null,
439
+ }))
440
+ const declaredFields = new Set(fields.map((field) => field.field))
441
+ for (const field of record?.fields ?? []) {
442
+ if (!declaredFields.has(field.field)) fields.push(field)
504
443
  }
505
- this.missCache.delete(tag)
506
- this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })
507
- if (this.cache && typeof this.cache.set === 'function') {
508
- await this.cache.set(tag, loaded, { ttl: 300 })
444
+ return {
445
+ entityId,
446
+ keyScope: 'system',
447
+ fields,
509
448
  }
510
- return loaded.fields
511
449
  }
512
450
 
513
451
  /**
514
- * The field rules that are actually encrypted at rest for a scope.
452
+ * Resolve policy from the canonical table. Nothing is retained across units of work: no
453
+ * invalidation protocol can make process-local state authoritative after an independent worker
454
+ * commits a map mutation.
515
455
  *
516
- * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but
517
- * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading
518
- * or writing such a row with the base map alone stores those fields as plaintext and hands
519
- * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in
520
- * (#5949) — the same set `getEncryptedFieldNames` has reported since #2282.
456
+ * Within one unit of work — the caller's EntityManager, or its open transaction — repeated
457
+ * decisions for the same scope share a single read, so loading N rows or running the before/after
458
+ * hooks of one flush costs one query instead of one per row and hook. That memo dies with the
459
+ * EntityManager/transaction, is dropped for the EntityManager that writes a map
460
+ * (`forgetEncryptionPolicyMemo`) and after its flush, and for every EntityManager when this
461
+ * process invalidates a map. Calls that do not hand in an EntityManager fall back to the
462
+ * long-lived service EntityManager and are never memoized.
521
463
  */
522
- private async resolveFieldRulesForScope(
523
- entityId: string,
524
- tenantId: string | null,
525
- organizationId: string | null,
526
- map: EncryptionMapRecord | null,
527
- ): Promise<EncryptedFieldRule[]> {
528
- const mapRules = normalizeEncryptedFieldRules(map?.fields)
529
- if (organizationId != null) return mapRules
530
- return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])
464
+ private resolvePolicy(key: MapCacheKey, em?: EntityManager): Promise<ResolvedEncryptionPolicy> {
465
+ if (!em) return this.loadPolicy(key)
466
+ const memo = getEncryptionPolicyMemo(em)
467
+ const tag = cacheKey(key)
468
+ const pending = memo.get(tag)
469
+ if (pending) return pending
470
+ const loading = this.loadPolicy(key, em)
471
+ memo.set(tag, loading)
472
+ loading.catch(() => {
473
+ if (memo.get(tag) === loading) memo.delete(tag)
474
+ })
475
+ return loading
531
476
  }
532
477
 
478
+ /**
479
+ * Drops the policy reads memoized by every EntityManager of this process. Map writers call it
480
+ * after their write commits; readers in other processes never depended on it, because policy is
481
+ * not cached across units of work.
482
+ */
533
483
  async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {
534
- const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]
535
- for (const tag of tags) {
536
- this.memoryCache.delete(tag)
537
- this.aggregateMemoryCache.delete(tag)
538
- this.inflightMaps.delete(tag)
539
- this.missCache.delete(tag)
540
- if (this.cache && typeof (this.cache as any).delete === 'function') {
541
- await (this.cache as any).delete(tag)
542
- }
543
- }
484
+ debug('♻️ encmap.invalidate', { entityId, tenantId, organizationId })
485
+ invalidateEncryptionPolicyMemos()
544
486
  }
545
487
 
546
488
  // Force a flush of a tenant's cached DEK across the service-level cache and the
@@ -565,19 +507,16 @@ export class TenantDataEncryptionService {
565
507
  entityId: string,
566
508
  tenantId: string | null | undefined,
567
509
  organizationId?: string | null,
568
- options?: { ignoreRuntimeHealth?: boolean }
510
+ options?: { ignoreRuntimeHealth?: boolean; em?: EntityManager }
569
511
  ): Promise<string[]> {
570
512
  if (options?.ignoreRuntimeHealth) {
571
513
  if (!isTenantDataEncryptionEnabled()) return []
572
514
  } else if (!this.isEnabled()) {
573
515
  return []
574
516
  }
575
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
576
- const fields = await this.resolveFieldRulesForScope(
577
- entityId,
578
- tenantId ?? null,
579
- organizationId ?? null,
580
- map,
517
+ const { fields } = await this.resolvePolicy(
518
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
519
+ options?.em,
581
520
  )
582
521
  return fields.map((rule) => rule.field)
583
522
  }
@@ -666,18 +605,15 @@ export class TenantDataEncryptionService {
666
605
  payload: Record<string, unknown>,
667
606
  tenantId: string | null | undefined,
668
607
  organizationId?: string | null,
669
- options?: { createMissingDek?: boolean }
608
+ options?: { createMissingDek?: boolean; em?: EntityManager }
670
609
  ): Promise<Record<string, unknown>> {
671
610
  if (!this.isEnabled()) {
672
611
  debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
673
612
  return payload
674
613
  }
675
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
676
- const fields = await this.resolveFieldRulesForScope(
677
- entityId,
678
- tenantId ?? null,
679
- organizationId ?? null,
680
- map,
614
+ const { map, fields } = await this.resolvePolicy(
615
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
616
+ options?.em,
681
617
  )
682
618
  if (!fields.length) {
683
619
  debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
@@ -697,18 +633,16 @@ export class TenantDataEncryptionService {
697
633
  entityId: string,
698
634
  payload: Record<string, unknown>,
699
635
  tenantId: string | null | undefined,
700
- organizationId?: string | null
636
+ organizationId?: string | null,
637
+ options?: { em?: EntityManager }
701
638
  ): Promise<Record<string, unknown>> {
702
639
  if (!isTenantDataEncryptionEnabled()) {
703
640
  debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
704
641
  return payload
705
642
  }
706
- const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
707
- const fields = await this.resolveFieldRulesForScope(
708
- entityId,
709
- tenantId ?? null,
710
- organizationId ?? null,
711
- map,
643
+ const { map, fields } = await this.resolvePolicy(
644
+ { entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null },
645
+ options?.em,
712
646
  )
713
647
  if (!fields.length) {
714
648
  debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })