@open-mercato/core 0.10.1-develop.7752.1.fd7c06569b → 0.10.1-develop.7756.1.8f5dec1b96

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 (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/dbFixtures.js +1 -1
  3. package/dist/helpers/integration/dbFixtures.js.map +1 -1
  4. package/dist/modules/auth/api/roles/acl/route.js +2 -2
  5. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  6. package/dist/modules/auth/api/roles/route.js +1 -1
  7. package/dist/modules/auth/api/roles/route.js.map +2 -2
  8. package/dist/modules/auth/api/users/acl/route.js +10 -2
  9. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  10. package/dist/modules/auth/commands/acl.js +2 -2
  11. package/dist/modules/auth/commands/acl.js.map +2 -2
  12. package/dist/modules/auth/commands/roles.js +1 -1
  13. package/dist/modules/auth/commands/roles.js.map +2 -2
  14. package/dist/modules/auth/commands/users.js +7 -3
  15. package/dist/modules/auth/commands/users.js.map +2 -2
  16. package/dist/modules/auth/data/entities.js.map +2 -2
  17. package/dist/modules/auth/lib/executionPrincipal.js +2 -2
  18. package/dist/modules/auth/lib/executionPrincipal.js.map +2 -2
  19. package/dist/modules/auth/lib/grantChecks.js +38 -11
  20. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  21. package/dist/modules/auth/lib/setup-app.js +2 -2
  22. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  23. package/dist/modules/auth/migrations/Migration20260828120000_auth.js +45 -0
  24. package/dist/modules/auth/migrations/Migration20260828120000_auth.js.map +7 -0
  25. package/dist/modules/auth/services/rbacService.js +138 -59
  26. package/dist/modules/auth/services/rbacService.js.map +3 -3
  27. package/dist/modules/entities/cli.js +12 -1
  28. package/dist/modules/entities/cli.js.map +2 -2
  29. package/dist/modules/entities/lib/helpers.js +13 -0
  30. package/dist/modules/entities/lib/helpers.js.map +2 -2
  31. package/dist/modules/entities/lib/rotate-custom-entity-documents.js +157 -0
  32. package/dist/modules/entities/lib/rotate-custom-entity-documents.js.map +7 -0
  33. package/dist/modules/notifications/lib/notificationRecipients.js +2 -2
  34. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  35. package/dist/modules/query_index/lib/engine.js +179 -11
  36. package/dist/modules/query_index/lib/engine.js.map +3 -3
  37. package/package.json +7 -7
  38. package/src/helpers/integration/dbFixtures.ts +1 -1
  39. package/src/modules/auth/api/roles/acl/route.ts +2 -2
  40. package/src/modules/auth/api/roles/route.ts +1 -1
  41. package/src/modules/auth/api/users/acl/route.ts +10 -2
  42. package/src/modules/auth/commands/acl.ts +2 -2
  43. package/src/modules/auth/commands/roles.ts +1 -1
  44. package/src/modules/auth/commands/users.ts +7 -3
  45. package/src/modules/auth/data/entities.ts +12 -0
  46. package/src/modules/auth/lib/executionPrincipal.ts +2 -2
  47. package/src/modules/auth/lib/grantChecks.ts +115 -11
  48. package/src/modules/auth/lib/setup-app.ts +2 -2
  49. package/src/modules/auth/migrations/Migration20260828120000_auth.ts +70 -0
  50. package/src/modules/auth/services/rbacService.ts +161 -76
  51. package/src/modules/entities/cli.ts +12 -1
  52. package/src/modules/entities/lib/helpers.ts +17 -0
  53. package/src/modules/entities/lib/rotate-custom-entity-documents.ts +200 -0
  54. package/src/modules/notifications/lib/notificationRecipients.ts +6 -0
  55. package/src/modules/query_index/lib/engine.ts +187 -9
@@ -64,9 +64,12 @@ export async function getRecipientUserIdsForRole(
64
64
  const builder: any = db
65
65
  const userRoles = await builder
66
66
  .selectFrom('user_roles')
67
+ .innerJoin('roles', 'user_roles.role_id', 'roles.id')
67
68
  .innerJoin('users', 'user_roles.user_id', 'users.id')
68
69
  .where('user_roles.role_id', '=', roleId)
69
70
  .where('user_roles.deleted_at', 'is', null)
71
+ .where('roles.deleted_at', 'is', null)
72
+ .where('roles.tenant_id', '=', tenantId)
70
73
  .where('users.deleted_at', 'is', null)
71
74
  .where('users.tenant_id', '=', tenantId)
72
75
  .select('users.id as user_id')
@@ -101,10 +104,13 @@ export async function getRecipientUserIdsForFeature(
101
104
 
102
105
  const roleAcls = await builder
103
106
  .selectFrom('role_acls')
107
+ .innerJoin('roles', 'role_acls.role_id', 'roles.id')
104
108
  .innerJoin('user_roles', 'role_acls.role_id', 'user_roles.role_id')
105
109
  .innerJoin('users', 'user_roles.user_id', 'users.id')
106
110
  .where('role_acls.tenant_id', '=', tenantId)
107
111
  .where('role_acls.deleted_at', 'is', null)
112
+ .where('roles.deleted_at', 'is', null)
113
+ .where('roles.tenant_id', '=', tenantId)
108
114
  .where('user_roles.deleted_at', 'is', null)
109
115
  .where('users.deleted_at', 'is', null)
110
116
  .where('users.tenant_id', '=', tenantId)
@@ -17,6 +17,8 @@ import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
17
17
  import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
18
18
  import type { VectorIndexService } from '@open-mercato/search/vector'
19
19
  import { decryptIndexDocCustomFields } from '@open-mercato/shared/lib/encryption/indexDoc'
20
+ import { decryptCustomEntityDocument, isCustomEntityDocumentCiphertext } from '@open-mercato/shared/lib/encryption/customEntityDoc'
21
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
20
22
  import {
21
23
  DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
22
24
  DECRYPT_REFUSAL_LOG_MESSAGE,
@@ -72,6 +74,85 @@ import { createBoundedTtlMemo } from '@open-mercato/shared/lib/query/bounded-ttl
72
74
 
73
75
  const logger = createLogger('query_index').child({ component: 'engine' })
74
76
 
77
+ function customDocumentValue(row: Record<string, unknown>, field: string): unknown {
78
+ const bare = field.replace(/^cf[:_]/, '')
79
+ return row[field] ?? row[`cf_${bare.replace(/[^a-zA-Z0-9_]/g, '_')}`] ?? row[`cf:${bare}`] ?? row[bare]
80
+ }
81
+
82
+ function assertCustomDocumentPlaintext(actual: unknown): void {
83
+ if (isCustomEntityDocumentCiphertext(actual) || (Array.isArray(actual) && actual.some(isCustomEntityDocumentCiphertext))) {
84
+ throw new Error('[internal] Cannot compare custom entity ciphertext without its tenant key')
85
+ }
86
+ }
87
+
88
+ function matchesCustomDocumentPattern(value: string, pattern: string, ignoreCase: boolean): boolean {
89
+ const source = Array.from(value)
90
+ const authored = Array.from(pattern)
91
+ const normalize = (character: string) => ignoreCase ? character.toLowerCase() : character
92
+ const tokens: Array<{ kind: 'literal' | 'one' | 'many'; value: string }> = []
93
+ for (let index = 0; index < authored.length; index += 1) {
94
+ const character = authored[index]!
95
+ if (character === '\\') {
96
+ const literal = authored[++index]
97
+ if (literal === undefined) throw new Error('[internal] Invalid custom entity LIKE escape')
98
+ tokens.push({ kind: 'literal', value: normalize(literal) })
99
+ } else {
100
+ tokens.push({ kind: character === '%' ? 'many' : character === '_' ? 'one' : 'literal', value: normalize(character) })
101
+ }
102
+ }
103
+ let sourceIndex = 0
104
+ let tokenIndex = 0
105
+ let wildcardIndex = -1
106
+ let retryIndex = 0
107
+ while (sourceIndex < source.length) {
108
+ const token = tokens[tokenIndex]
109
+ if (token?.kind === 'one' || (token?.kind === 'literal' && token.value === normalize(source[sourceIndex]!))) {
110
+ sourceIndex += 1
111
+ tokenIndex += 1
112
+ } else if (token?.kind === 'many') {
113
+ wildcardIndex = tokenIndex++
114
+ retryIndex = sourceIndex
115
+ } else if (wildcardIndex >= 0) {
116
+ tokenIndex = wildcardIndex + 1
117
+ sourceIndex = ++retryIndex
118
+ } else {
119
+ return false
120
+ }
121
+ }
122
+ while (tokens[tokenIndex]?.kind === 'many') tokenIndex += 1
123
+ return tokenIndex === tokens.length
124
+ }
125
+
126
+ function matchesCustomDocumentFilter(row: Record<string, unknown>, filter: Filter, actual = customDocumentValue(row, filter.field)): boolean {
127
+ const expected: unknown = filter.value
128
+ assertCustomDocumentPlaintext(actual)
129
+ const equal = (left: unknown, right: unknown) => left == null || right == null
130
+ ? left == null && right == null
131
+ : String(left) === String(right)
132
+ const values = filter.field.startsWith('cf:') && Array.isArray(actual) ? actual : [actual]
133
+ const included = (candidates: unknown[]) => values.some((value) => candidates.some((candidate) => equal(value, candidate)))
134
+ switch (filter.op) {
135
+ case 'exists': return Boolean(expected) === (actual != null)
136
+ case 'eq': return values.some((value) => equal(value, expected))
137
+ case 'ne': return actual != null && !values.some((value) => equal(value, expected))
138
+ case 'in': return included(Array.isArray(expected) ? expected : [])
139
+ case 'nin': return actual != null && !included(Array.isArray(expected) ? expected : [])
140
+ case 'overlap': return Array.isArray(actual) && normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value))
141
+ case 'noverlap': return Array.isArray(actual) && !normalizeOverlapValues(expected).some((value) => actual.map(String).includes(value))
142
+ case 'gt': case 'gte': case 'lt': case 'lte': {
143
+ if (actual == null || expected == null) return false
144
+ const left = typeof actual === 'number' ? actual : String(actual)
145
+ const right = typeof actual === 'number' ? Number(expected) : String(expected)
146
+ const order = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right))
147
+ return filter.op === 'gt' ? order > 0 : filter.op === 'gte' ? order >= 0 : filter.op === 'lt' ? order < 0 : order <= 0
148
+ }
149
+ case 'like': case 'ilike': {
150
+ return values.some((value) => value != null && matchesCustomDocumentPattern(String(value), String(expected ?? ''), filter.op === 'ilike'))
151
+ }
152
+ default: throw new Error('[internal] Unsupported custom entity filter operator')
153
+ }
154
+ }
155
+
75
156
  /**
76
157
  * Plain-row shape of the `custom_field_defs` columns the key and kind lookups read. The
77
158
  * scope columns are load-bearing: a key can carry an organization-specific definition that
@@ -2232,6 +2313,26 @@ export class HybridQueryEngine implements QueryEngine {
2232
2313
  if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
2233
2314
 
2234
2315
  const normalizedFilters = normalizeFilters(opts.filters)
2316
+ const encryptionService = resolveDecryptEnabled(opts) ? this.getEncryptionService() : null
2317
+ const documentEncryptionService = encryptionService && 'getDek' in encryptionService
2318
+ && typeof encryptionService.getDek === 'function' && typeof encryptionService.isEnabled === 'function'
2319
+ ? encryptionService as TenantDataEncryptionService
2320
+ : null
2321
+ const definitions = encryptionService ? await this.resolveCustomFieldDefs([entity], opts.tenantId, true) : []
2322
+ const resolveKinds = createCustomFieldKindMapResolver(buildCustomFieldKindIndex(definitions))
2323
+ const encryptedKeys = new Set(definitions.filter((definition) => {
2324
+ const config = definition.configJson
2325
+ return config != null && typeof config === 'object' && 'encrypted' in config && Boolean(config.encrypted)
2326
+ }).map((definition) => definition.key))
2327
+ const isProtected = (field: string) => encryptedKeys.has(field.replace(/^cf[:_]/, ''))
2328
+ const plaintextFilters = normalizedFilters.some((filter) => isProtected(filter.field))
2329
+ const plaintextSort = (opts.sort ?? []).some((sort) => isProtected(sort.field))
2330
+ const scanPlaintext = plaintextFilters || plaintextSort
2331
+ const deferredFilters = plaintextFilters ? normalizedFilters.filter((filter) => filter.orGroup || isProtected(filter.field)) : []
2332
+ const jsonComparisonAliases = new Map(Array.from(new Set(deferredFilters
2333
+ .filter((filter) => !isProtected(filter.field) && !filter.field.startsWith('cf:')
2334
+ && !this.resolveCustomEntityColumn(alias, filter.field) && (filter.op === 'overlap' || filter.op === 'noverlap'))
2335
+ .map((filter) => filter.field)), (field, index) => [field, `__om_ce_filter_${index}`]))
2235
2336
  const searchConfig = resolveSearchConfig()
2236
2337
  const searchEnabled = await this.searchAvailability().staticEnabled()
2237
2338
  // Same gate as `query()` (#4723): without a like/ilike filter the probe's answer is never read.
@@ -2258,6 +2359,7 @@ export class HybridQueryEngine implements QueryEngine {
2258
2359
  }
2259
2360
  if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, 'is', null)
2260
2361
  for (const filter of normalizedFilters) {
2362
+ if (plaintextFilters && (filter.orGroup || isProtected(filter.field))) continue
2261
2363
  if (filter.field.startsWith('cf:')) {
2262
2364
  next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime)
2263
2365
  continue
@@ -2317,20 +2419,31 @@ export class HybridQueryEngine implements QueryEngine {
2317
2419
  for (const k of opts.includeCustomFields) cfKeys.add(k)
2318
2420
  }
2319
2421
 
2422
+ const projectedFields = new Set((opts.fields?.length ? opts.fields : ['id']).map((field) => field.startsWith('cf:') ? this.sanitize(field) : field))
2423
+ for (const key of cfKeys) projectedFields.add(this.sanitize(`cf:${key}`))
2424
+ const hiddenFields = new Set<string>()
2425
+ const comparisonFields = scanPlaintext
2426
+ ? Array.from(new Set([...deferredFilters.map((filter) => filter.field), ...(opts.sort ?? []).map((sort) => sort.field)]))
2427
+ : []
2428
+ for (const field of comparisonFields) {
2429
+ const outputKey = field.startsWith('cf:') ? this.sanitize(field) : field
2430
+ if (!projectedFields.has(outputKey)) hiddenFields.add(outputKey)
2431
+ }
2432
+
2320
2433
  const applySelection = (q: AnyBuilder): AnyBuilder => {
2321
2434
  let next = q
2322
- const requested = (opts.fields && opts.fields.length) ? opts.fields : ['id']
2435
+ const requested = Array.from(new Set([...(opts.fields?.length ? opts.fields : ['id']), ...comparisonFields]))
2323
2436
  for (const field of requested) {
2324
2437
  const f = String(field)
2325
2438
  if (f.startsWith('cf:')) {
2326
2439
  const aliasName = this.sanitize(f)
2327
2440
  next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName))
2328
- } else if (f === 'id') {
2329
- next = next.select(`${alias}.entity_id as id`)
2330
- } else if (f === 'created_at' || f === 'updated_at' || f === 'deleted_at') {
2331
- next = next.select(`${alias}.${f} as ${f}`)
2441
+ } else if (this.resolveCustomEntityColumn(alias, f)) {
2442
+ next = next.select(`${this.resolveCustomEntityColumn(alias, f)} as ${f}`)
2332
2443
  } else {
2333
- const expr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${f})`
2444
+ const expr = isProtected(f)
2445
+ ? sql<unknown>`(${sql.ref(alias + '.doc')} -> ${f})`
2446
+ : sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${f})`
2334
2447
  next = next.select(expr.as(f))
2335
2448
  }
2336
2449
  }
@@ -2339,6 +2452,12 @@ export class HybridQueryEngine implements QueryEngine {
2339
2452
  const aliasName = this.sanitize(`cf:${key}`)
2340
2453
  next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName))
2341
2454
  }
2455
+ for (const [field, comparisonAlias] of jsonComparisonAliases) {
2456
+ next = next.select(sql<unknown>`(${sql.ref(alias + '.doc')} -> ${field})`.as(comparisonAlias))
2457
+ }
2458
+ if (encryptionService) {
2459
+ next = next.select([`${alias}.tenant_id as __om_ce_tenant_id`, `${alias}.organization_id as __om_ce_organization_id`])
2460
+ }
2342
2461
  return next
2343
2462
  }
2344
2463
 
@@ -2364,6 +2483,63 @@ export class HybridQueryEngine implements QueryEngine {
2364
2483
  const page = opts.page?.page ?? 1
2365
2484
  const pageSize = opts.page?.pageSize ?? 20
2366
2485
 
2486
+ const decryptRow = async (item: Record<string, unknown>): Promise<Record<string, unknown>> => {
2487
+ let next = item
2488
+ if (encryptionService) {
2489
+ const hasOrganizationScope = Object.prototype.hasOwnProperty.call(item, '__om_ce_organization_id')
2490
+ const decision = resolveDecryptScope({
2491
+ rowTenantId: (Object.prototype.hasOwnProperty.call(item, '__om_ce_tenant_id') ? item.__om_ce_tenant_id : item.tenant_id ?? null) as string | null,
2492
+ rowOrganizationId: (hasOrganizationScope ? item.__om_ce_organization_id : item.organization_id ?? null) as string | null,
2493
+ callerTenantId: opts.tenantId,
2494
+ callerOrganizationId: hasOrganizationScope ? null : opts.organizationId ?? null,
2495
+ })
2496
+ if (decision.decrypt) {
2497
+ const fields = Object.entries(resolveKinds(decision.organizationId, decision.tenantId)).map(([key, kind]) => ({ key, kind, encrypted: encryptedKeys.has(key) }))
2498
+ next = await decryptCustomEntityDocument(item, fields, decision.tenantId, documentEncryptionService)
2499
+ }
2500
+ }
2501
+ const projected = { ...next }
2502
+ delete projected.__om_ce_tenant_id
2503
+ delete projected.__om_ce_organization_id
2504
+ return projected
2505
+ }
2506
+
2507
+ if (scanPlaintext) {
2508
+ const maxRows = resolveEncryptedSortMaxRows()
2509
+ let candidateQuery = applySelection(applyScope(db.selectFrom(`custom_entities_storage as ${alias}`)))
2510
+ if (maxRows !== null) candidateQuery = candidateQuery.limit(maxRows + 1)
2511
+ const rows: Record<string, unknown>[] = await candidateQuery.execute()
2512
+ if (maxRows !== null && rows.length > maxRows) throw new Error('[internal] Custom entity encrypted query exceeds the candidate row limit')
2513
+ let candidates = await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow)
2514
+ if (plaintextFilters) {
2515
+ const common = deferredFilters.filter((filter) => !filter.orGroup)
2516
+ const groups = new Map<string, typeof normalizedFilters>()
2517
+ for (const filter of normalizedFilters) {
2518
+ if (filter.orGroup) groups.set(filter.orGroup, [...(groups.get(filter.orGroup) ?? []), filter])
2519
+ }
2520
+ const matches = (row: Record<string, unknown>, filter: Filter) => {
2521
+ const comparisonAlias = jsonComparisonAliases.get(filter.field)
2522
+ return matchesCustomDocumentFilter(row, filter, comparisonAlias ? row[comparisonAlias] : customDocumentValue(row, filter.field))
2523
+ }
2524
+ candidates = candidates.filter((row) => common.every((filter) => matches(row, filter))
2525
+ && (!groups.size || Array.from(groups.values()).some((group) => group.every((filter) => matches(row, filter)))))
2526
+ }
2527
+ for (const row of candidates) {
2528
+ for (const sort of opts.sort ?? []) {
2529
+ if (isProtected(sort.field)) assertCustomDocumentPlaintext(customDocumentValue(row, sort.field))
2530
+ }
2531
+ }
2532
+ candidates = sortRowsInMemory(candidates, (opts.sort ?? []).map((sort) => ({ ...sort, field: sort.field.startsWith('cf:') ? this.sanitize(sort.field) : sort.field })))
2533
+ const total = candidates.length
2534
+ const items = candidates.slice((page - 1) * pageSize, page * pageSize).map((row) => {
2535
+ const projected = { ...row }
2536
+ for (const field of hiddenFields) delete projected[field]
2537
+ for (const comparisonAlias of jsonComparisonAliases.values()) delete projected[comparisonAlias]
2538
+ return projected
2539
+ })
2540
+ return { items: items as T[], page, pageSize, total }
2541
+ }
2542
+
2367
2543
  // Single-table count: `applyScope` adds only WHERE predicates (cf filters
2368
2544
  // included — no join), and doc storage holds one row per record within a
2369
2545
  // scope, so `count(*)` needs no DISTINCT and — when the cap is active — a
@@ -2389,8 +2565,9 @@ export class HybridQueryEngine implements QueryEngine {
2389
2565
  dataQuery = applySelection(dataQuery)
2390
2566
  dataQuery = applySort(dataQuery)
2391
2567
  dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize)
2392
- const items = await dataQuery.execute()
2393
- const result: QueryResult<T> = { items, page, pageSize, total }
2568
+ const rows: Record<string, unknown>[] = await dataQuery.execute()
2569
+ const items = encryptionService ? await mapWithConcurrency(rows, DECRYPT_CONCURRENCY, decryptRow) : rows
2570
+ const result: QueryResult<T> = { items: items as T[], page, pageSize, total }
2394
2571
  if (listCountCapWarning) result.meta = { listCountCapWarning }
2395
2572
  return result
2396
2573
  }
@@ -2411,12 +2588,13 @@ export class HybridQueryEngine implements QueryEngine {
2411
2588
  private async resolveCustomFieldDefs(
2412
2589
  entityIds: string[],
2413
2590
  tenantId: string | null,
2591
+ fresh = false,
2414
2592
  ): Promise<CustomFieldDefRow[]> {
2415
2593
  if (!entityIds.length) return []
2416
2594
  const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId)
2417
2595
  const now = Date.now()
2418
2596
  const cached = this.customFieldDefsCache.get(cacheKey)
2419
- if (cached && cached.expiresAt > now) return cached.value.slice()
2597
+ if (!fresh && cached && cached.expiresAt > now) return cached.value.slice()
2420
2598
 
2421
2599
  const db = this.getDb() as any
2422
2600
  const rows = await db