@open-mercato/shared 0.6.7-develop.6851.1.c1680b00d8 → 0.6.7-develop.6860.1.f796b22e80

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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/server.js +15 -2
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/crud/factory.js +10 -0
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/db/entityMetadata.js +26 -0
  8. package/dist/lib/db/entityMetadata.js.map +7 -0
  9. package/dist/lib/db/pg-errors.js +42 -0
  10. package/dist/lib/db/pg-errors.js.map +2 -2
  11. package/dist/lib/encryption/subscriber.js +2 -10
  12. package/dist/lib/encryption/subscriber.js.map +2 -2
  13. package/dist/lib/encryption/tenantDataEncryptionService.js +37 -15
  14. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  15. package/dist/lib/version.js +1 -1
  16. package/dist/lib/version.js.map +1 -1
  17. package/dist/modules/registry.js +1 -0
  18. package/dist/modules/registry.js.map +2 -2
  19. package/package.json +2 -2
  20. package/src/lib/auth/__tests__/server.test.ts +23 -0
  21. package/src/lib/auth/server.ts +23 -2
  22. package/src/lib/crud/__tests__/crud-factory.test.ts +12 -0
  23. package/src/lib/crud/factory.ts +13 -0
  24. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  25. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  26. package/src/lib/db/entityMetadata.ts +43 -0
  27. package/src/lib/db/pg-errors.ts +58 -0
  28. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  29. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  30. package/src/lib/encryption/subscriber.ts +2 -14
  31. package/src/lib/encryption/tenantDataEncryptionService.ts +48 -16
  32. package/src/modules/__tests__/registry.test.ts +10 -2
  33. package/src/modules/encryption.ts +3 -0
  34. package/src/modules/registry.ts +1 -0
@@ -6,6 +6,7 @@ import { isTenantDataEncryptionEnabled } from './toggles'
6
6
  import { isEncryptionDebugEnabled } from './toggles'
7
7
  import { resolveTenantEncryptionService } from './customFieldValues'
8
8
  import { createLogger } from '../logger'
9
+ import { listEntityMetadataFromRegistry } from '../db/entityMetadata'
9
10
 
10
11
  const logger = createLogger('shared').child({ component: 'encryption' })
11
12
 
@@ -85,13 +86,8 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
85
86
  try { return registry.find?.(ctor) } catch {}
86
87
  try { return registry.get?.(name) } catch {}
87
88
  try { return registry.get?.(ctor) } catch {}
88
- const all =
89
- (typeof registry.getAll === 'function' && registry.getAll()) ||
90
- (Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||
91
- (registry as any).metadata ||
92
- {}
93
89
  try {
94
- const entries = Array.isArray(all) ? all : Object.values<any>(all)
90
+ const entries = listEntityMetadataFromRegistry(registry)
95
91
  const match = entries.find(
96
92
  (m: any) =>
97
93
  m?.className === name ||
@@ -224,10 +220,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
224
220
  return
225
221
  }
226
222
  const { tenantId, organizationId } = resolveScope(target)
227
- if (!tenantId) {
228
- debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
229
- return
230
- }
231
223
  const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)
232
224
  const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'
233
225
  ? resolvedMeta.properties
@@ -325,10 +317,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
325
317
  const { tenantId, organizationId } = resolveScope(target)
326
318
  const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null
327
319
  const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null
328
- if (!scopedTenantId) {
329
- debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
330
- return
331
- }
332
320
  // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a
333
321
  // managed entity that a command already mutated would clear its dirty changeset and silently
334
322
  // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
@@ -4,6 +4,7 @@ import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'
4
4
  import { createKmsService, type KmsService, type TenantDek } from './kms'
5
5
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
6
6
  import { createLogger } from '../logger'
7
+ import type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'
7
8
 
8
9
  const logger = createLogger('shared').child({ component: 'tenant-encryption' })
9
10
 
@@ -14,6 +15,7 @@ export type EncryptedFieldRule = {
14
15
 
15
16
  export type EncryptionMapRecord = {
16
17
  entityId: string
18
+ keyScope?: EncryptionKeyScope
17
19
  fields: EncryptedFieldRule[]
18
20
  }
19
21
 
@@ -154,13 +156,23 @@ export class TenantDataEncryptionService {
154
156
  private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
155
157
  private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
156
158
  private readonly missCache = TenantDataEncryptionService.globalMissCache
159
+ private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>
157
160
 
158
161
  constructor(
159
162
  private em: EntityManager,
160
- opts?: { cache?: CacheStrategy; kms?: KmsService }
163
+ opts?: {
164
+ cache?: CacheStrategy
165
+ kms?: KmsService
166
+ defaultEncryptionMaps?: readonly ModuleEncryptionMap[]
167
+ }
161
168
  ) {
162
169
  this.cache = opts?.cache
163
170
  this.kms = opts?.kms ?? createKmsService()
171
+ this.systemDefaultMaps = new Map(
172
+ (opts?.defaultEncryptionMaps ?? [])
173
+ .filter((map) => map.keyScope === 'system')
174
+ .map((map) => [map.entityId, map]),
175
+ )
164
176
  }
165
177
 
166
178
  isEnabled(): boolean {
@@ -239,6 +251,24 @@ export class TenantDataEncryptionService {
239
251
  }
240
252
  }
241
253
 
254
+ private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {
255
+ const declared = this.systemDefaultMaps.get(entityId)
256
+ if (!declared) return record
257
+ const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({
258
+ field: field.field,
259
+ hashField: field.hashField ?? null,
260
+ }))
261
+ const declaredFields = new Set(fields.map((field) => field.field))
262
+ for (const field of record?.fields ?? []) {
263
+ if (!declaredFields.has(field.field)) fields.push(field)
264
+ }
265
+ return {
266
+ entityId,
267
+ keyScope: 'system',
268
+ fields,
269
+ }
270
+ }
271
+
242
272
  private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
243
273
  const shouldSkipLookup = (tag: string) => {
244
274
  const expiresAt = this.missCache.get(tag)
@@ -262,13 +292,13 @@ export class TenantDataEncryptionService {
262
292
  if (this.inflightMaps.has(tag)) {
263
293
  const pending = this.inflightMaps.get(tag)!
264
294
  const resolved = await pending
265
- if (resolved) return resolved
295
+ if (resolved) return this.applySystemDefault(resolved, key.entityId)
266
296
  }
267
297
  const mem = this.memoryCache.get(tag)
268
- if (mem) return mem
298
+ if (mem) return this.applySystemDefault(mem, key.entityId)
269
299
  if (this.cache && typeof this.cache.get === 'function') {
270
300
  const cached = await this.cache.get(tag)
271
- if (cached) return cached as EncryptionMapRecord
301
+ if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)
272
302
  }
273
303
  const pending = this.fetchMap(candidate)
274
304
  this.inflightMaps.set(tag, pending)
@@ -288,9 +318,9 @@ export class TenantDataEncryptionService {
288
318
  if (this.cache && typeof this.cache.set === 'function') {
289
319
  await this.cache.set(tag, loaded, { ttl: 300 })
290
320
  }
291
- return loaded
321
+ return this.applySystemDefault(loaded, key.entityId)
292
322
  }
293
- return null
323
+ return this.applySystemDefault(null, key.entityId)
294
324
  }
295
325
 
296
326
  private async fetchAllOrganizationFieldNames(entityId: string, tenantId: string | null): Promise<string[]> {
@@ -435,16 +465,17 @@ export class TenantDataEncryptionService {
435
465
  debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
436
466
  return payload
437
467
  }
438
- const dek = await this.resolveDekForEncrypt(tenantId ?? null)
439
- if (!dek) {
440
- debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId })
441
- return payload
442
- }
443
468
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
444
469
  if (!map || !map.fields?.length) {
445
470
  debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
446
471
  return payload
447
472
  }
473
+ const keyId = map.keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
474
+ const dek = await this.resolveDekForEncrypt(keyId)
475
+ if (!dek) {
476
+ debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
477
+ return payload
478
+ }
448
479
  debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
449
480
  return this.encryptFields(payload, map.fields, dek)
450
481
  }
@@ -459,16 +490,17 @@ export class TenantDataEncryptionService {
459
490
  debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
460
491
  return payload
461
492
  }
462
- const dek = await this.getDek(tenantId ?? null)
463
- if (!dek) {
464
- debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId })
465
- return payload
466
- }
467
493
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
468
494
  if (!map || !map.fields?.length) {
469
495
  debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
470
496
  return payload
471
497
  }
498
+ const keyId = map.keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
499
+ const dek = await this.getDek(keyId)
500
+ if (!dek) {
501
+ debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
502
+ return payload
503
+ }
472
504
  debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
473
505
  return this.decryptFields(payload, map.fields, dek)
474
506
  }
@@ -94,7 +94,11 @@ describe('CLI Modules Registry', () => {
94
94
  {
95
95
  id: 'auth',
96
96
  defaultEncryptionMaps: [
97
- { entityId: 'auth:user', fields: [{ field: 'email', hashField: 'email_hash' }] },
97
+ {
98
+ entityId: 'auth:user',
99
+ keyScope: 'system',
100
+ fields: [{ field: 'email', hashField: 'email_hash' }],
101
+ },
98
102
  ],
99
103
  },
100
104
  {
@@ -106,7 +110,11 @@ describe('CLI Modules Registry', () => {
106
110
  ])
107
111
 
108
112
  expect(maps).toEqual([
109
- { entityId: 'auth:user', fields: [{ field: 'email', hashField: 'email_hash' }] },
113
+ {
114
+ entityId: 'auth:user',
115
+ keyScope: 'system',
116
+ fields: [{ field: 'email', hashField: 'email_hash' }],
117
+ },
110
118
  { entityId: 'customers:customer_comment', fields: [{ field: 'body', hashField: null }] },
111
119
  ])
112
120
  })
@@ -3,7 +3,10 @@ export type ModuleEncryptionFieldRule = {
3
3
  hashField?: string | null
4
4
  }
5
5
 
6
+ export type EncryptionKeyScope = 'tenant' | 'system'
7
+
6
8
  export type ModuleEncryptionMap = {
7
9
  entityId: string
10
+ keyScope?: EncryptionKeyScope
8
11
  fields: ModuleEncryptionFieldRule[]
9
12
  }
@@ -515,6 +515,7 @@ export function getDefaultEncryptionMaps(modules: Module[]): import('./encryptio
515
515
  moduleId: mod.id,
516
516
  map: {
517
517
  entityId: entry.entityId,
518
+ ...(entry.keyScope ? { keyScope: entry.keyScope } : {}),
518
519
  fields: entry.fields.map((field) => ({
519
520
  field: field.field,
520
521
  hashField: field.hashField ?? null,