@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +10 -0
  3. package/dist/lib/auth/apiKeyAuthCache.js +17 -6
  4. package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
  5. package/dist/lib/commands/command-bus.js +56 -47
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/flush.js +23 -1
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/index.js +6 -1
  10. package/dist/lib/commands/index.js.map +2 -2
  11. package/dist/lib/commands/redo.js +106 -0
  12. package/dist/lib/commands/redo.js.map +7 -0
  13. package/dist/lib/commands/runCrudCommandWrite.js +38 -0
  14. package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
  15. package/dist/lib/commands/scope.js +51 -37
  16. package/dist/lib/commands/scope.js.map +2 -2
  17. package/dist/lib/commands/types.js.map +2 -2
  18. package/dist/lib/crud/errors.js +22 -0
  19. package/dist/lib/crud/errors.js.map +2 -2
  20. package/dist/lib/crud/factory.js +16 -0
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  23. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  24. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  25. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  26. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  27. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  28. package/dist/lib/crud/optimistic-lock.js +172 -0
  29. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  30. package/dist/lib/data/engine.js +2 -2
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/di/container.js +18 -2
  33. package/dist/lib/di/container.js.map +2 -2
  34. package/dist/lib/encryption/aes.js +37 -3
  35. package/dist/lib/encryption/aes.js.map +2 -2
  36. package/dist/lib/encryption/kms.js +57 -23
  37. package/dist/lib/encryption/kms.js.map +2 -2
  38. package/dist/lib/encryption/subscriber.js +41 -8
  39. package/dist/lib/encryption/subscriber.js.map +2 -2
  40. package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
  41. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  42. package/dist/lib/i18n/context.js +5 -0
  43. package/dist/lib/i18n/context.js.map +2 -2
  44. package/dist/lib/query/engine.js +41 -31
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/version.js +1 -1
  47. package/dist/lib/version.js.map +1 -1
  48. package/dist/modules/integrations/types.js.map +2 -2
  49. package/dist/modules/search.js.map +2 -2
  50. package/package.json +8 -9
  51. package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
  52. package/src/lib/auth/apiKeyAuthCache.ts +20 -6
  53. package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
  54. package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
  55. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
  56. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  57. package/src/lib/commands/__tests__/redo.test.ts +265 -0
  58. package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
  59. package/src/lib/commands/__tests__/scope.test.ts +48 -0
  60. package/src/lib/commands/command-bus.ts +62 -44
  61. package/src/lib/commands/flush.ts +79 -2
  62. package/src/lib/commands/index.ts +9 -0
  63. package/src/lib/commands/redo.ts +235 -0
  64. package/src/lib/commands/runCrudCommandWrite.ts +82 -0
  65. package/src/lib/commands/scope.ts +70 -55
  66. package/src/lib/commands/types.ts +54 -1
  67. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  68. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  69. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  70. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  71. package/src/lib/crud/errors.ts +29 -0
  72. package/src/lib/crud/factory.ts +23 -0
  73. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  74. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  75. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  76. package/src/lib/crud/optimistic-lock.ts +379 -0
  77. package/src/lib/data/engine.ts +11 -8
  78. package/src/lib/di/container.ts +17 -1
  79. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
  80. package/src/lib/encryption/__tests__/kms.test.ts +44 -6
  81. package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
  82. package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
  83. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
  84. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
  85. package/src/lib/encryption/aes.ts +78 -2
  86. package/src/lib/encryption/kms.ts +76 -24
  87. package/src/lib/encryption/subscriber.ts +54 -9
  88. package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
  89. package/src/lib/i18n/context.tsx +11 -0
  90. package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
  91. package/src/lib/query/engine.ts +59 -30
  92. package/src/modules/integrations/types.ts +14 -0
  93. package/src/modules/notifications/handler.ts +7 -0
  94. package/src/modules/search.ts +9 -0
  95. package/src/modules/vector.ts +7 -0
@@ -22,6 +22,7 @@ export interface KmsService {
22
22
  getTenantDek(tenantId: string): Promise<TenantDek | null>
23
23
  createTenantDek(tenantId: string): Promise<TenantDek | null>
24
24
  isHealthy(): boolean
25
+ invalidateDek?(tenantId: string): void
25
26
  }
26
27
 
27
28
  class FallbackKmsService implements KmsService {
@@ -76,6 +77,11 @@ class FallbackKmsService implements KmsService {
76
77
  }
77
78
  return null
78
79
  }
80
+
81
+ invalidateDek(tenantId: string): void {
82
+ this.primary.invalidateDek?.(tenantId)
83
+ this.fallback?.invalidateDek?.(tenantId)
84
+ }
79
85
  }
80
86
 
81
87
  type VaultClientOpts = {
@@ -90,6 +96,10 @@ type VaultReadResponse = {
90
96
  data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }
91
97
  }
92
98
 
99
+ // 'conflict' = a check-and-set write lost to a concurrent writer (normal race
100
+ // outcome, Vault still healthy); 'error' = the write genuinely failed.
101
+ type VaultWriteOutcome = 'ok' | 'conflict' | 'error'
102
+
93
103
  function normalizeEnv(value: string | undefined): string {
94
104
  if (!value) return ''
95
105
  return value.trim().replace(/(?:^['"]|['"]$)/g, '')
@@ -230,11 +240,13 @@ export class HashicorpVaultKmsService implements KmsService {
230
240
  }
231
241
  }
232
242
 
233
- private async writeVault(path: string, key: string): Promise<boolean> {
243
+ private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {
234
244
  if (!this.vaultAddr || !this.vaultToken) {
235
245
  this.healthy = false
236
- return false
246
+ return 'error'
237
247
  }
248
+ const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }
249
+ if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }
238
250
  try {
239
251
  const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {
240
252
  method: 'POST',
@@ -242,14 +254,23 @@ export class HashicorpVaultKmsService implements KmsService {
242
254
  'X-Vault-Token': this.vaultToken,
243
255
  'Content-Type': 'application/json',
244
256
  },
245
- body: JSON.stringify({ data: { key } }),
257
+ body: JSON.stringify(body),
246
258
  timeoutMs: this.requestTimeoutMs,
247
259
  })
248
- this.healthy = res.ok
249
- if (!res.ok) {
250
- console.warn('⚠️ [encryption][kms] Vault write failed', { path, status: res.status })
260
+ if (res.ok) {
261
+ this.healthy = true
262
+ return 'ok'
251
263
  }
252
- return res.ok
264
+ // KV v2 returns 400 when a check-and-set write loses to a concurrent
265
+ // writer (path already at a newer version). That is a normal race outcome,
266
+ // not an unhealthy Vault — don't flip `healthy`.
267
+ if (typeof opts?.cas === 'number' && res.status === 400) {
268
+ console.warn('⚠️ [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })
269
+ return 'conflict'
270
+ }
271
+ this.healthy = false
272
+ console.warn('⚠️ [encryption][kms] Vault write failed', { path, status: res.status })
273
+ return 'error'
253
274
  } catch (err) {
254
275
  this.healthy = false
255
276
  console.warn('⚠️ [encryption][kms] Vault write error', {
@@ -257,7 +278,7 @@ export class HashicorpVaultKmsService implements KmsService {
257
278
  error: (err as Error)?.message || String(err),
258
279
  timeoutMs: this.requestTimeoutMs,
259
280
  })
260
- return false
281
+ return 'error'
261
282
  }
262
283
  }
263
284
 
@@ -287,21 +308,60 @@ export class HashicorpVaultKmsService implements KmsService {
287
308
  }
288
309
 
289
310
  async createTenantDek(tenantId: string): Promise<TenantDek | null> {
290
- const key = generateDek()
291
311
  const path = this.buildKeyPath(tenantId)
292
- const ok = await this.writeVault(path, key)
293
- if (ok) {
312
+ // Read-before-write: if a DEK already exists for this tenant (another request
313
+ // or process created it first), adopt it instead of overwriting the active
314
+ // key — overwriting orphans every row already encrypted under it (#2746).
315
+ const existing = await this.readVault(path)
316
+ const existingKey = existing?.data?.data?.key
317
+ if (existingKey) {
318
+ return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })
319
+ }
320
+ // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new
321
+ // key over a possibly-existing one we just couldn't read — let the caller fall back.
322
+ if (!this.healthy) return null
323
+ const key = generateDek()
324
+ const outcome = await this.writeVault(path, key, { cas: 0 })
325
+ if (outcome === 'ok') {
294
326
  console.info('🔑 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })
295
- } else {
296
- console.warn('⚠️ [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })
327
+ return this.remember({ tenantId, key, fetchedAt: this.now() })
328
+ }
329
+ if (outcome === 'conflict') {
330
+ // A concurrent create won the CAS race — adopt the winner's key so both
331
+ // callers encrypt under the same DEK.
332
+ const winner = await this.readVault(path)
333
+ const winnerKey = winner?.data?.data?.key
334
+ if (winnerKey) {
335
+ console.info('🔑 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })
336
+ return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })
337
+ }
297
338
  }
298
- if (!ok) return null
299
- return this.remember({ tenantId, key, fetchedAt: this.now() })
339
+ console.warn('⚠️ [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })
340
+ return null
341
+ }
342
+
343
+ invalidateDek(tenantId: string): void {
344
+ this.cache.delete(tenantId)
300
345
  }
301
346
  }
302
347
 
303
348
  let loggedDerivedKeyFallbackBanner = false
304
349
 
350
+ function fingerprintSecret(secret: string): string {
351
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
352
+ }
353
+
354
+ export function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {
355
+ const sourceLine =
356
+ opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
357
+ return [
358
+ '🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
359
+ sourceLine,
360
+ `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,
361
+ 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
362
+ ]
363
+ }
364
+
305
365
  function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
306
366
  if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return
307
367
  loggedDerivedKeyFallbackBanner = true
@@ -310,15 +370,7 @@ function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
310
370
  const reset = '\x1b[0m'
311
371
  const width = 110
312
372
  const border = `${redBg}${white}${'━'.repeat(width)}${reset}`
313
- const isProduction = process.env.NODE_ENV === 'production'
314
- const sourceLine =
315
- opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'
316
- const body = [
317
- '🚨 Using derived tenant encryption keys (Vault unavailable / no DEK)',
318
- sourceLine,
319
- isProduction ? 'Secret: [redacted in production]' : `Secret: ${opts.secret}`,
320
- 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',
321
- ]
373
+ const body = buildDerivedKeyFallbackBannerLines(opts)
322
374
  console.warn(border)
323
375
  for (const line of body) {
324
376
  const padded = line.padEnd(width - 2, ' ')
@@ -139,6 +139,43 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
139
139
  helper.__touched = false
140
140
  }
141
141
 
142
+ /**
143
+ * Reports whether a managed entity currently carries un-flushed changes relative to its load
144
+ * baseline, using MikroORM's own comparator so the verdict matches the change-set computer that
145
+ * runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a
146
+ * decrypt pass traverses back into an entity a command already mutated.
147
+ */
148
+ private hasPendingChanges(
149
+ target: Record<string, unknown>,
150
+ meta: EntityMetadata<any> | undefined,
151
+ em?: { getComparator?: () => any },
152
+ ): boolean {
153
+ const helper = (target as any)?.__helper
154
+ if (!helper || typeof helper !== 'object') return false
155
+ const original = helper.__originalEntityData
156
+ if (!original) return false
157
+ const entityName = meta?.className || meta?.name
158
+ try {
159
+ const comparator = em?.getComparator?.()
160
+ if (entityName && comparator?.prepareEntity) {
161
+ const current = comparator.prepareEntity(target)
162
+ if (typeof comparator.matching === 'function') {
163
+ return !comparator.matching(entityName, original, current)
164
+ }
165
+ if (typeof comparator.diffEntities === 'function') {
166
+ const diff = comparator.diffEntities(entityName, original, current)
167
+ return !!diff && Object.keys(diff).length > 0
168
+ }
169
+ }
170
+ } catch (err) {
171
+ debug('⚪️ subscriber.pending_changes.compare_failed', {
172
+ entity: entityName,
173
+ message: (err as Error)?.message ?? String(err),
174
+ })
175
+ }
176
+ return helper.__touched === true
177
+ }
178
+
142
179
  private async encrypt(
143
180
  target: Record<string, unknown>,
144
181
  meta: EntityMetadata<any> | undefined,
@@ -264,9 +301,14 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
264
301
  debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
265
302
  return
266
303
  }
304
+ // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a
305
+ // managed entity that a command already mutated would clear its dirty changeset and silently
306
+ // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
307
+ // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).
308
+ const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false
267
309
  const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)
268
310
  Object.assign(target, decrypted)
269
- if (syncOriginal) {
311
+ if (syncOriginal && !hadPendingChanges) {
270
312
  this.syncOriginalEntityData(target, resolvedMeta, em as any)
271
313
  }
272
314
  const nextFallback =
@@ -278,6 +320,17 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
278
320
  try {
279
321
  const extractEntities = (value: any): any[] => {
280
322
  if (!value) return []
323
+ // MikroORM Collection wrapper — MUST be checked before the Reference branch: both wrappers
324
+ // expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference
325
+ // branch first returned the Collection wrapper itself instead of its items, so collection
326
+ // relations were never deep-decrypted and leaked ciphertext (issue #2744).
327
+ if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
328
+ try {
329
+ return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
330
+ } catch {
331
+ return []
332
+ }
333
+ }
281
334
  // MikroORM Reference wrapper
282
335
  if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {
283
336
  try {
@@ -290,14 +343,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
290
343
  }
291
344
  return []
292
345
  }
293
- // Collection wrapper
294
- if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
295
- try {
296
- return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
297
- } catch {
298
- return []
299
- }
300
- }
301
346
  if (Array.isArray(value)) return value
302
347
  if (typeof value === 'object') return [value]
303
348
  return []
@@ -22,6 +22,10 @@ type MapCacheKey = {
22
22
  }
23
23
 
24
24
  const MAP_MISS_TTL_MS = 5 * 60 * 1000
25
+ // Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked
26
+ // up by long-lived processes without a restart (#2746). The service-level cache
27
+ // previously had no TTL and shadowed the KMS's own 15-minute expiry.
28
+ const DEK_CACHE_TTL_MS = 15 * 60 * 1000
25
29
 
26
30
  function cacheKey(key: MapCacheKey): string {
27
31
  return [
@@ -86,21 +90,33 @@ export function parseDecryptedFieldValue(decrypted: string): unknown {
86
90
  }
87
91
  }
88
92
 
89
- function isEncryptedPayload(value: unknown): boolean {
93
+ /**
94
+ * A value is only treated as "already encrypted" when it actually decrypts
95
+ * under the tenant DEK — i.e. the AES-GCM authentication tag verifies. A purely
96
+ * structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled
97
+ * field values (e.g. their own profile email/phone) could impersonate ciphertext
98
+ * to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding
99
+ * the check to a successful authenticated decrypt makes forgery infeasible, so a
100
+ * fake payload simply gets encrypted like any other plaintext.
101
+ */
102
+ function isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {
90
103
  if (typeof value !== 'string') return false
91
104
  const parts = value.split(':')
92
- return parts.length === 4 && parts[3] === 'v1'
105
+ if (parts.length !== 4 || parts[3] !== 'v1') return false
106
+ return decryptWithAesGcm(value, dek.key) !== null
93
107
  }
94
108
 
95
109
  export class TenantDataEncryptionService {
96
110
  private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
97
111
  private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
98
112
  private static globalDekCache = new Map<string, TenantDek>()
113
+ private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
99
114
  private static globalMissCache = new Map<string, number>()
100
115
  private readonly kms: KmsService
101
116
  private readonly cache?: CacheStrategy
102
117
  private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
103
118
  private readonly dekCache = TenantDataEncryptionService.globalDekCache
119
+ private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
104
120
  private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
105
121
  private readonly missCache = TenantDataEncryptionService.globalMissCache
106
122
 
@@ -116,10 +132,15 @@ export class TenantDataEncryptionService {
116
132
  return isTenantDataEncryptionEnabled() && this.kms.isHealthy()
117
133
  }
118
134
 
135
+ private isDekExpired(dek: TenantDek): boolean {
136
+ return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS
137
+ }
138
+
119
139
  async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {
120
140
  if (!tenantId) return null
121
141
  const cached = this.dekCache.get(tenantId)
122
- if (cached) return cached
142
+ if (cached && !this.isDekExpired(cached)) return cached
143
+ if (cached) this.dekCache.delete(tenantId)
123
144
  const dek = await this.kms.getTenantDek(tenantId)
124
145
  if (!dek) {
125
146
  debug('🔎 dek.miss', { tenantId })
@@ -134,9 +155,22 @@ export class TenantDataEncryptionService {
134
155
  const existing = await this.getDek(tenantId)
135
156
  if (existing || !tenantId) return existing ?? null
136
157
  if (typeof this.kms.createTenantDek !== 'function') return existing ?? null
137
- const created = await this.kms.createTenantDek(tenantId)
138
- if (created) this.dekCache.set(tenantId, created)
139
- return created ?? null
158
+ // Dedupe concurrent first-time creation within this process so two callers
159
+ // can't each generate a distinct DEK and overwrite one another (#2746).
160
+ // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).
161
+ const pending = this.inflightDeks.get(tenantId)
162
+ if (pending) return pending
163
+ const creation = (async () => {
164
+ const created = await this.kms.createTenantDek(tenantId)
165
+ if (created) this.dekCache.set(tenantId, created)
166
+ return created ?? null
167
+ })()
168
+ this.inflightDeks.set(tenantId, creation)
169
+ try {
170
+ return await creation
171
+ } finally {
172
+ this.inflightDeks.delete(tenantId)
173
+ }
140
174
  }
141
175
 
142
176
  async createDek(tenantId: string): Promise<TenantDek | null> {
@@ -236,6 +270,15 @@ export class TenantDataEncryptionService {
236
270
  }
237
271
  }
238
272
 
273
+ // Force a flush of a tenant's cached DEK across the service-level cache and the
274
+ // underlying KMS cache so an operator can pick up a rotated/revoked key without
275
+ // a process restart (#2746).
276
+ invalidateDek(tenantId: string): void {
277
+ this.dekCache.delete(tenantId)
278
+ this.inflightDeks.delete(tenantId)
279
+ this.kms.invalidateDek?.(tenantId)
280
+ }
281
+
239
282
  async getEncryptedFieldNames(
240
283
  entityId: string,
241
284
  tenantId: string | null | undefined,
@@ -260,8 +303,10 @@ export class TenantDataEncryptionService {
260
303
  if (!key) continue
261
304
  const value = clone[key]
262
305
  if (value === null || value === undefined) continue
263
- // Avoid double-encrypting already encrypted payloads
264
- if (isEncryptedPayload(value)) continue
306
+ // Avoid double-encrypting payloads that genuinely decrypt under this DEK.
307
+ // A forged ciphertext-shaped string fails this check and is encrypted as
308
+ // plaintext, closing the encryption-at-rest bypass (issue #2720).
309
+ if (isEncryptedWithDek(value, dek)) continue
265
310
  const serialized = typeof value === 'string' ? value : JSON.stringify(value)
266
311
  const payload = encryptWithAesGcm(serialized, dek.key)
267
312
  clone[key] = payload.value
@@ -73,6 +73,17 @@ export function useT() {
73
73
  return ctx.t
74
74
  }
75
75
 
76
+ /**
77
+ * Like `useT`, but returns `undefined` instead of throwing when no
78
+ * `I18nProvider` is in scope. Use where a translator is desirable but not
79
+ * guaranteed (e.g. plumbing `t` into side-effect handlers that may run before
80
+ * the provider mounts) — callers MUST provide a fallback.
81
+ */
82
+ export function useOptionalT(): TranslateFn | undefined {
83
+ const ctx = useContext(I18nContext)
84
+ return ctx?.t
85
+ }
86
+
76
87
  export function useLocale() {
77
88
  const ctx = useContext(I18nContext)
78
89
  if (!ctx) throw new Error('useLocale must be used within I18nProvider')
@@ -0,0 +1,83 @@
1
+ import {
2
+ resolveRegisteredEntityTableName,
3
+ resolveEntityTableName,
4
+ isValidEntityIdShape,
5
+ ENTITY_ID_PATTERN,
6
+ } from '../engine'
7
+
8
+ function makeEm(metaByClass: Record<string, string>) {
9
+ const all = Object.entries(metaByClass).map(([className, tableName]) => ({ className, tableName }))
10
+ return {
11
+ getMetadata: () => ({
12
+ find: (className: string) => {
13
+ const tableName = metaByClass[className]
14
+ return tableName ? { tableName } : undefined
15
+ },
16
+ getAll: () => all,
17
+ }),
18
+ } as any
19
+ }
20
+
21
+ describe('resolveRegisteredEntityTableName', () => {
22
+ it('resolves a registered entity via class-name metadata', () => {
23
+ const em = makeEm({ Todo: 'todos' })
24
+ expect(resolveRegisteredEntityTableName(em, 'example:todo')).toBe('todos')
25
+ })
26
+
27
+ it('resolves a registered entity via the secondary table-name lookup', () => {
28
+ const em = makeEm({ SomeOtherClass: 'directory_organizations' })
29
+ expect(resolveRegisteredEntityTableName(em, 'directory:organization')).toBe('directory_organizations')
30
+ })
31
+
32
+ it('returns null for an entity type that does not map to any registered metadata', () => {
33
+ const em = makeEm({ Todo: 'todos' })
34
+ expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
35
+ expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
36
+ })
37
+
38
+ it('returns null when no metadata is available', () => {
39
+ expect(resolveRegisteredEntityTableName(undefined, 'example:todo')).toBeNull()
40
+ expect(resolveRegisteredEntityTableName({} as any, 'example:todo')).toBeNull()
41
+ })
42
+
43
+ it('never pluralizes attacker-chosen ids into a real table name', () => {
44
+ const em = makeEm({})
45
+ expect(resolveRegisteredEntityTableName(em, 'foo:auth_user')).toBeNull()
46
+ expect(resolveRegisteredEntityTableName(em, 'foo:user')).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('resolveEntityTableName fallback (broad query path)', () => {
51
+ it('still falls back to a pluralized guess for unregistered ids', () => {
52
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
53
+ try {
54
+ const em = makeEm({})
55
+ expect(resolveEntityTableName(em, 'foo:never_registered_widget')).toBe('never_registered_widgets')
56
+ } finally {
57
+ warnSpy.mockRestore()
58
+ }
59
+ })
60
+ })
61
+
62
+ describe('isValidEntityIdShape', () => {
63
+ it('accepts canonical module:entity ids', () => {
64
+ expect(isValidEntityIdShape('example:todo')).toBe(true)
65
+ expect(isValidEntityIdShape('directory:organization')).toBe(true)
66
+ expect(isValidEntityIdShape('query_index:search_token')).toBe(true)
67
+ })
68
+
69
+ it('rejects ids without exactly two snake_case segments', () => {
70
+ expect(isValidEntityIdShape('todos')).toBe(false)
71
+ expect(isValidEntityIdShape('auth_users')).toBe(false)
72
+ expect(isValidEntityIdShape('foo:bar:baz')).toBe(false)
73
+ expect(isValidEntityIdShape('Foo:Bar')).toBe(false)
74
+ expect(isValidEntityIdShape('1bad:entity')).toBe(false)
75
+ expect(isValidEntityIdShape('foo:')).toBe(false)
76
+ expect(isValidEntityIdShape(':bar')).toBe(false)
77
+ expect(isValidEntityIdShape('foo bar:baz')).toBe(false)
78
+ })
79
+
80
+ it('exposes the pattern for schema reuse', () => {
81
+ expect(ENTITY_ID_PATTERN.test('example:todo')).toBe(true)
82
+ })
83
+ })
@@ -42,6 +42,15 @@ type ResolvedCustomFieldSource = {
42
42
 
43
43
  type ResultRow = Record<string, unknown>
44
44
 
45
+ /**
46
+ * Canonical `module:entity` entity-id shape (both segments snake_case,
47
+ * starting with a lowercase letter). Used to validate caller-supplied entity
48
+ * ids at security-sensitive boundaries before they reach table resolution.
49
+ */
50
+ export const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/
51
+
52
+ export const isValidEntityIdShape = (value: string): boolean => ENTITY_ID_PATTERN.test(value)
53
+
45
54
  const pluralizeBaseName = (name: string): string => {
46
55
  if (!name) return name
47
56
  if (name.endsWith('s')) return name
@@ -65,46 +74,66 @@ const candidateClassNames = (rawName: string): string[] => {
65
74
  return Array.from(candidates)
66
75
  }
67
76
 
68
- export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
69
- if (entityTableCache.has(entity)) {
70
- return entityTableCache.get(entity)!
71
- }
77
+ /**
78
+ * Resolve an entity id to a table name strictly via registered MikroORM metadata.
79
+ *
80
+ * Unlike {@link resolveEntityTableName}, this never falls back to a pluralized
81
+ * guess: it returns `null` when no registered entity matches. Use it for
82
+ * security-sensitive call sites (e.g. the reindexer) that must refuse to read
83
+ * arbitrary, attacker-chosen tables that happen to exist in the schema.
84
+ */
85
+ export function resolveRegisteredEntityTableName(
86
+ em: EntityManager | undefined,
87
+ entity: EntityId,
88
+ ): string | null {
72
89
  const parts = String(entity || '').split(':')
73
90
  const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
74
91
  const metadata = (em as any)?.getMetadata?.()
75
92
 
76
- if (metadata && rawName) {
77
- const candidates = candidateClassNames(rawName)
78
- for (const candidate of candidates) {
79
- try {
80
- const meta = metadata.find?.(candidate)
81
- if (meta?.tableName) {
82
- const tableName = String(meta.tableName)
83
- entityTableCache.set(entity, tableName)
84
- return tableName
85
- }
86
- } catch {}
87
- }
93
+ if (!metadata || !rawName) return null
88
94
 
89
- // Secondary lookup: search ORM metadata by candidate table names
90
- const modulePrefix = parts[0] ?? ''
91
- const candidateTables = [
92
- `${modulePrefix}_${rawName}`,
93
- pluralizeBaseName(rawName),
94
- `${modulePrefix}_${pluralizeBaseName(rawName)}`,
95
- ]
95
+ const candidates = candidateClassNames(rawName)
96
+ for (const candidate of candidates) {
96
97
  try {
97
- const allMeta: any[] = metadata.getAll?.() ?? []
98
- for (const meta of allMeta) {
99
- if (meta?.tableName && candidateTables.includes(String(meta.tableName))) {
100
- const tableName = String(meta.tableName)
101
- entityTableCache.set(entity, tableName)
102
- return tableName
103
- }
98
+ const meta = metadata.find?.(candidate)
99
+ if (meta?.tableName) {
100
+ return String(meta.tableName)
104
101
  }
105
102
  } catch {}
106
103
  }
107
104
 
105
+ // Secondary lookup: search ORM metadata by candidate table names
106
+ const modulePrefix = parts[0] ?? ''
107
+ const candidateTables = [
108
+ `${modulePrefix}_${rawName}`,
109
+ pluralizeBaseName(rawName),
110
+ `${modulePrefix}_${pluralizeBaseName(rawName)}`,
111
+ ]
112
+ try {
113
+ const allMeta: any[] = metadata.getAll?.() ?? []
114
+ for (const meta of allMeta) {
115
+ if (meta?.tableName && candidateTables.includes(String(meta.tableName))) {
116
+ return String(meta.tableName)
117
+ }
118
+ }
119
+ } catch {}
120
+
121
+ return null
122
+ }
123
+
124
+ export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
125
+ if (entityTableCache.has(entity)) {
126
+ return entityTableCache.get(entity)!
127
+ }
128
+ const parts = String(entity || '').split(':')
129
+ const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
130
+
131
+ const registered = resolveRegisteredEntityTableName(em, entity)
132
+ if (registered) {
133
+ entityTableCache.set(entity, registered)
134
+ return registered
135
+ }
136
+
108
137
  const fallback = pluralizeBaseName(rawName || '')
109
138
  console.warn(
110
139
  `[QueryEngine] Could not resolve entity "${entity}" via ORM metadata. ` +
@@ -1,6 +1,20 @@
1
1
  export type IntegrationScope = {
2
2
  organizationId: string
3
3
  tenantId: string
4
+ /**
5
+ * Optional per-user secret scoping. When set, credential lookups and writes
6
+ * are scoped to a single user — used by per-user channels (Gmail, IMAP)
7
+ * so two users on the same tenant don't share one row.
8
+ *
9
+ * When `undefined`/`null`, behaviour is unchanged — tenant-wide credentials
10
+ * (e.g. shared Stripe/Akeneo API keys) keep working exactly as before.
11
+ *
12
+ * Stored on `IntegrationCredentials.user_id` (added 2026-05-26 by the email
13
+ * integration spec). Backed by the partial unique index
14
+ * `integration_credentials_user_lookup_idx` across integration, organization,
15
+ * tenant, and user where `user_id IS NOT NULL AND deleted_at IS NULL`.
16
+ */
17
+ userId?: string | null
4
18
  }
5
19
 
6
20
  export type IntegrationCategory =
@@ -30,6 +30,13 @@ export type NotificationHandlerContext = {
30
30
  userId?: string
31
31
  features: string[]
32
32
  currentPath: string
33
+ /**
34
+ * Optional translator, present when the dispatch happens inside a React tree
35
+ * with an active `I18nProvider` (the poll/SSE notification hooks). Handlers
36
+ * that emit user-facing copy (e.g. a toast action label) MUST localize via
37
+ * this when available and fall back to an English literal only when absent.
38
+ */
39
+ t?: (key: string, fallback?: string) => string
33
40
  toast: (options: NotificationHandlerToastOptions) => void
34
41
  popup: (options: NotificationHandlerPopupOptions) => void
35
42
  emitEvent: (eventName: string, detail?: unknown) => void
@@ -279,6 +279,15 @@ export type SearchEntityConfig = {
279
279
  resolveLinks?: (ctx: SearchBuildContext) => Promise<SearchResultLink[] | null> | SearchResultLink[] | null
280
280
  /** Define which fields are searchable vs hash-only */
281
281
  fieldPolicy?: SearchFieldPolicy
282
+ /**
283
+ * Per-entity view feature(s) required to read this entity's records through
284
+ * data-returning surfaces (e.g. the `search_get` / `search_aggregate` AI tools).
285
+ * These tools must NOT rely on the search-administration `search.view` feature
286
+ * to gate record reads — callers must additionally hold the owning module's
287
+ * `<entity>.view` feature(s) declared here. When omitted, those tools fail
288
+ * closed (deny) so an entity is never exposed without an explicit grant.
289
+ */
290
+ aclFeatures?: string[]
282
291
  }
283
292
 
284
293
  /**
@@ -1,5 +1,6 @@
1
1
  import type { EntityId } from './entities'
2
2
  import type { QueryEngine } from '../lib/query/types'
3
+ import type { SearchFieldPolicy } from './search'
3
4
 
4
5
  export type VectorDriverId = 'pgvector' | 'qdrant' | 'chromadb'
5
6
 
@@ -71,6 +72,12 @@ export type VectorEntityConfig = {
71
72
  * Provide extra deep links rendered next to the search result.
72
73
  */
73
74
  resolveLinks?: (ctx: VectorBuildContext) => Promise<VectorLinkDescriptor[] | null> | VectorLinkDescriptor[] | null
75
+ /**
76
+ * Controls which record/custom fields are eligible for the default embedding source builder.
77
+ * Applied only when `buildSource` is not provided. `excluded`/`hashOnly` fields are never embedded;
78
+ * when `searchable` is defined it acts as an allowlist for both record and custom fields.
79
+ */
80
+ fieldPolicy?: SearchFieldPolicy
74
81
  }
75
82
 
76
83
  export type VectorModuleConfig = {