@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -196,6 +196,64 @@ describe('TenantDataEncryptionService.encryptFields (issue #2720)', () => {
196
196
  })
197
197
  })
198
198
 
199
+ describe('TenantDataEncryptionService system-scoped maps', () => {
200
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
201
+
202
+ beforeEach(() => {
203
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
204
+ })
205
+
206
+ afterEach(() => {
207
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
208
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
209
+ })
210
+
211
+ it('encrypts tenant-less payloads with a stable entity-scoped KMS key', async () => {
212
+ const entityId = 'onboarding:onboarding_request'
213
+ const systemKeyId = `system:${entityId}`
214
+ const getTenantDek = jest.fn(async (keyId: string) => (
215
+ keyId === systemKeyId
216
+ ? { tenantId: keyId, key: fixedKey, fetchedAt: new Date() }
217
+ : null
218
+ ))
219
+ const service = new TenantDataEncryptionService(
220
+ {
221
+ getConnection: () => ({ execute: jest.fn(async () => []) }),
222
+ } as never,
223
+ {
224
+ kms: {
225
+ getTenantDek,
226
+ createTenantDek: jest.fn(async () => null),
227
+ isHealthy: () => true,
228
+ },
229
+ defaultEncryptionMaps: [
230
+ {
231
+ entityId,
232
+ keyScope: 'system',
233
+ fields: [
234
+ { field: 'email', hashField: 'email_hash' },
235
+ { field: 'password_hash' },
236
+ ],
237
+ },
238
+ ],
239
+ } as never,
240
+ )
241
+
242
+ const encrypted = await service.encryptEntityPayload(entityId, {
243
+ email: 'person@example.com',
244
+ email_hash: null,
245
+ password_hash: 'bcrypt-value',
246
+ }, null, null)
247
+
248
+ expect(getTenantDek).toHaveBeenCalledWith(systemKeyId)
249
+ expect(encrypted.email).not.toBe('person@example.com')
250
+ expect(encrypted.password_hash).not.toBe('bcrypt-value')
251
+ expect(encrypted.email_hash).toBe(hashForLookup('person@example.com'))
252
+ expect(decryptWithAesGcm(encrypted.email as string, fixedKey)).toBe('person@example.com')
253
+ expect(decryptWithAesGcm(encrypted.password_hash as string, fixedKey)).toBe('bcrypt-value')
254
+ })
255
+ })
256
+
199
257
  describe('TenantDataEncryptionService.getEncryptedFieldNames', () => {
200
258
  it('returns active encryption-map field names for query planning', async () => {
201
259
  const service = new TenantDataEncryptionService({} as never)
@@ -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[]> {
@@ -336,12 +366,26 @@ export class TenantDataEncryptionService {
336
366
  this.kms.invalidateDek?.(tenantId)
337
367
  }
338
368
 
369
+ /**
370
+ * Lists the fields an encryption map marks as encrypted at rest.
371
+ *
372
+ * `isEnabled()` folds the environment toggle together with KMS health, so by default an
373
+ * unhealthy KMS reports "nothing is encrypted" — which is safe for write paths but wrong for
374
+ * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`
375
+ * answers the on-disk question instead: it consults the map even when the KMS cannot currently
376
+ * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).
377
+ */
339
378
  async getEncryptedFieldNames(
340
379
  entityId: string,
341
380
  tenantId: string | null | undefined,
342
- organizationId?: string | null
381
+ organizationId?: string | null,
382
+ options?: { ignoreRuntimeHealth?: boolean }
343
383
  ): Promise<string[]> {
344
- if (!this.isEnabled()) return []
384
+ if (options?.ignoreRuntimeHealth) {
385
+ if (!isTenantDataEncryptionEnabled()) return []
386
+ } else if (!this.isEnabled()) {
387
+ return []
388
+ }
345
389
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
346
390
  const fields = new Set(normalizeEncryptedFieldNames(map?.fields))
347
391
  if (organizationId == null) {
@@ -421,16 +465,17 @@ export class TenantDataEncryptionService {
421
465
  debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
422
466
  return payload
423
467
  }
424
- const dek = await this.resolveDekForEncrypt(tenantId ?? null)
425
- if (!dek) {
426
- debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId })
427
- return payload
428
- }
429
468
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
430
469
  if (!map || !map.fields?.length) {
431
470
  debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
432
471
  return payload
433
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
+ }
434
479
  debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
435
480
  return this.encryptFields(payload, map.fields, dek)
436
481
  }
@@ -445,16 +490,17 @@ export class TenantDataEncryptionService {
445
490
  debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
446
491
  return payload
447
492
  }
448
- const dek = await this.getDek(tenantId ?? null)
449
- if (!dek) {
450
- debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId })
451
- return payload
452
- }
453
493
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
454
494
  if (!map || !map.fields?.length) {
455
495
  debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
456
496
  return payload
457
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
+ }
458
504
  debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
459
505
  return this.decryptFields(payload, map.fields, dek)
460
506
  }
@@ -0,0 +1,8 @@
1
+ import { defaultLocale, locales, type Locale } from '../config'
2
+
3
+ describe('supported application locales', () => {
4
+ it('registers Korean without changing the default locale', () => {
5
+ expect(locales).toEqual<Locale[]>(['en', 'pl', 'es', 'de', 'ko'])
6
+ expect(defaultLocale).toBe('en')
7
+ })
8
+ })
@@ -0,0 +1,30 @@
1
+ import { loadDictionary, registerModules } from '../server'
2
+ import type { Module } from '../../../modules/registry'
3
+
4
+ describe('loadDictionary memoization', () => {
5
+ it('returns a cached dictionary for repeated calls with the same locale', async () => {
6
+ registerModules([
7
+ { id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } },
8
+ ] satisfies Module[])
9
+
10
+ const first = await loadDictionary('en')
11
+ const second = await loadDictionary('en')
12
+
13
+ expect(second).toBe(first)
14
+ expect(first['demo.hello']).toBe('Hello')
15
+ })
16
+
17
+ it('busts the cache when the registered module set changes', async () => {
18
+ registerModules([{ id: 'a', translations: { en: { 'a.k': 'A' } } }] satisfies Module[])
19
+ const before = await loadDictionary('en')
20
+
21
+ registerModules([
22
+ { id: 'a', translations: { en: { 'a.k': 'A' } } },
23
+ { id: 'b', translations: { en: { 'b.k': 'B' } } },
24
+ ] satisfies Module[])
25
+ const after = await loadDictionary('en')
26
+
27
+ expect(after).not.toBe(before)
28
+ expect(after['b.k']).toBe('B')
29
+ })
30
+ })
@@ -0,0 +1,29 @@
1
+ import { invalidateDictionaryCache, loadDictionary, registerModules, resolveTranslations } from '../server'
2
+ import type { Module } from '../../../modules/registry'
3
+
4
+ const REGISTRY_GLOBAL_KEY = '__openMercatoModulesRegistry__'
5
+
6
+ describe('loadDictionary without a bootstrapped module registry', () => {
7
+ beforeEach(() => {
8
+ delete (globalThis as Record<string, unknown>)[REGISTRY_GLOBAL_KEY]
9
+ invalidateDictionaryCache()
10
+ })
11
+
12
+ it('resolves the app dictionary instead of throwing a bootstrap error', async () => {
13
+ await expect(loadDictionary('en')).resolves.toEqual(expect.any(Object))
14
+ })
15
+
16
+ it('lets a route handler translate its response before bootstrap runs', async () => {
17
+ const { translate } = await resolveTranslations()
18
+
19
+ expect(translate('storage_s3.upload.errors.unsupported', 'Unsupported file type')).toBe('Unsupported file type')
20
+ })
21
+
22
+ it('picks up module translations once bootstrap registers them', async () => {
23
+ await loadDictionary('en')
24
+
25
+ registerModules([{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } }] satisfies Module[])
26
+
27
+ await expect(loadDictionary('en')).resolves.toMatchObject({ 'demo.hello': 'Hello' })
28
+ })
29
+ })
@@ -1,4 +1,4 @@
1
- export type Locale = 'en' | 'pl' | 'es' | 'de'
1
+ export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'
2
2
 
3
- export const locales: Locale[] = ['en', 'pl', 'es', 'de']
3
+ export const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
4
4
  export const defaultLocale: Locale = 'en'
@@ -2,7 +2,7 @@ import { defaultLocale, locales, type Locale } from './config'
2
2
  import type { Dict } from './context'
3
3
  import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'
4
4
  import { createFallbackTranslator, createTranslator } from './translate'
5
- import { getModules } from '../modules/registry'
5
+ import { tryGetModules } from '../modules/registry'
6
6
  import { loadAppDictionary } from './app-dictionaries'
7
7
  import { getCachedDictionary, setCachedDictionary } from './dictionary-cache'
8
8
 
@@ -61,7 +61,11 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
61
61
  // Load from registry instead of @/ import (works in standalone packages)
62
62
  const baseRaw = await loadAppDictionary(locale)
63
63
  const merged: Dict = { ...flattenDictionary(baseRaw) }
64
- const modules = getModules()
64
+ // Route handlers translate their responses, so they resolve a dictionary even
65
+ // when they are exercised in isolation without a bootstrapped registry. The
66
+ // app dictionary alone is the right degraded answer there — `registerModules`
67
+ // invalidates this cache, so a later bootstrap still gets the merged result.
68
+ const modules = tryGetModules() ?? []
65
69
  for (const m of modules) {
66
70
  const dict = m.translations?.[locale]
67
71
  if (dict) Object.assign(merged, flattenDictionary(dict))
@@ -3,7 +3,9 @@ import {
3
3
  createLogger,
4
4
  getLogLevel,
5
5
  isLevelEnabled,
6
+ registerLoggerExtension,
6
7
  resetLogLevelCache,
8
+ resetLoggerExtension,
7
9
  resetLoggerRegistry,
8
10
  } from '../index'
9
11
  import { resolveLevel, type LogLevel } from '../level'
@@ -65,6 +67,7 @@ function resetLoggerState(): void {
65
67
  resetLoggerRegistry()
66
68
  resetServerLoggerCache()
67
69
  resetLogPrettyCache()
70
+ resetLoggerExtension()
68
71
  }
69
72
 
70
73
  function forcePinoTransport(): void {
@@ -241,6 +244,80 @@ describe('structured logging facade', () => {
241
244
  })
242
245
  })
243
246
 
247
+ describe('process-wide logger extension', () => {
248
+ beforeEach(() => {
249
+ forcePinoTransport()
250
+ })
251
+
252
+ it('keeps one local line and observes one correlated record with child bindings', () => {
253
+ const fake = createFakePinoHarness()
254
+ mockPinoLoader(fake.factory)
255
+ const records: unknown[] = []
256
+ registerLoggerExtension({
257
+ enrich: () => ({ trace_id: 'trace-1', span_id: 'span-1' }),
258
+ emit: (record) => records.push(record),
259
+ })
260
+
261
+ createLogger('orders')
262
+ .child({ module: 'sales' })
263
+ .info('Order placed', { orderId: 'o-1' })
264
+
265
+ expect(fake.calls).toEqual([{
266
+ level: 'info',
267
+ args: [{
268
+ orderId: 'o-1',
269
+ trace_id: 'trace-1',
270
+ span_id: 'span-1',
271
+ }, 'Order placed'],
272
+ }])
273
+ expect(records).toHaveLength(1)
274
+ expect(records[0]).toMatchObject({
275
+ level: 'info',
276
+ namespace: 'orders',
277
+ message: 'Order placed',
278
+ fields: {
279
+ module: 'sales',
280
+ orderId: 'o-1',
281
+ trace_id: 'trace-1',
282
+ span_id: 'span-1',
283
+ },
284
+ })
285
+ })
286
+
287
+ it('applies the shared level gate to local and remote output', () => {
288
+ process.env.OM_LOG_LEVEL = 'warn'
289
+ resetLogLevelCache()
290
+ const fake = createFakePinoHarness()
291
+ mockPinoLoader(fake.factory)
292
+ const emit = jest.fn()
293
+ registerLoggerExtension({ emit })
294
+ const logger = createLogger('gated-extension')
295
+
296
+ logger.info('quiet')
297
+ logger.warn('visible')
298
+
299
+ expect(fake.calls.map((call) => call.level)).toEqual(['warn'])
300
+ expect(emit).toHaveBeenCalledTimes(1)
301
+ expect(emit.mock.calls[0][0]).toMatchObject({ level: 'warn', message: 'visible' })
302
+ })
303
+
304
+ it('isolates extension failures from application logging', () => {
305
+ const fake = createFakePinoHarness()
306
+ mockPinoLoader(fake.factory)
307
+ registerLoggerExtension({
308
+ enrich: () => {
309
+ throw new Error('context failed')
310
+ },
311
+ emit: () => {
312
+ throw new Error('sink failed')
313
+ },
314
+ })
315
+
316
+ expect(() => createLogger('resilient-extension').error('Still local')).not.toThrow()
317
+ expect(fake.calls).toEqual([{ level: 'error', args: [{}, 'Still local'] }])
318
+ })
319
+ })
320
+
244
321
  describe('isomorphism', () => {
245
322
  beforeEach(() => {
246
323
  forcePinoTransport()
@@ -0,0 +1,59 @@
1
+ import type { LogLevel } from './level'
2
+
3
+ export type LoggerExtensionRecord = {
4
+ level: LogLevel
5
+ namespace: string
6
+ message: string
7
+ fields: Record<string, unknown>
8
+ time: number
9
+ }
10
+
11
+ export type LoggerExtension = {
12
+ /**
13
+ * Add process-local context (for example trace/span ids) to both the local
14
+ * line and the optional remote sink.
15
+ */
16
+ enrich?(): Record<string, unknown> | undefined
17
+ /**
18
+ * Observe a record after the local transport writes it. Implementations must
19
+ * never throw into application code.
20
+ */
21
+ emit?(record: LoggerExtensionRecord): void
22
+ }
23
+
24
+ const GLOBAL_KEY = Symbol.for('@open-mercato/shared.loggerExtension')
25
+
26
+ type LoggerExtensionStore = {
27
+ active?: LoggerExtension
28
+ }
29
+
30
+ function store(): LoggerExtensionStore {
31
+ const globalStore = globalThis as unknown as Record<symbol, LoggerExtensionStore | undefined>
32
+ let current = globalStore[GLOBAL_KEY]
33
+ if (!current) {
34
+ current = {}
35
+ globalStore[GLOBAL_KEY] = current
36
+ }
37
+ return current
38
+ }
39
+
40
+ /**
41
+ * Register the single process-wide logger extension. The returned disposer only
42
+ * clears the extension when it is still the active registration.
43
+ */
44
+ export function registerLoggerExtension(extension: LoggerExtension): () => void {
45
+ store().active = extension
46
+ return () => {
47
+ const current = store()
48
+ if (current.active === extension) current.active = undefined
49
+ }
50
+ }
51
+
52
+ export function getLoggerExtension(): LoggerExtension | undefined {
53
+ return store().active
54
+ }
55
+
56
+ /** Test-only: clear the process-wide extension. */
57
+ export function resetLoggerExtension(): void {
58
+ store().active = undefined
59
+ }
@@ -1,9 +1,17 @@
1
1
  import { selectTransport } from './transport'
2
+ import { getLoggerExtension } from './extension'
3
+ import { isLevelEnabled, type LogLevel } from './level'
2
4
 
3
5
  export type { LogLevel } from './level'
4
6
  export { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'
5
7
  export { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'
6
8
  export { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'
9
+ export {
10
+ getLoggerExtension,
11
+ registerLoggerExtension,
12
+ resetLoggerExtension,
13
+ } from './extension'
14
+ export type { LoggerExtension, LoggerExtensionRecord } from './extension'
7
15
 
8
16
  export type LogBindings = Record<string, unknown>
9
17
 
@@ -18,11 +26,61 @@ export interface Logger {
18
26
 
19
27
  const loggerRegistry = new Map<string, Logger>()
20
28
 
29
+ function createExtendedLogger(
30
+ namespace: string,
31
+ transport: Logger,
32
+ bindings: LogBindings = {},
33
+ ): Logger {
34
+ const emit = (
35
+ level: LogLevel,
36
+ msg: string,
37
+ fields?: LogBindings,
38
+ ): void => {
39
+ if (!isLevelEnabled(level)) return
40
+
41
+ const extension = getLoggerExtension()
42
+ let context: LogBindings = {}
43
+ try {
44
+ context = extension?.enrich?.() ?? {}
45
+ } catch {
46
+ // Observability must never alter application behavior.
47
+ }
48
+
49
+ const mergedFields = { ...fields, ...context }
50
+ transport[level](msg, mergedFields)
51
+
52
+ try {
53
+ extension?.emit?.({
54
+ level,
55
+ namespace,
56
+ message: msg,
57
+ fields: { ...bindings, ...mergedFields },
58
+ time: Date.now(),
59
+ })
60
+ } catch {
61
+ // Remote logging is best-effort; the local line has already been written.
62
+ }
63
+ }
64
+
65
+ return {
66
+ debug: (msg, fields) => emit('debug', msg, fields),
67
+ info: (msg, fields) => emit('info', msg, fields),
68
+ warn: (msg, fields) => emit('warn', msg, fields),
69
+ error: (msg, fields) => emit('error', msg, fields),
70
+ child: (childBindings) =>
71
+ createExtendedLogger(
72
+ namespace,
73
+ transport.child(childBindings),
74
+ { ...bindings, ...childBindings },
75
+ ),
76
+ }
77
+ }
78
+
21
79
  /** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */
22
80
  export function createLogger(namespace: string): Logger {
23
81
  const existing = loggerRegistry.get(namespace)
24
82
  if (existing) return existing
25
- const created = selectTransport(namespace)
83
+ const created = createExtendedLogger(namespace, selectTransport(namespace))
26
84
  loggerRegistry.set(namespace, created)
27
85
  return created
28
86
  }