@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4

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 (224) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +62 -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 +190 -70
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/bootstrap/factory.js +18 -13
  15. package/dist/lib/bootstrap/factory.js.map +2 -2
  16. package/dist/lib/commands/command-bus.js +9 -3
  17. package/dist/lib/commands/command-bus.js.map +2 -2
  18. package/dist/lib/commands/command-interceptor-runner.js +9 -4
  19. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  20. package/dist/lib/commands/errors.js +25 -4
  21. package/dist/lib/commands/errors.js.map +2 -2
  22. package/dist/lib/commands/index.js +7 -1
  23. package/dist/lib/commands/index.js.map +2 -2
  24. package/dist/lib/crud/enricher-runner.js +2 -11
  25. package/dist/lib/crud/enricher-runner.js.map +2 -2
  26. package/dist/lib/crud/factory.js +104 -23
  27. package/dist/lib/crud/factory.js.map +2 -2
  28. package/dist/lib/crud/interceptor-runner.js +2 -2
  29. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  30. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  31. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  32. package/dist/lib/crud/types.js +4 -0
  33. package/dist/lib/crud/types.js.map +3 -3
  34. package/dist/lib/data/consistency.js +19 -0
  35. package/dist/lib/data/consistency.js.map +7 -0
  36. package/dist/lib/data/engine.js +37 -11
  37. package/dist/lib/data/engine.js.map +2 -2
  38. package/dist/lib/db/entityMetadata.js +26 -0
  39. package/dist/lib/db/entityMetadata.js.map +7 -0
  40. package/dist/lib/db/pg-errors.js +42 -0
  41. package/dist/lib/db/pg-errors.js.map +2 -2
  42. package/dist/lib/di/container.js +48 -6
  43. package/dist/lib/di/container.js.map +2 -2
  44. package/dist/lib/email/send.js +56 -0
  45. package/dist/lib/email/send.js.map +2 -2
  46. package/dist/lib/encryption/find.js +22 -0
  47. package/dist/lib/encryption/find.js.map +2 -2
  48. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  49. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  50. package/dist/lib/encryption/subscriber.js +2 -10
  51. package/dist/lib/encryption/subscriber.js.map +2 -2
  52. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  53. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  54. package/dist/lib/i18n/config.js +1 -1
  55. package/dist/lib/i18n/config.js.map +2 -2
  56. package/dist/lib/i18n/dictionary-cache.js +5 -0
  57. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  58. package/dist/lib/i18n/server.js +4 -4
  59. package/dist/lib/i18n/server.js.map +3 -3
  60. package/dist/lib/logger/extension.js +29 -0
  61. package/dist/lib/logger/extension.js.map +7 -0
  62. package/dist/lib/logger/index.js +47 -3
  63. package/dist/lib/logger/index.js.map +3 -3
  64. package/dist/lib/modules/registry.js +36 -6
  65. package/dist/lib/modules/registry.js.map +2 -2
  66. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  67. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  68. package/dist/lib/query/ciphertext-search-warning.js +51 -0
  69. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  70. package/dist/lib/query/engine.js +60 -43
  71. package/dist/lib/query/engine.js.map +2 -2
  72. package/dist/lib/query/types.js.map +1 -1
  73. package/dist/lib/redis/connection.js +7 -2
  74. package/dist/lib/redis/connection.js.map +2 -2
  75. package/dist/lib/search/auto-indexing.js +14 -0
  76. package/dist/lib/search/auto-indexing.js.map +7 -0
  77. package/dist/lib/search/availability.js +107 -0
  78. package/dist/lib/search/availability.js.map +7 -0
  79. package/dist/lib/search/config.js +61 -2
  80. package/dist/lib/search/config.js.map +2 -2
  81. package/dist/lib/search/tokenLookup.js +46 -0
  82. package/dist/lib/search/tokenLookup.js.map +7 -0
  83. package/dist/lib/search/tokenize.js +23 -11
  84. package/dist/lib/search/tokenize.js.map +2 -2
  85. package/dist/lib/telemetry/runtime.js +35 -0
  86. package/dist/lib/telemetry/runtime.js.map +7 -0
  87. package/dist/lib/version.js +1 -1
  88. package/dist/lib/version.js.map +1 -1
  89. package/dist/lib/webhooks/body.js +57 -0
  90. package/dist/lib/webhooks/body.js.map +7 -0
  91. package/dist/lib/webhooks/inbound-types.js +1 -0
  92. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  93. package/dist/lib/webhooks/index.js +13 -1
  94. package/dist/lib/webhooks/index.js.map +2 -2
  95. package/dist/lib/webhooks/verify.js +12 -7
  96. package/dist/lib/webhooks/verify.js.map +2 -2
  97. package/dist/modules/analytics.js.map +2 -2
  98. package/dist/modules/overrides.js +50 -1
  99. package/dist/modules/overrides.js.map +2 -2
  100. package/dist/modules/payment_gateways/types.js +8 -1
  101. package/dist/modules/payment_gateways/types.js.map +2 -2
  102. package/dist/modules/registry.js +1 -0
  103. package/dist/modules/registry.js.map +2 -2
  104. package/dist/modules/widgets/extension-points.js +96 -0
  105. package/dist/modules/widgets/extension-points.js.map +7 -0
  106. package/dist/security/enabledModulesRegistry.js +56 -11
  107. package/dist/security/enabledModulesRegistry.js.map +2 -2
  108. package/dist/security/featurePolicy.js +62 -0
  109. package/dist/security/featurePolicy.js.map +7 -0
  110. package/jest.config.cjs +20 -10
  111. package/package.json +19 -9
  112. package/scripts/versionSource.cjs +65 -0
  113. package/src/lib/__tests__/versionSource.test.ts +64 -0
  114. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  115. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  116. package/src/lib/ai/llm-provider.ts +33 -0
  117. package/src/lib/ai/safety-identifier.ts +73 -0
  118. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  119. package/src/lib/auth/__tests__/server.test.ts +23 -0
  120. package/src/lib/auth/organizationScope.ts +65 -0
  121. package/src/lib/auth/server.ts +23 -2
  122. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  123. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  124. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  125. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  126. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  127. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  128. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  129. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  130. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  131. package/src/lib/bootstrap/dynamicLoader.ts +346 -96
  132. package/src/lib/bootstrap/factory.ts +21 -15
  133. package/src/lib/bootstrap/types.ts +6 -0
  134. package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
  135. package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
  136. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
  137. package/src/lib/commands/command-bus.ts +9 -3
  138. package/src/lib/commands/command-interceptor-runner.ts +27 -6
  139. package/src/lib/commands/command-interceptor.ts +8 -0
  140. package/src/lib/commands/errors.ts +67 -2
  141. package/src/lib/commands/index.ts +7 -1
  142. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  143. package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
  144. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  145. package/src/lib/crud/enricher-runner.ts +2 -11
  146. package/src/lib/crud/factory.ts +157 -22
  147. package/src/lib/crud/interceptor-runner.ts +2 -2
  148. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  149. package/src/lib/crud/types.ts +3 -0
  150. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  151. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  152. package/src/lib/data/consistency.ts +17 -0
  153. package/src/lib/data/engine.ts +50 -16
  154. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  155. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  156. package/src/lib/db/entityMetadata.ts +43 -0
  157. package/src/lib/db/pg-errors.ts +58 -0
  158. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  159. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  160. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  161. package/src/lib/di/container.ts +72 -8
  162. package/src/lib/email/__tests__/send.test.ts +37 -1
  163. package/src/lib/email/send.ts +79 -0
  164. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  165. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  166. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  167. package/src/lib/encryption/find.ts +22 -0
  168. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  169. package/src/lib/encryption/subscriber.ts +2 -14
  170. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  171. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  172. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  173. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  174. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  175. package/src/lib/i18n/config.ts +2 -2
  176. package/src/lib/i18n/dictionary-cache.ts +5 -0
  177. package/src/lib/i18n/server.ts +6 -2
  178. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  179. package/src/lib/logger/extension.ts +59 -0
  180. package/src/lib/logger/index.ts +59 -1
  181. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  182. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  183. package/src/lib/modules/registry.ts +46 -7
  184. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  185. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  186. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
  187. package/src/lib/query/__tests__/engine.test.ts +213 -13
  188. package/src/lib/query/ciphertext-search-warning.ts +106 -0
  189. package/src/lib/query/engine.ts +102 -61
  190. package/src/lib/query/types.ts +4 -5
  191. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  192. package/src/lib/redis/connection.ts +10 -7
  193. package/src/lib/search/__tests__/availability.test.ts +211 -0
  194. package/src/lib/search/__tests__/config.test.ts +177 -0
  195. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  196. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  197. package/src/lib/search/auto-indexing.ts +22 -0
  198. package/src/lib/search/availability.ts +232 -0
  199. package/src/lib/search/config.ts +106 -8
  200. package/src/lib/search/tokenLookup.ts +133 -0
  201. package/src/lib/search/tokenize.ts +33 -11
  202. package/src/lib/telemetry/runtime.ts +72 -0
  203. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  204. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  205. package/src/lib/webhooks/body.ts +70 -0
  206. package/src/lib/webhooks/inbound-types.ts +125 -0
  207. package/src/lib/webhooks/index.ts +19 -1
  208. package/src/lib/webhooks/verify.ts +17 -9
  209. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  210. package/src/modules/__tests__/registry.test.ts +10 -2
  211. package/src/modules/analytics.ts +7 -0
  212. package/src/modules/customer-auth.ts +1 -0
  213. package/src/modules/encryption.ts +3 -0
  214. package/src/modules/entities.ts +15 -0
  215. package/src/modules/navigation/backendChrome.ts +15 -0
  216. package/src/modules/overrides.ts +103 -0
  217. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  218. package/src/modules/payment_gateways/types.ts +41 -0
  219. package/src/modules/registry.ts +3 -0
  220. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  221. package/src/modules/widgets/extension-points.ts +524 -0
  222. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  223. package/src/security/enabledModulesRegistry.ts +64 -12
  224. 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)
@@ -5,6 +5,7 @@ import type {
5
5
  FindOneOptions,
6
6
  FindOptions,
7
7
  } from '@mikro-orm/postgresql'
8
+ import { warnOnEncryptedLikeFilter } from './likeFilterWarning'
8
9
  import { decryptEntitiesWithFallbackScope } from './subscriber'
9
10
  import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
10
11
 
@@ -24,6 +25,13 @@ export async function findWithDecryption<Entity extends object, Hint extends str
24
25
  options?: AnyFindOptions<Entity, Hint>,
25
26
  scope?: DecryptionScope,
26
27
  ): Promise<Entity[]> {
28
+ await warnOnEncryptedLikeFilter({
29
+ em,
30
+ entityName,
31
+ where,
32
+ tenantId: scope?.tenantId,
33
+ encryptionService: scope?.encryptionService,
34
+ })
27
35
  const records = (await em.find<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
28
36
  | Entity[]
29
37
  | undefined
@@ -44,6 +52,13 @@ export async function findOneWithDecryption<Entity extends object, Hint extends
44
52
  options?: AnyFindOneOptions<Entity, Hint>,
45
53
  scope?: DecryptionScope,
46
54
  ): Promise<Entity | null> {
55
+ await warnOnEncryptedLikeFilter({
56
+ em,
57
+ entityName,
58
+ where,
59
+ tenantId: scope?.tenantId,
60
+ encryptionService: scope?.encryptionService,
61
+ })
47
62
  const record = (await em.findOne<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
48
63
  | Entity
49
64
  | null
@@ -64,6 +79,13 @@ export async function findAndCountWithDecryption<Entity extends object, Hint ext
64
79
  options?: AnyFindOptions<Entity, Hint>,
65
80
  scope?: DecryptionScope,
66
81
  ): Promise<[Entity[], number]> {
82
+ await warnOnEncryptedLikeFilter({
83
+ em,
84
+ entityName,
85
+ where,
86
+ tenantId: scope?.tenantId,
87
+ encryptionService: scope?.encryptionService,
88
+ })
67
89
  const [recordsRaw, count] = await em.findAndCount<Entity, Hint, any, any>(
68
90
  entityName as any,
69
91
  where as any,
@@ -0,0 +1,102 @@
1
+ import type { EntityManager, EntityName } from '@mikro-orm/postgresql'
2
+ import { warnOnCiphertextLikeFallback } from '../query/ciphertext-search-warning'
3
+ import { resolveTenantEncryptionService } from './customFieldValues'
4
+ import { resolveEntityIdFromMetadata } from './entityIds'
5
+ import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
6
+
7
+ const LIKE_OPERATORS = new Set(['$like', '$ilike', '$re', '$fulltext'])
8
+ const LOGICAL_OPERATORS = new Set(['$and', '$or', '$not', '$every', '$some', '$none'])
9
+
10
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)
12
+
13
+ // An operator object holds only `$`-prefixed keys. Anything else is a nested
14
+ // relation filter, whose property names belong to another entity's encryption
15
+ // map — attributing them to the root entity would produce false warnings.
16
+ const isOperatorObject = (value: unknown): value is Record<string, unknown> => {
17
+ if (!isPlainObject(value)) return false
18
+ const keys = Object.keys(value)
19
+ return keys.length > 0 && keys.every((key) => key.startsWith('$'))
20
+ }
21
+
22
+ function walkFilter(node: unknown, field: string | null, out: Set<string>): void {
23
+ if (Array.isArray(node)) {
24
+ for (const item of node) walkFilter(item, field, out)
25
+ return
26
+ }
27
+ if (!isPlainObject(node)) return
28
+ for (const [key, value] of Object.entries(node)) {
29
+ if (LIKE_OPERATORS.has(key)) {
30
+ if (field) out.add(field)
31
+ continue
32
+ }
33
+ if (LOGICAL_OPERATORS.has(key)) {
34
+ walkFilter(value, field, out)
35
+ continue
36
+ }
37
+ if (key.startsWith('$')) continue
38
+ if (isOperatorObject(value)) walkFilter(value, key, out)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Lists the root-entity properties a filter targets with a string-matching
44
+ * operator (`$like`, `$ilike`, `$re`, `$fulltext`), including inside
45
+ * `$and`/`$or`/`$not` branches.
46
+ */
47
+ export function collectLikeFilterFields(where: unknown): string[] {
48
+ const found = new Set<string>()
49
+ walkFilter(where, null, found)
50
+ return Array.from(found)
51
+ }
52
+
53
+ // The map lookup behind the warning issues an uncached read against
54
+ // `encryption_maps`, and this path runs on every matching query rather than in a
55
+ // rare fallback branch — so the check stays out of production entirely.
56
+ const isWarningEnabled = (): boolean => process.env.NODE_ENV !== 'production'
57
+
58
+ function resolveEntityId(em: EntityManager, entityName: EntityName<any>): string | null {
59
+ const metadata = (em as any)?.getMetadata?.()
60
+ if (!metadata || typeof metadata.find !== 'function') return null
61
+ const name = typeof entityName === 'string' ? entityName : (entityName as any)?.name
62
+ if (!name) return null
63
+ return resolveEntityIdFromMetadata(metadata.find(name) ?? undefined)
64
+ }
65
+
66
+ /**
67
+ * Warn, outside production, when a `where` clause matches an encrypted-at-rest
68
+ * column with a string operator.
69
+ *
70
+ * Decryption happens on load, not on filter, so the predicate compares a
71
+ * plaintext pattern against ciphertext: it matches nothing and does not raise,
72
+ * which is indistinguishable from a genuine empty result. The query engines
73
+ * already warn about their own `ILIKE` fallback; this covers the raw-ORM path
74
+ * that never reaches them. Issue #5051, related to #2990.
75
+ *
76
+ * Never throws and never blocks the query.
77
+ */
78
+ export async function warnOnEncryptedLikeFilter(params: {
79
+ em: EntityManager
80
+ entityName: EntityName<any>
81
+ where: unknown
82
+ tenantId?: string | null
83
+ encryptionService?: TenantDataEncryptionService | null
84
+ }): Promise<void> {
85
+ if (!isWarningEnabled()) return
86
+ const fields = collectLikeFilterFields(params.where)
87
+ if (!fields.length) return
88
+ try {
89
+ const entity = resolveEntityId(params.em, params.entityName)
90
+ if (!entity) return
91
+ const service = params.encryptionService ?? resolveTenantEncryptionService(params.em)
92
+ await warnOnCiphertextLikeFallback({
93
+ entity,
94
+ fields,
95
+ tenantId: params.tenantId ?? null,
96
+ reason: 'raw-orm-filter',
97
+ service,
98
+ })
99
+ } catch {
100
+ // A diagnostic must never break the query it is diagnosing.
101
+ }
102
+ }
@@ -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
+ })
@@ -41,6 +41,29 @@ describe('i18n dictionary memoization', () => {
41
41
  expect(loader).toHaveBeenCalledTimes(2)
42
42
  })
43
43
 
44
+ it('keeps other locale dictionaries cached after locale-scoped module registration', async () => {
45
+ const loader = jest.fn(async (locale: Locale) => ({ greeting: locale }))
46
+ registerAppDictionaryLoader(loader)
47
+ registerModules([
48
+ { id: 'demo', translations: { en: { module: 'English' }, pl: { module: 'Polski' } } },
49
+ ] as any)
50
+
51
+ const englishBefore = await loadDictionary('en')
52
+ const polishBefore = await loadDictionary('pl')
53
+
54
+ registerModules([
55
+ { id: 'demo', translations: { pl: { module: 'Polski 2' } } },
56
+ ] as any)
57
+
58
+ const englishAfter = await loadDictionary('en')
59
+ const polishAfter = await loadDictionary('pl')
60
+
61
+ expect(englishAfter).toBe(englishBefore)
62
+ expect(polishAfter).not.toBe(polishBefore)
63
+ expect(polishAfter.module).toBe('Polski 2')
64
+ expect(loader).toHaveBeenCalledTimes(3)
65
+ })
66
+
44
67
  it('rebuilds after modules are re-registered', async () => {
45
68
  registerAppDictionaryLoader(async () => ({}))
46
69
  registerModules([{ translations: { en: { a: 'one' } } }] as any)
@@ -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'
@@ -33,3 +33,8 @@ export function setCachedDictionary(locale: Locale, dict: Dict): void {
33
33
  export function invalidateDictionaryCache(): void {
34
34
  getCache().clear()
35
35
  }
36
+
37
+ export function invalidateDictionaryCacheLocales(locales: readonly Locale[]): void {
38
+ const cache = getCache()
39
+ for (const locale of locales) cache.delete(locale)
40
+ }
@@ -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()