@open-mercato/shared 0.8.1-develop.7206.1.45a3cee87f → 0.8.1-develop.7215.1.60786cf836

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.
@@ -34,6 +34,10 @@ const MAP_MISS_TTL_MS = 5 * 60 * 1000
34
34
  // up by long-lived processes without a restart (#2746). The service-level cache
35
35
  // previously had no TTL and shadowed the KMS's own 15-minute expiry.
36
36
  const DEK_CACHE_TTL_MS = 15 * 60 * 1000
37
+ // Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations
38
+ // aggregate, so a process that only ever hits its own memory entry (no shared cache configured,
39
+ // or already warm) still picks up a runtime map edit within the same bound (#6066).
40
+ const AGGREGATE_CACHE_TTL_MS = 300 * 1000
37
41
 
38
42
  function cacheKey(key: MapCacheKey): string {
39
43
  return [
@@ -44,6 +48,13 @@ function cacheKey(key: MapCacheKey): string {
44
48
  ].join(':')
45
49
  }
46
50
 
51
+ // Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs
52
+ // from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a
53
+ // tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).
54
+ function allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {
55
+ return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')
56
+ }
57
+
47
58
  function debug(event: string, payload: Record<string, unknown>) {
48
59
  if (!isEncryptionDebugEnabled()) return
49
60
  try {
@@ -113,11 +124,45 @@ function isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {
113
124
  return decryptWithAesGcm(value, dek.key) !== null
114
125
  }
115
126
 
116
- function normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {
127
+ function normalizeEncryptedFieldRules(
128
+ fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,
129
+ ): EncryptedFieldRule[] {
117
130
  if (!Array.isArray(fields)) return []
118
- return fields
119
- .map((rule) => rule.field)
120
- .filter((field): field is string => typeof field === 'string' && field.trim().length > 0)
131
+ const rules: EncryptedFieldRule[] = []
132
+ for (const rule of fields) {
133
+ if (!rule || typeof rule !== 'object') continue
134
+ const field = rule.field
135
+ if (typeof field !== 'string' || field.trim().length === 0) continue
136
+ rules.push({ field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null })
137
+ }
138
+ return rules
139
+ }
140
+
141
+ function normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {
142
+ return normalizeEncryptedFieldRules(fields).map((rule) => rule.field)
143
+ }
144
+
145
+ /**
146
+ * Union of field rules, first declaration wins on the field name. A later duplicate only
147
+ * contributes its `hashField` when the winning rule declares none, so merging an organization-scoped
148
+ * map into a tenant-wide one never silently retargets an existing lookup-hash column.
149
+ */
150
+ function mergeEncryptedFieldRules(groups: readonly EncryptedFieldRule[][]): EncryptedFieldRule[] {
151
+ const merged: EncryptedFieldRule[] = []
152
+ const byField = new Map<string, EncryptedFieldRule>()
153
+ for (const group of groups) {
154
+ for (const rule of group) {
155
+ const existing = byField.get(rule.field)
156
+ if (!existing) {
157
+ const copy: EncryptedFieldRule = { field: rule.field, hashField: rule.hashField ?? null }
158
+ byField.set(rule.field, copy)
159
+ merged.push(copy)
160
+ continue
161
+ }
162
+ if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField
163
+ }
164
+ }
165
+ return merged
121
166
  }
122
167
 
123
168
  function readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {
@@ -160,6 +205,7 @@ function getSqlConnection(em: EntityManager): SqlConnection | null {
160
205
 
161
206
  export class TenantDataEncryptionService {
162
207
  private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
208
+ private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()
163
209
  private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
164
210
  private static globalDekCache = new Map<string, TenantDek>()
165
211
  private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()
@@ -167,6 +213,7 @@ export class TenantDataEncryptionService {
167
213
  private readonly kms: KmsService
168
214
  private readonly cache?: CacheStrategy
169
215
  private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
216
+ private readonly aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache
170
217
  private readonly dekCache = TenantDataEncryptionService.globalDekCache
171
218
  private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
172
219
  private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
@@ -352,7 +399,10 @@ export class TenantDataEncryptionService {
352
399
  return this.applySystemDefault(null, key.entityId)
353
400
  }
354
401
 
355
- private async fetchAllOrganizationFieldNames(entityId: string, tenantId: string | null): Promise<string[]> {
402
+ private async fetchAllOrganizationFieldRules(
403
+ entityId: string,
404
+ tenantId: string | null,
405
+ ): Promise<EncryptedFieldRule[]> {
356
406
  const conn = getSqlConnection(this.em)
357
407
  if (!conn) return []
358
408
  const sql = `
@@ -366,23 +416,97 @@ export class TenantDataEncryptionService {
366
416
  `
367
417
  const rows = await conn.execute(sql, [entityId, tenantId])
368
418
  if (!Array.isArray(rows) || rows.length === 0) return []
369
- const names = new Set<string>()
419
+ const groups: EncryptedFieldRule[][] = []
370
420
  for (const row of rows) {
371
421
  if (!row || typeof row !== 'object') continue
372
- for (const field of normalizeEncryptedFieldNames(readEncryptedFieldsJson(row as Record<string, unknown>))) {
373
- names.add(field)
422
+ groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))
423
+ }
424
+ return mergeEncryptedFieldRules(groups)
425
+ }
426
+
427
+ /**
428
+ * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`
429
+ * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached
430
+ * read this used to be would add a round-trip per flush and per load (#5949).
431
+ */
432
+ private async getAllOrganizationFieldRules(
433
+ entityId: string,
434
+ tenantId: string | null,
435
+ ): Promise<EncryptedFieldRule[]> {
436
+ const tag = allOrganizationsCacheKey(entityId, tenantId)
437
+ const missExpiresAt = this.missCache.get(tag)
438
+ if (missExpiresAt) {
439
+ if (missExpiresAt > Date.now()) return []
440
+ this.missCache.delete(tag)
441
+ }
442
+ const mem = this.aggregateMemoryCache.get(tag)
443
+ if (mem) {
444
+ if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields
445
+ this.aggregateMemoryCache.delete(tag)
446
+ }
447
+ if (this.cache && typeof this.cache.get === 'function') {
448
+ const cached = await this.cache.get(tag)
449
+ if (cached) {
450
+ const record = cached as EncryptionMapRecord
451
+ this.aggregateMemoryCache.set(tag, { at: Date.now(), record })
452
+ return record.fields
374
453
  }
375
454
  }
376
- return Array.from(names)
455
+ const inflight = this.inflightMaps.get(tag)
456
+ if (inflight) return (await inflight)?.fields ?? []
457
+ const pending = (async (): Promise<EncryptionMapRecord | null> => {
458
+ const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)
459
+ return fields.length ? { entityId, fields } : null
460
+ })()
461
+ this.inflightMaps.set(tag, pending)
462
+ let loaded: EncryptionMapRecord | null
463
+ try {
464
+ loaded = await pending
465
+ } finally {
466
+ this.inflightMaps.delete(tag)
467
+ }
468
+ if (!loaded) {
469
+ this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
470
+ return []
471
+ }
472
+ this.missCache.delete(tag)
473
+ this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })
474
+ if (this.cache && typeof this.cache.set === 'function') {
475
+ await this.cache.set(tag, loaded, { ttl: 300 })
476
+ }
477
+ return loaded.fields
478
+ }
479
+
480
+ /**
481
+ * The field rules that are actually encrypted at rest for a scope.
482
+ *
483
+ * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but
484
+ * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading
485
+ * or writing such a row with the base map alone stores those fields as plaintext and hands
486
+ * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in
487
+ * (#5949) — the same set `getEncryptedFieldNames` has reported since #2282.
488
+ */
489
+ private async resolveFieldRulesForScope(
490
+ entityId: string,
491
+ tenantId: string | null,
492
+ organizationId: string | null,
493
+ map: EncryptionMapRecord | null,
494
+ ): Promise<EncryptedFieldRule[]> {
495
+ const mapRules = normalizeEncryptedFieldRules(map?.fields)
496
+ if (organizationId != null) return mapRules
497
+ return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])
377
498
  }
378
499
 
379
500
  async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {
380
- const tag = cacheKey({ entityId, tenantId, organizationId })
381
- this.memoryCache.delete(tag)
382
- this.inflightMaps.delete(tag)
383
- this.missCache.delete(tag)
384
- if (this.cache && typeof (this.cache as any).delete === 'function') {
385
- await (this.cache as any).delete(tag)
501
+ const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]
502
+ for (const tag of tags) {
503
+ this.memoryCache.delete(tag)
504
+ this.aggregateMemoryCache.delete(tag)
505
+ this.inflightMaps.delete(tag)
506
+ this.missCache.delete(tag)
507
+ if (this.cache && typeof (this.cache as any).delete === 'function') {
508
+ await (this.cache as any).delete(tag)
509
+ }
386
510
  }
387
511
  }
388
512
 
@@ -416,13 +540,13 @@ export class TenantDataEncryptionService {
416
540
  return []
417
541
  }
418
542
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
419
- const fields = new Set(normalizeEncryptedFieldNames(map?.fields))
420
- if (organizationId == null) {
421
- for (const field of await this.fetchAllOrganizationFieldNames(entityId, tenantId ?? null)) {
422
- fields.add(field)
423
- }
424
- }
425
- return Array.from(fields)
543
+ const fields = await this.resolveFieldRulesForScope(
544
+ entityId,
545
+ tenantId ?? null,
546
+ organizationId ?? null,
547
+ map,
548
+ )
549
+ return fields.map((rule) => rule.field)
426
550
  }
427
551
 
428
552
  private encryptFields(
@@ -506,18 +630,24 @@ export class TenantDataEncryptionService {
506
630
  return payload
507
631
  }
508
632
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
509
- if (!map || !map.fields?.length) {
633
+ const fields = await this.resolveFieldRulesForScope(
634
+ entityId,
635
+ tenantId ?? null,
636
+ organizationId ?? null,
637
+ map,
638
+ )
639
+ if (!fields.length) {
510
640
  debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
511
641
  return payload
512
642
  }
513
- const keyId = resolveEncryptionKeyId(entityId, map.keyScope, tenantId)
643
+ const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)
514
644
  const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })
515
645
  if (!dek) {
516
- debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
646
+ debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })
517
647
  return payload
518
648
  }
519
- debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
520
- return this.encryptFields(payload, map.fields, dek)
649
+ debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })
650
+ return this.encryptFields(payload, fields, dek)
521
651
  }
522
652
 
523
653
  async decryptEntityPayload(
@@ -531,17 +661,23 @@ export class TenantDataEncryptionService {
531
661
  return payload
532
662
  }
533
663
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
534
- if (!map || !map.fields?.length) {
664
+ const fields = await this.resolveFieldRulesForScope(
665
+ entityId,
666
+ tenantId ?? null,
667
+ organizationId ?? null,
668
+ map,
669
+ )
670
+ if (!fields.length) {
535
671
  debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
536
672
  return payload
537
673
  }
538
- const keyId = resolveEncryptionKeyId(entityId, map.keyScope, tenantId)
674
+ const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)
539
675
  const dek = await this.getDek(keyId)
540
676
  if (!dek) {
541
- debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
677
+ debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })
542
678
  return payload
543
679
  }
544
- debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
545
- return this.decryptFields(payload, map.fields, dek)
680
+ debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })
681
+ return this.decryptFields(payload, fields, dek)
546
682
  }
547
683
  }
@@ -60,9 +60,10 @@ export function isEncryptedLikeField(encrypted: ReadonlySet<string>, field: stri
60
60
  return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate))
61
61
  }
62
62
 
63
- // The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)` is an UNCACHED
64
- // `encryption_maps` read. Encryption maps change on deploys, not per request, so a short TTL
65
- // removes the per-search round-trip without meaningfully delaying a map rollout.
63
+ // The all-orgs union behind `getEncryptedFieldNames(..., organizationId: null)` reads
64
+ // `encryption_maps`. The service caches that aggregate since #5949, but this set is per-search
65
+ // hot and also spans the name-shape expansion below, so a short TTL keeps the whole resolved
66
+ // set out of the request path without meaningfully delaying a map rollout.
66
67
  const ENCRYPTED_LIKE_FIELDS_TTL_MS = 60_000
67
68
  const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500
68
69
  const encryptedLikeFieldsCache = new Map<string, { at: number; fields: Set<string> }>()
@@ -455,10 +456,10 @@ export class BasicQueryEngine implements QueryEngine {
455
456
  // `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while the
456
457
  // KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
457
458
  // `organizationId: null` is deliberate, not an omission: the service then unions in every
458
- // organization's map (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays on
459
- // the token path -- a wider set fails safe. Passing the request's org instead would silently
460
- // break encrypted-column search for orgs without their own map. That union is an UNCACHED
461
- // `encryption_maps` read, one extra round-trip per searched list request. `null` means
459
+ // organization's map, so a field any org encrypts stays on the token path -- a wider set
460
+ // fails safe. Passing the request's org instead would silently break encrypted-column search
461
+ // for orgs without their own map. The service caches that union (#5949), and the TTL cache
462
+ // above keeps even the cache lookup off the request path. `null` means
462
463
  // the encryption service could not answer at all; keep the pre-existing rewrite-everything
463
464
  // behavior then, because guessing "plaintext" would turn encrypted-column search into an
464
465
  // ILIKE-on-ciphertext that matches nothing.
@@ -21,6 +21,13 @@ export type SearchConfig = {
21
21
  entityBlocklistedFields?: Record<string, string[]>
22
22
  maxFieldChars?: number
23
23
  maxTokensPerField?: number
24
+ /**
25
+ * Ceiling on token rows across all fields of one record; `0` disables it. The budget is spent in
26
+ * the order the document's own keys iterate in, so on an over-budget record *which* fields stay
27
+ * searchable depends on that key order — see `buildSearchTokenRows` in
28
+ * `@open-mercato/core/modules/query_index/lib/search-tokens` before recomputing expected tokens
29
+ * from a document that did not come straight from the indexer.
30
+ */
24
31
  maxTokensPerRecord?: number
25
32
  }
26
33
 
@@ -58,7 +58,13 @@ export type ExtensionPointPatternParameter = {
58
58
  }
59
59
 
60
60
  type ExtensionHostDeclarationBase = {
61
- source: string
61
+ /**
62
+ * File(s) this host is mounted from, relative to the module's source root. A
63
+ * host mounted from more than one file may declare them all; the first entry
64
+ * remains the authoritative provenance path, the rest surface on the
65
+ * generated fact's `additionalSources`.
66
+ */
67
+ source: string | readonly string[]
62
68
  contextContract?: string
63
69
  dataContract?: string
64
70
  scopeContract?: string
@@ -398,6 +404,8 @@ export type ModuleExtensionHostFact = {
398
404
  | { kind: 'declaration'; path: string; symbol: string }
399
405
  | { kind: 'fact-ref'; factSection: string; factKey: string }
400
406
  | { kind: 'framework'; path: string; symbol: string }
407
+ /** Extra mount-point paths beyond the declaration's primary `source` entry. */
408
+ additionalSources?: string[]
401
409
  aliases?: string[]
402
410
  patternParameters?: Record<string, ExtensionPointPatternParameter>
403
411
  fallbacks?: string[]