@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
@@ -10,6 +10,7 @@ import {
10
10
  TenantDataEncryptionService,
11
11
  parseDecryptedFieldValue,
12
12
  } from '../tenantDataEncryptionService'
13
+ import { forgetEncryptionPolicyMemo } from '../policyMemo'
13
14
 
14
15
  const fixedKey = Buffer.alloc(32, 1).toString('base64')
15
16
 
@@ -306,6 +307,7 @@ describe('TenantDataEncryptionService system-scoped maps', () => {
306
307
  ))
307
308
  const service = new TenantDataEncryptionService(
308
309
  {
310
+ execute: jest.fn(async () => []),
309
311
  getConnection: () => ({ execute: jest.fn(async () => []) }),
310
312
  } as never,
311
313
  {
@@ -342,47 +344,373 @@ describe('TenantDataEncryptionService system-scoped maps', () => {
342
344
  })
343
345
  })
344
346
 
347
+ type PolicyRow = {
348
+ entity_id: string
349
+ tenant_id: string | null
350
+ organization_id: string | null
351
+ fields_json: Array<{ field: string; hashField?: string | null }>
352
+ }
353
+
354
+ // Evaluates the scope predicate of the single policy statement against an in-memory table:
355
+ // `[entityId, tenantId]` selects every row of the tenant plus the global row (tenant-wide scope,
356
+ // including the all-organizations aggregate); `[entityId, tenantId, organizationId]` selects the
357
+ // exact, tenant-wide and global rows only.
358
+ function selectPolicyRows(table: readonly PolicyRow[], params: readonly unknown[]): PolicyRow[] {
359
+ const [entityId, tenantId, organizationId] = params
360
+ return table.filter((row) => {
361
+ if (row.entity_id !== entityId) return false
362
+ if (row.tenant_id === null && row.organization_id === null) return true
363
+ if (row.tenant_id !== (tenantId ?? null)) return false
364
+ if (params.length === 2) return true
365
+ return row.organization_id === null || row.organization_id === organizationId
366
+ })
367
+ }
368
+
369
+ function makePolicyEm(readTable: () => readonly PolicyRow[]) {
370
+ const execute = jest.fn(async (_sql: string, params: readonly unknown[] = []) => selectPolicyRows(readTable(), params))
371
+ return { em: { execute }, execute }
372
+ }
373
+
345
374
  describe('TenantDataEncryptionService.getEncryptedFieldNames', () => {
346
375
  it('returns active encryption-map field names for query planning', async () => {
347
- const service = new TenantDataEncryptionService({} as never)
348
- jest.spyOn(service, 'isEnabled').mockReturnValue(true)
349
- ;(service as unknown as {
350
- getMap: () => Promise<{ fields: Array<{ field?: unknown }> }>
351
- }).getMap = jest.fn(async () => ({
352
- fields: [
376
+ const entityId = 'customers:customer_entity'
377
+ const { em } = makePolicyEm(() => [{
378
+ entity_id: entityId,
379
+ tenant_id: 't1',
380
+ organization_id: 'org1',
381
+ fields_json: [
353
382
  { field: 'display_name' },
354
383
  { field: 'primary_email' },
355
384
  { field: '' },
356
- { field: null },
385
+ { field: null as unknown as string },
357
386
  ],
358
- }))
387
+ }])
388
+ const service = new TenantDataEncryptionService(em as never)
389
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
359
390
 
360
391
  await expect(
361
- service.getEncryptedFieldNames('customers:customer_entity', 't1', 'org1'),
392
+ service.getEncryptedFieldNames(entityId, 't1', 'org1'),
362
393
  ).resolves.toEqual(['display_name', 'primary_email'])
363
394
  })
364
395
 
365
- it('returns org-scoped field union for all-organization query planning', async () => {
366
- const execute = jest.fn(async (_sql: string, params: unknown[]) => {
367
- if (params.length === 3) return []
368
- return [
369
- { fields_json: [{ field: 'display_name' }, { field: 'primary_email' }] },
370
- { fields_json: [{ field: 'display_name' }, { field: 'description' }, { field: '' }] },
371
- ]
372
- })
373
- const service = new TenantDataEncryptionService({
374
- getConnection: () => ({ execute }),
375
- } as never)
396
+ it('returns org-scoped field union for all-organization query planning in one statement', async () => {
397
+ const entityId = 'test:all_org_customer_entity'
398
+ const { em, execute } = makePolicyEm(() => [
399
+ { entity_id: entityId, tenant_id: 'tenant-all', organization_id: 'org-a', fields_json: [{ field: 'display_name' }, { field: 'primary_email' }] },
400
+ { entity_id: entityId, tenant_id: 'tenant-all', organization_id: 'org-b', fields_json: [{ field: 'display_name' }, { field: 'description' }, { field: '' }] },
401
+ { entity_id: entityId, tenant_id: 'other-tenant', organization_id: 'org-c', fields_json: [{ field: 'foreign' }] },
402
+ ])
403
+ const service = new TenantDataEncryptionService(em as never)
376
404
  jest.spyOn(service, 'isEnabled').mockReturnValue(true)
377
405
 
378
406
  await expect(
379
- service.getEncryptedFieldNames('test:all_org_customer_entity', 'tenant-all', null),
407
+ service.getEncryptedFieldNames(entityId, 'tenant-all', null),
380
408
  ).resolves.toEqual(['display_name', 'primary_email', 'description'])
381
409
 
410
+ expect(execute).toHaveBeenCalledTimes(1)
382
411
  expect(execute).toHaveBeenCalledWith(
383
- expect.stringContaining('organization_id is not null'),
384
- ['test:all_org_customer_entity', 'tenant-all'],
412
+ expect.stringContaining('tenant_id is null and organization_id is null'),
413
+ [entityId, 'tenant-all'],
414
+ )
415
+ })
416
+
417
+ it('resolves the exact, tenant-wide and global fallback chain with one statement', async () => {
418
+ const entityId = 'test:fallback_chain'
419
+ let table: PolicyRow[] = [
420
+ { entity_id: entityId, tenant_id: null, organization_id: null, fields_json: [{ field: 'global' }] },
421
+ { entity_id: entityId, tenant_id: 'tenant-1', organization_id: null, fields_json: [{ field: 'tenant_wide' }] },
422
+ { entity_id: entityId, tenant_id: 'tenant-1', organization_id: 'org-1', fields_json: [{ field: 'exact' }] },
423
+ ]
424
+ const { em, execute } = makePolicyEm(() => table)
425
+ const service = new TenantDataEncryptionService(em as never)
426
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
427
+
428
+ await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['exact'])
429
+ table = table.filter((row) => row.organization_id !== 'org-1')
430
+ await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['tenant_wide'])
431
+ table = table.filter((row) => row.tenant_id !== 'tenant-1')
432
+ await expect(service.getEncryptedFieldNames(entityId, 'tenant-1', 'org-1')).resolves.toEqual(['global'])
433
+ expect(execute).toHaveBeenCalledTimes(3)
434
+ for (const [, params] of execute.mock.calls) expect(params).toEqual([entityId, 'tenant-1', 'org-1'])
435
+ })
436
+
437
+ it('fails closed when the EntityManager cannot execute SQL', async () => {
438
+ const service = new TenantDataEncryptionService({} as never)
439
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
440
+
441
+ await expect(service.getEncryptedFieldNames('test:no_executor', 'tenant-1', 'org-1'))
442
+ .rejects.toThrow('[internal]')
443
+ })
444
+ })
445
+
446
+ describe('TenantDataEncryptionService canonical map reads and invalidation', () => {
447
+ it('re-reads exact and all-organizations policies without waiting for invalidation', async () => {
448
+ const entityId = 'test:cache_invalidation_hit'
449
+ const tenantId = 'tenant-cache-invalidation-hit'
450
+ const organizationId = 'org-cache-invalidation-hit'
451
+ let exactFields = [{ field: 'existing_exact' }]
452
+ let aggregateFields = [{ field: 'existing_aggregate' }]
453
+ const { em } = makePolicyEm(() => [
454
+ { entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: exactFields },
455
+ { entity_id: entityId, tenant_id: tenantId, organization_id: 'org-aggregate', fields_json: aggregateFields },
456
+ ])
457
+ const service = new TenantDataEncryptionService(em as never)
458
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
459
+
460
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
461
+ .resolves.toEqual(['existing_exact'])
462
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, null))
463
+ .resolves.toEqual(['existing_exact', 'existing_aggregate'])
464
+
465
+ exactFields = [{ field: 'existing_exact' }, { field: 'fresh_exact' }]
466
+ aggregateFields = [{ field: 'existing_aggregate' }, { field: 'fresh_aggregate' }]
467
+
468
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
469
+ .resolves.toEqual(['existing_exact', 'fresh_exact'])
470
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, null))
471
+ .resolves.toEqual(['existing_exact', 'fresh_exact', 'existing_aggregate', 'fresh_aggregate'])
472
+
473
+ await service.invalidateMap(entityId, tenantId, organizationId)
474
+
475
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
476
+ .resolves.toEqual(['existing_exact', 'fresh_exact'])
477
+ })
478
+
479
+ it('does not retain an exact miss after a map is committed', async () => {
480
+ const entityId = 'test:cache_invalidation_miss'
481
+ const tenantId = 'tenant-cache-invalidation-miss'
482
+ const organizationId = 'org-cache-invalidation-miss'
483
+ let table: PolicyRow[] = []
484
+ const { em } = makePolicyEm(() => table)
485
+ const service = new TenantDataEncryptionService(em as never)
486
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
487
+
488
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId)).resolves.toEqual([])
489
+ table = [{ entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: [{ field: 'fresh_after_miss' }] }]
490
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
491
+ .resolves.toEqual(['fresh_after_miss'])
492
+ })
493
+
494
+ it('never reads, publishes or deletes shared-cache entries', async () => {
495
+ const entityId = 'test:canonical_policy_over_shared_cache'
496
+ const tenantId = 'tenant-canonical-policy'
497
+ const organizationId = 'org-canonical-policy'
498
+ const cache = {
499
+ get: jest.fn(async () => ({ entityId, fields: [{ field: 'stale_shared_cache' }] })),
500
+ set: jest.fn(async () => undefined),
501
+ delete: jest.fn(async () => {
502
+ throw new Error('sensitive backend endpoint')
503
+ }),
504
+ }
505
+ const { em } = makePolicyEm(() => [
506
+ { entity_id: entityId, tenant_id: tenantId, organization_id: organizationId, fields_json: [{ field: 'fresh_exact' }] },
507
+ ])
508
+ const service = new TenantDataEncryptionService(em as never, { cache: cache as never })
509
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
510
+
511
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
512
+ .resolves.toEqual(['fresh_exact'])
513
+ await expect(service.invalidateMap(entityId, tenantId, organizationId)).resolves.toBeUndefined()
514
+ expect(cache.get).not.toHaveBeenCalled()
515
+ expect(cache.set).not.toHaveBeenCalled()
516
+ expect(cache.delete).not.toHaveBeenCalled()
517
+ })
518
+ })
519
+
520
+ describe('TenantDataEncryptionService per-unit-of-work policy memo', () => {
521
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
522
+ const tenantId = 'tenant-memo'
523
+ const organizationId = 'org-memo'
524
+
525
+ beforeEach(() => {
526
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
527
+ })
528
+
529
+ afterEach(() => {
530
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
531
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
532
+ })
533
+
534
+ function makeService(entityId: string, table: { rows: PolicyRow[] }) {
535
+ const { execute } = makePolicyEm(() => table.rows)
536
+ const service = new TenantDataEncryptionService({ execute } as never, {
537
+ kms: {
538
+ getTenantDek: jest.fn(async (keyId: string) => ({ tenantId: keyId, key: fixedKey, fetchedAt: Date.now() })),
539
+ createTenantDek: jest.fn(async () => null),
540
+ isHealthy: () => true,
541
+ },
542
+ } as never)
543
+ table.rows.push({ entity_id: entityId, tenant_id: tenantId, organization_id: null, fields_json: [{ field: 'secret' }] })
544
+ return { service, execute }
545
+ }
546
+
547
+ function makeUnitOfWork(execute: jest.Mock) {
548
+ return { execute } as never
549
+ }
550
+
551
+ it('reads policy once per distinct scope for N decrypts on the same EntityManager', async () => {
552
+ const entityId = 'test:memo_same_em'
553
+ const table = { rows: [] as PolicyRow[] }
554
+ const { service, execute } = makeService(entityId, table)
555
+ const em = makeUnitOfWork(execute)
556
+ const ciphertext = encryptWithAesGcm('value', fixedKey).value as string
557
+
558
+ const decrypted = await Promise.all(Array.from({ length: 25 }, () => (
559
+ service.decryptEntityPayload(entityId, { secret: ciphertext }, tenantId, organizationId, { em })
560
+ )))
561
+ await service.decryptEntityPayload(entityId, { secret: ciphertext }, tenantId, null, { em })
562
+ await service.encryptEntityPayload(entityId, { secret: 'value' }, tenantId, organizationId, { em })
563
+
564
+ expect(decrypted.every((row) => row.secret === 'value')).toBe(true)
565
+ expect(execute).toHaveBeenCalledTimes(2)
566
+ expect(execute.mock.calls.map(([, params]) => params)).toEqual([
567
+ [entityId, tenantId, organizationId],
568
+ [entityId, tenantId],
569
+ ])
570
+ })
571
+
572
+ it('keeps memos of different EntityManagers and transactions independent', async () => {
573
+ const entityId = 'test:memo_independent_em'
574
+ const table = { rows: [] as PolicyRow[] }
575
+ const { service, execute } = makeService(entityId, table)
576
+ const first = makeUnitOfWork(execute)
577
+ const second = makeUnitOfWork(execute)
578
+ const transaction = {}
579
+ const transactional = { execute, getTransactionContext: () => transaction } as never
580
+
581
+ await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: first })
582
+ table.rows[0].fields_json = [{ field: 'secret' }, { field: 'rotated' }]
583
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: first }))
584
+ .resolves.toEqual(['secret'])
585
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: second }))
586
+ .resolves.toEqual(['secret', 'rotated'])
587
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em: transactional }))
588
+ .resolves.toEqual(['secret', 'rotated'])
589
+ expect(execute).toHaveBeenCalledTimes(3)
590
+ })
591
+
592
+ it('never memoizes lookups that fall back to the service EntityManager', async () => {
593
+ const entityId = 'test:memo_fallback_em'
594
+ const table = { rows: [] as PolicyRow[] }
595
+ const { service, execute } = makeService(entityId, table)
596
+
597
+ await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
598
+ await service.getEncryptedFieldNames(entityId, tenantId, organizationId)
599
+
600
+ expect(execute).toHaveBeenCalledTimes(2)
601
+ })
602
+
603
+ it('drops the memo when a map write goes through that EntityManager', async () => {
604
+ const entityId = 'test:memo_write_forget'
605
+ const table = { rows: [] as PolicyRow[] }
606
+ const { service, execute } = makeService(entityId, table)
607
+ const em = makeUnitOfWork(execute)
608
+
609
+ await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em })
610
+ table.rows[0].fields_json = [{ field: 'secret' }, { field: 'added' }]
611
+ forgetEncryptionPolicyMemo(em)
612
+
613
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
614
+ .resolves.toEqual(['secret', 'added'])
615
+ expect(execute).toHaveBeenCalledTimes(2)
616
+ })
617
+
618
+ it('drops every memo of this process when a map is invalidated', async () => {
619
+ const entityId = 'test:memo_invalidate'
620
+ const table = { rows: [] as PolicyRow[] }
621
+ const { service, execute } = makeService(entityId, table)
622
+ const em = makeUnitOfWork(execute)
623
+
624
+ await service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em })
625
+ table.rows[0].fields_json = [{ field: 'secret' }, { field: 'added' }]
626
+ await service.invalidateMap(entityId, tenantId, null)
627
+
628
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
629
+ .resolves.toEqual(['secret', 'added'])
630
+ expect(execute).toHaveBeenCalledTimes(2)
631
+ })
632
+
633
+ it('does not memoize a failed read', async () => {
634
+ const entityId = 'test:memo_failed_read'
635
+ const table = { rows: [] as PolicyRow[] }
636
+ const { service, execute } = makeService(entityId, table)
637
+ execute.mockRejectedValueOnce(new Error('connection terminated'))
638
+ const em = makeUnitOfWork(execute)
639
+
640
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
641
+ .rejects.toThrow('connection terminated')
642
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId, { em }))
643
+ .resolves.toEqual(['secret'])
644
+ })
645
+ })
646
+
647
+ describe('TenantDataEncryptionService duplicate map fail-safe', () => {
648
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
649
+
650
+ beforeEach(() => {
651
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
652
+ })
653
+
654
+ afterEach(() => {
655
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
656
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
657
+ })
658
+
659
+ it('deterministically unions active duplicate rows so no declared field is stored as plaintext', async () => {
660
+ const entityId = 'test:duplicate_encryption_map'
661
+ const tenantId = 'tenant-duplicate-map'
662
+ const execute = jest.fn(async (sql: string) => {
663
+ expect(sql).toContain('order by organization_id asc, created_at asc, id asc')
664
+ return [
665
+ {
666
+ entity_id: entityId,
667
+ tenant_id: tenantId,
668
+ organization_id: 'org-1',
669
+ fields_json: [
670
+ { field: 'email' },
671
+ { field: 'display_name', hashField: 'display_name_hash' },
672
+ ],
673
+ },
674
+ {
675
+ entity_id: entityId,
676
+ tenant_id: tenantId,
677
+ organization_id: 'org-1',
678
+ fields_json: [
679
+ { field: 'email', hashField: 'email_hash' },
680
+ { field: 'phone' },
681
+ ],
682
+ },
683
+ ]
684
+ })
685
+ const service = new TenantDataEncryptionService(
686
+ { execute, getConnection: () => ({ execute }) } as never,
687
+ {
688
+ kms: {
689
+ getTenantDek: jest.fn(async () => ({ tenantId, key: fixedKey, fetchedAt: new Date() })),
690
+ createTenantDek: jest.fn(async () => null),
691
+ isHealthy: () => true,
692
+ },
693
+ } as never,
385
694
  )
695
+
696
+ const encrypted = await service.encryptEntityPayload(
697
+ entityId,
698
+ {
699
+ email: 'person@example.com',
700
+ email_hash: null,
701
+ display_name: 'Person',
702
+ display_name_hash: null,
703
+ phone: '+48123456789',
704
+ },
705
+ tenantId,
706
+ 'org-1',
707
+ )
708
+
709
+ expect(decryptWithAesGcm(encrypted.email as string, fixedKey)).toBe('person@example.com')
710
+ expect(encrypted.email_hash).toBe(hashForLookup('person@example.com'))
711
+ expect(decryptWithAesGcm(encrypted.display_name as string, fixedKey)).toBe('Person')
712
+ expect(encrypted.display_name_hash).toBe(hashForLookup('Person'))
713
+ expect(decryptWithAesGcm(encrypted.phone as string, fixedKey)).toBe('+48123456789')
386
714
  })
387
715
  })
388
716
 
@@ -400,19 +728,20 @@ describe('TenantDataEncryptionService tenant-wide scope parity (issue #5949)', (
400
728
  })
401
729
 
402
730
  // A base (organization-less) map declaring `display_name`, plus an organization-scoped map that
403
- // declares the extra `description` field. `getMap` only ever resolves the base map for
404
- // organizationId = null; the all-organizations aggregate is the 2-parameter query.
731
+ // declares the extra `description` field. At organizationId = null the policy statement also
732
+ // returns the organization-scoped rows (2-parameter form) for the all-organizations aggregate.
405
733
  function makeService(entityId: string) {
406
- const execute = jest.fn(async (_sql: string, params: unknown[]) => {
407
- if (params.length === 3) {
408
- return params[2] === null
409
- ? [{ entity_id: entityId, fields_json: [{ field: 'display_name' }] }]
410
- : []
411
- }
412
- return [{ fields_json: [{ field: 'description', hashField: 'description_hash' }] }]
413
- })
734
+ const { execute } = makePolicyEm(() => [
735
+ { entity_id: entityId, tenant_id: tenantId, organization_id: null, fields_json: [{ field: 'display_name' }] },
736
+ {
737
+ entity_id: entityId,
738
+ tenant_id: tenantId,
739
+ organization_id: 'org-with-description',
740
+ fields_json: [{ field: 'description', hashField: 'description_hash' }],
741
+ },
742
+ ])
414
743
  const service = new TenantDataEncryptionService(
415
- { getConnection: () => ({ execute }) } as never,
744
+ { execute } as never,
416
745
  {
417
746
  kms: {
418
747
  getTenantDek: jest.fn(async (keyId: string) => (
@@ -494,31 +823,63 @@ describe('TenantDataEncryptionService tenant-wide scope parity (issue #5949)', (
494
823
  expect(execute).not.toHaveBeenCalledWith(expect.anything(), [entityId, tenantId])
495
824
  })
496
825
 
497
- it('caches the all-organizations aggregate across payload calls', async () => {
498
- const entityId = 'test:parity_cached_entity'
826
+ it('re-reads the all-organizations aggregate across payload calls', async () => {
827
+ const entityId = 'test:parity_canonical_entity'
499
828
  const { service, execute } = makeService(entityId)
500
829
 
501
830
  await service.encryptEntityPayload(entityId, { description: 'first' }, tenantId, null)
502
831
  await service.encryptEntityPayload(entityId, { description: 'second' }, tenantId, null)
503
832
 
504
833
  const aggregateReads = execute.mock.calls.filter(([, params]) => (params as unknown[]).length === 2)
505
- expect(aggregateReads).toHaveLength(1)
834
+ expect(aggregateReads).toHaveLength(2)
506
835
  })
836
+ })
507
837
 
508
- it('re-reads the all-organizations aggregate once its memory entry passes the 300s TTL', async () => {
509
- const entityId = 'test:parity_expired_cache_entity'
510
- const { service, execute } = makeService(entityId)
511
- const nowSpy = jest.spyOn(Date, 'now')
512
-
513
- nowSpy.mockReturnValue(1_000_000)
514
- await service.encryptEntityPayload(entityId, { description: 'first' }, tenantId, null)
515
-
516
- nowSpy.mockReturnValue(1_000_000 + 300_000 + 1)
517
- await service.encryptEntityPayload(entityId, { description: 'second' }, tenantId, null)
518
-
519
- nowSpy.mockRestore()
838
+ describe('TenantDataEncryptionService map read failures (issue #6334)', () => {
839
+ const tenantId = 'tenant-6334'
840
+ const organizationId = 'org-6334'
841
+ const mapRow = (entityId: string) => [{
842
+ entity_id: entityId,
843
+ tenant_id: tenantId,
844
+ organization_id: organizationId,
845
+ fields_json: [{ field: 'display_name' }],
846
+ }]
847
+
848
+ function makeService(execute: jest.Mock) {
849
+ const service = new TenantDataEncryptionService({ execute, getConnection: () => ({ execute }) } as never)
850
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
851
+ return service
852
+ }
520
853
 
521
- const aggregateReads = execute.mock.calls.filter(([, params]) => (params as unknown[]).length === 2)
522
- expect(aggregateReads).toHaveLength(2)
854
+ it('retries the map read after a failed one instead of replaying the stored rejection', async () => {
855
+ const entityId = 'test:inflight_rejection_entity'
856
+ const execute = jest.fn()
857
+ .mockRejectedValueOnce(new Error('connection terminated'))
858
+ .mockImplementation(async () => mapRow(entityId))
859
+ const service = makeService(execute)
860
+
861
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
862
+ .rejects.toThrow('connection terminated')
863
+ await expect(service.getEncryptedFieldNames(entityId, tenantId, organizationId))
864
+ .resolves.toEqual(['display_name'])
865
+ })
866
+
867
+ it('does not couple a concurrent reader to another failing read', async () => {
868
+ const entityId = 'test:inflight_joined_rejection_entity'
869
+ let rejectFirst: (error: Error) => void = () => {}
870
+ const execute = jest.fn()
871
+ .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
872
+ .mockImplementation(async () => mapRow(entityId))
873
+ const service = makeService(execute)
874
+
875
+ const owner = service.getEncryptedFieldNames(entityId, tenantId, organizationId)
876
+ const joiner = service.getEncryptedFieldNames(entityId, tenantId, organizationId)
877
+ await new Promise((resolve) => setImmediate(resolve))
878
+ expect(execute).toHaveBeenCalledTimes(2)
879
+ await expect(joiner).resolves.toEqual(['display_name'])
880
+ rejectFirst(new Error('statement timeout'))
881
+
882
+ await expect(owner).rejects.toThrow('statement timeout')
883
+ expect(execute).toHaveBeenCalledTimes(2)
523
884
  })
524
885
  })
@@ -388,17 +388,12 @@ export class HashicorpVaultKmsService implements KmsService {
388
388
 
389
389
  let loggedDerivedKeyFallbackBanner = false
390
390
 
391
- function fingerprintSecret(secret: string): string {
392
- return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
393
- }
394
-
395
391
  export function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {
396
392
  const sourceLine =
397
393
  opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
398
394
  return [
399
395
  '🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
400
396
  sourceLine,
401
- `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,
402
397
  'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
403
398
  ]
404
399
  }
@@ -419,7 +414,7 @@ function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
419
414
  ]
420
415
  process.stderr.write(bannerLines.join('\n') + '\n')
421
416
  logger.warn('Using derived tenant encryption keys (Vault unavailable / no DEK)', {
422
- secretFingerprint: fingerprintSecret(opts.secret),
417
+ fallbackSource: opts.envName,
423
418
  })
424
419
  }
425
420
 
@@ -73,17 +73,25 @@ function resolveEntityId(em: EntityManager, entityName: EntityName<any>): string
73
73
  * already warn about their own `ILIKE` fallback; this covers the raw-ORM path
74
74
  * that never reaches them. Issue #5051, related to #2990.
75
75
  *
76
+ * A caller whose predicate the walker cannot read — a `raw()` fragment keyed by
77
+ * a symbol, say, which `Object.entries` never yields — passes the matched
78
+ * properties as `likeFields` instead, so wrapping a filter in raw SQL does not
79
+ * silently opt it out of the diagnostic.
80
+ *
76
81
  * Never throws and never blocks the query.
77
82
  */
78
83
  export async function warnOnEncryptedLikeFilter(params: {
79
84
  em: EntityManager
80
85
  entityName: EntityName<any>
81
- where: unknown
86
+ where?: unknown
87
+ likeFields?: readonly string[]
82
88
  tenantId?: string | null
83
89
  encryptionService?: TenantDataEncryptionService | null
84
90
  }): Promise<void> {
85
91
  if (!isWarningEnabled()) return
86
- const fields = collectLikeFilterFields(params.where)
92
+ const fields = Array.from(
93
+ new Set([...collectLikeFilterFields(params.where), ...(params.likeFields ?? [])]),
94
+ )
87
95
  if (!fields.length) return
88
96
  try {
89
97
  const entity = resolveEntityId(params.em, params.entityName)
@@ -0,0 +1,90 @@
1
+ import type { ModuleEncryptionFieldRule, ModuleEncryptionMap } from '../../modules/encryption'
2
+
3
+ /**
4
+ * Tenant encryption maps are seeded only when a tenant is created (`auth:setup`) or by hand
5
+ * (`mercato entities seed-encryption`). A map entry added to a module after tenants exist therefore
6
+ * never reaches them: `encryptEntityPayload` finds no rule and the column is written as plaintext.
7
+ *
8
+ * `buildEncryptionMapBackfillSql` emits one idempotent statement for a module migration that closes
9
+ * that gap for every pre-existing (tenant, organization) scope with active maps:
10
+ * - inserts the whole map where the scope has no row for the entity yet (`NOT EXISTS` guard);
11
+ * - appends each missing field rule to rows that already exist, leaving other rules untouched.
12
+ *
13
+ * Scopes without active maps (encryption disabled) are skipped, mirroring `seed-encryption`. When the
14
+ * `encryption_maps` table does not exist yet (a fresh database where this module migrates before
15
+ * `entities`), the statement is a no-op: no tenant exists at that point either.
16
+ *
17
+ * Prefer a forward-only migration (`down()` without SQL): deleting by `entity_id` would also remove
18
+ * maps seeded at tenant creation or edited by admins and silently switch encryption off.
19
+ */
20
+
21
+ const ENTITY_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/
22
+ const FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
23
+
24
+ function assertIdentifier(value: unknown, pattern: RegExp, label: string): string {
25
+ if (typeof value !== 'string' || !pattern.test(value)) {
26
+ throw new Error(`[internal] buildEncryptionMapBackfillSql received an invalid ${label}: ${String(value)}`)
27
+ }
28
+ return value
29
+ }
30
+
31
+ function normalizeRules(map: ModuleEncryptionMap): ModuleEncryptionFieldRule[] {
32
+ if (!Array.isArray(map.fields) || map.fields.length === 0) {
33
+ throw new Error(`[internal] buildEncryptionMapBackfillSql requires at least one field for ${map.entityId}`)
34
+ }
35
+ const rules: ModuleEncryptionFieldRule[] = []
36
+ for (const rule of map.fields) {
37
+ const field = assertIdentifier(rule?.field, FIELD_NAME_PATTERN, 'field name')
38
+ if (rules.some((existing) => existing.field === field)) continue
39
+ const hashField = rule.hashField == null ? null : assertIdentifier(rule.hashField, FIELD_NAME_PATTERN, 'hash field name')
40
+ rules.push(hashField ? { field, hashField } : { field })
41
+ }
42
+ return rules
43
+ }
44
+
45
+ function jsonbLiteral(value: unknown): string {
46
+ return `'${JSON.stringify(value)}'::jsonb`
47
+ }
48
+
49
+ export function buildEncryptionMapBackfillSql(map: ModuleEncryptionMap): string {
50
+ const entityId = assertIdentifier(map?.entityId, ENTITY_ID_PATTERN, 'entity id')
51
+ if (map.keyScope === 'system') {
52
+ throw new Error(`[internal] buildEncryptionMapBackfillSql: ${entityId} is system-scoped and is resolved from module code, not encryption_maps`)
53
+ }
54
+ const rules = normalizeRules(map)
55
+ const appendStatements = rules.map((rule) => `
56
+ update "encryption_maps"
57
+ set "fields_json" = coalesce("fields_json", '[]'::jsonb) || ${jsonbLiteral([rule])}, "updated_at" = now()
58
+ where "entity_id" = '${entityId}'
59
+ and "deleted_at" is null
60
+ and jsonb_typeof(coalesce("fields_json", '[]'::jsonb)) = 'array'
61
+ and not exists (
62
+ select 1
63
+ from jsonb_array_elements(case when jsonb_typeof("fields_json") = 'array' then "fields_json" else '[]'::jsonb end) as rule
64
+ where rule->>'field' = '${rule.field}'
65
+ );`).join('\n')
66
+
67
+ return `do $$
68
+ begin
69
+ if to_regclass('encryption_maps') is null then
70
+ return;
71
+ end if;
72
+
73
+ insert into "encryption_maps" ("id", "entity_id", "tenant_id", "organization_id", "fields_json", "is_active", "created_at", "updated_at")
74
+ select gen_random_uuid(), '${entityId}', src."tenant_id", src."organization_id", ${jsonbLiteral(rules)}, true, now(), now()
75
+ from (
76
+ select distinct "tenant_id", "organization_id"
77
+ from "encryption_maps"
78
+ where "is_active" = true and "deleted_at" is null
79
+ ) src
80
+ where not exists (
81
+ select 1 from "encryption_maps" existing
82
+ where existing."entity_id" = '${entityId}'
83
+ and existing."tenant_id" is not distinct from src."tenant_id"
84
+ and existing."organization_id" is not distinct from src."organization_id"
85
+ and existing."deleted_at" is null
86
+ );
87
+ ${appendStatements}
88
+ end
89
+ $$;`
90
+ }