@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (199) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +6 -57
  3. package/build.mjs +4 -2
  4. package/dist/lib/auth/server.js +2 -15
  5. package/dist/lib/auth/server.js.map +2 -2
  6. package/dist/lib/bootstrap/dynamicLoader.js +43 -117
  7. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  8. package/dist/lib/commands/command-bus.js +1 -5
  9. package/dist/lib/commands/command-bus.js.map +2 -2
  10. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  11. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  12. package/dist/lib/crud/enricher-runner.js +11 -2
  13. package/dist/lib/crud/enricher-runner.js.map +2 -2
  14. package/dist/lib/crud/factory.js +4 -63
  15. package/dist/lib/crud/factory.js.map +2 -2
  16. package/dist/lib/crud/interceptor-runner.js +2 -2
  17. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  19. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  20. package/dist/lib/crud/types.js +0 -4
  21. package/dist/lib/crud/types.js.map +3 -3
  22. package/dist/lib/data/engine.js +11 -37
  23. package/dist/lib/data/engine.js.map +2 -2
  24. package/dist/lib/db/mikro.js +12 -3
  25. package/dist/lib/db/mikro.js.map +2 -2
  26. package/dist/lib/db/pg-errors.js +0 -42
  27. package/dist/lib/db/pg-errors.js.map +2 -2
  28. package/dist/lib/di/container.js +6 -48
  29. package/dist/lib/di/container.js.map +2 -2
  30. package/dist/lib/email/send.js +0 -56
  31. package/dist/lib/email/send.js.map +2 -2
  32. package/dist/lib/encryption/subscriber.js +10 -2
  33. package/dist/lib/encryption/subscriber.js.map +2 -2
  34. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
  35. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  36. package/dist/lib/i18n/config.js +1 -1
  37. package/dist/lib/i18n/config.js.map +2 -2
  38. package/dist/lib/i18n/server.js +4 -4
  39. package/dist/lib/i18n/server.js.map +3 -3
  40. package/dist/lib/logger/index.js +3 -47
  41. package/dist/lib/logger/index.js.map +3 -3
  42. package/dist/lib/modules/registry.js +1 -5
  43. package/dist/lib/modules/registry.js.map +2 -2
  44. package/dist/lib/query/engine.js +40 -54
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/query/types.js.map +1 -1
  47. package/dist/lib/redis/connection.js +2 -7
  48. package/dist/lib/redis/connection.js.map +2 -2
  49. package/dist/lib/search/config.js +2 -61
  50. package/dist/lib/search/config.js.map +2 -2
  51. package/dist/lib/search/tokenize.js +11 -23
  52. package/dist/lib/search/tokenize.js.map +2 -2
  53. package/dist/lib/version.js +1 -1
  54. package/dist/lib/version.js.map +1 -1
  55. package/dist/lib/webhooks/index.js +1 -13
  56. package/dist/lib/webhooks/index.js.map +2 -2
  57. package/dist/lib/webhooks/verify.js +7 -12
  58. package/dist/lib/webhooks/verify.js.map +2 -2
  59. package/dist/modules/analytics.js.map +2 -2
  60. package/dist/modules/overrides.js +1 -50
  61. package/dist/modules/overrides.js.map +2 -2
  62. package/dist/modules/payment_gateways/types.js +1 -8
  63. package/dist/modules/payment_gateways/types.js.map +2 -2
  64. package/dist/modules/registry.js +0 -1
  65. package/dist/modules/registry.js.map +2 -2
  66. package/dist/security/enabledModulesRegistry.js +11 -56
  67. package/dist/security/enabledModulesRegistry.js.map +2 -2
  68. package/jest.config.cjs +1 -2
  69. package/package.json +9 -19
  70. package/src/lib/ai/llm-provider.ts +0 -33
  71. package/src/lib/auth/__tests__/server.test.ts +0 -23
  72. package/src/lib/auth/server.ts +2 -23
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
  75. package/src/lib/bootstrap/dynamicLoader.ts +65 -213
  76. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
  77. package/src/lib/commands/command-bus.ts +1 -5
  78. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  79. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
  80. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
  81. package/src/lib/crud/enricher-runner.ts +11 -2
  82. package/src/lib/crud/factory.ts +1 -69
  83. package/src/lib/crud/interceptor-runner.ts +2 -2
  84. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  85. package/src/lib/crud/types.ts +0 -3
  86. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
  87. package/src/lib/data/engine.ts +16 -50
  88. package/src/lib/db/__tests__/mikro.test.ts +25 -0
  89. package/src/lib/db/mikro.ts +38 -13
  90. package/src/lib/db/pg-errors.ts +0 -58
  91. package/src/lib/di/container.ts +8 -72
  92. package/src/lib/email/__tests__/send.test.ts +1 -37
  93. package/src/lib/email/send.ts +0 -79
  94. package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
  95. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
  96. package/src/lib/encryption/subscriber.ts +14 -2
  97. package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
  98. package/src/lib/i18n/config.ts +2 -2
  99. package/src/lib/i18n/server.ts +2 -6
  100. package/src/lib/logger/__tests__/logger.test.ts +0 -77
  101. package/src/lib/logger/index.ts +1 -59
  102. package/src/lib/modules/registry.ts +0 -10
  103. package/src/lib/query/__tests__/engine.test.ts +13 -65
  104. package/src/lib/query/engine.ts +58 -75
  105. package/src/lib/query/types.ts +5 -4
  106. package/src/lib/redis/__tests__/connection.test.ts +0 -16
  107. package/src/lib/redis/connection.ts +7 -10
  108. package/src/lib/search/__tests__/config.test.ts +0 -177
  109. package/src/lib/search/config.ts +8 -106
  110. package/src/lib/search/tokenize.ts +11 -33
  111. package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
  112. package/src/lib/webhooks/index.ts +1 -19
  113. package/src/lib/webhooks/verify.ts +9 -17
  114. package/src/modules/__tests__/registry.test.ts +2 -10
  115. package/src/modules/analytics.ts +0 -7
  116. package/src/modules/customer-auth.ts +0 -1
  117. package/src/modules/encryption.ts +0 -3
  118. package/src/modules/navigation/backendChrome.ts +0 -15
  119. package/src/modules/overrides.ts +0 -103
  120. package/src/modules/payment_gateways/types.ts +0 -41
  121. package/src/modules/registry.ts +0 -3
  122. package/src/security/enabledModulesRegistry.ts +12 -64
  123. package/dist/lib/ai/safety-identifier.js +0 -32
  124. package/dist/lib/ai/safety-identifier.js.map +0 -7
  125. package/dist/lib/auth/organizationScope.js +0 -34
  126. package/dist/lib/auth/organizationScope.js.map +0 -7
  127. package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
  128. package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
  129. package/dist/lib/data/consistency.js +0 -19
  130. package/dist/lib/data/consistency.js.map +0 -7
  131. package/dist/lib/db/entityMetadata.js +0 -26
  132. package/dist/lib/db/entityMetadata.js.map +0 -7
  133. package/dist/lib/logger/extension.js +0 -29
  134. package/dist/lib/logger/extension.js.map +0 -7
  135. package/dist/lib/modules/surfaceFingerprint.js +0 -47
  136. package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
  137. package/dist/lib/query/ciphertext-search-warning.js +0 -45
  138. package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
  139. package/dist/lib/search/auto-indexing.js +0 -14
  140. package/dist/lib/search/auto-indexing.js.map +0 -7
  141. package/dist/lib/search/availability.js +0 -107
  142. package/dist/lib/search/availability.js.map +0 -7
  143. package/dist/lib/search/tokenLookup.js +0 -46
  144. package/dist/lib/search/tokenLookup.js.map +0 -7
  145. package/dist/lib/telemetry/runtime.js +0 -35
  146. package/dist/lib/telemetry/runtime.js.map +0 -7
  147. package/dist/lib/webhooks/body.js +0 -57
  148. package/dist/lib/webhooks/body.js.map +0 -7
  149. package/dist/lib/webhooks/inbound-types.js +0 -1
  150. package/dist/lib/webhooks/inbound-types.js.map +0 -7
  151. package/dist/modules/widgets/extension-points.js +0 -96
  152. package/dist/modules/widgets/extension-points.js.map +0 -7
  153. package/dist/security/featurePolicy.js +0 -62
  154. package/dist/security/featurePolicy.js.map +0 -7
  155. package/scripts/versionSource.cjs +0 -65
  156. package/src/lib/__tests__/versionSource.test.ts +0 -46
  157. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
  158. package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
  159. package/src/lib/ai/safety-identifier.ts +0 -73
  160. package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
  161. package/src/lib/auth/organizationScope.ts +0 -65
  162. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
  163. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
  164. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
  165. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
  166. package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
  167. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
  168. package/src/lib/data/__tests__/consistency.test.ts +0 -38
  169. package/src/lib/data/consistency.ts +0 -17
  170. package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
  171. package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
  172. package/src/lib/db/entityMetadata.ts +0 -43
  173. package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
  174. package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
  175. package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
  176. package/src/lib/i18n/__tests__/config.test.ts +0 -8
  177. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
  178. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
  179. package/src/lib/logger/extension.ts +0 -59
  180. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
  181. package/src/lib/modules/surfaceFingerprint.ts +0 -87
  182. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
  183. package/src/lib/query/ciphertext-search-warning.ts +0 -95
  184. package/src/lib/search/__tests__/availability.test.ts +0 -211
  185. package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
  186. package/src/lib/search/__tests__/tokenize.test.ts +0 -49
  187. package/src/lib/search/auto-indexing.ts +0 -22
  188. package/src/lib/search/availability.ts +0 -232
  189. package/src/lib/search/tokenLookup.ts +0 -133
  190. package/src/lib/telemetry/runtime.ts +0 -72
  191. package/src/lib/webhooks/__tests__/body.test.ts +0 -95
  192. package/src/lib/webhooks/body.ts +0 -70
  193. package/src/lib/webhooks/inbound-types.ts +0 -125
  194. package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
  195. package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
  196. package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
  197. package/src/modules/widgets/extension-points.ts +0 -524
  198. package/src/security/__tests__/featurePolicy.test.ts +0 -166
  199. package/src/security/featurePolicy.ts +0 -89
@@ -12,13 +12,6 @@ import {
12
12
  type ResolvedJoin,
13
13
  } from './join-utils'
14
14
  import { resolveSearchConfig } from '../search/config'
15
- import {
16
- createSearchTokenAvailability,
17
- isSearchFilterOp,
18
- type SearchTokenAvailability,
19
- type SearchTokenProbeDb,
20
- type SearchTokenProbeQueryBuilder,
21
- } from '../search/availability'
22
15
  import { tokenizeText } from '../search/tokenize'
23
16
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
24
17
  import {
@@ -27,7 +20,6 @@ import {
27
20
  type CustomFieldDefinitionRow,
28
21
  type ResolvedCustomFieldDefinitions,
29
22
  } from '../crud/custom-field-definition-index'
30
- import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
31
23
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
32
24
  import { mapWithConcurrency } from './bounded-decrypt'
33
25
  import { createLogger } from '../logger'
@@ -214,8 +206,8 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
214
206
  */
215
207
  export class BasicQueryEngine implements QueryEngine {
216
208
  private columnCache = new Map<string, boolean>()
209
+ private tableCache = new Map<string, boolean>()
217
210
  private searchAliasSeq = 0
218
- private searchAvailabilityInstance: SearchTokenAvailability | null = null
219
211
 
220
212
  constructor(
221
213
  private em: EntityManager,
@@ -238,22 +230,6 @@ export class BasicQueryEngine implements QueryEngine {
238
230
  throw new Error('BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
239
231
  }
240
232
 
241
- private searchAvailability(): SearchTokenAvailability {
242
- if (!this.searchAvailabilityInstance) {
243
- this.searchAvailabilityInstance = createSearchTokenAvailability({
244
- getDb: () => this.getDb() as unknown as SearchTokenProbeDb,
245
- getConfig: resolveSearchConfig,
246
- applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
247
- query as unknown as AnyBuilder,
248
- column,
249
- scope,
250
- ) as unknown as SearchTokenProbeQueryBuilder,
251
- logDebug: (event, payload) => this.logSearchDebug(event, payload),
252
- })
253
- }
254
- return this.searchAvailabilityInstance
255
- }
256
-
257
233
  async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
258
234
  // --- UMES query extension: before-query pipeline ---
259
235
  const ext = opts.extensions
@@ -317,20 +293,17 @@ export class BasicQueryEngine implements QueryEngine {
317
293
  const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
318
294
  const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
319
295
  const searchConfig = resolveSearchConfig()
320
- const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
321
296
  // Callers that opt out of automatic tenant/org scoping own the full
322
297
  // visibility predicate. Search-token filtering has its own tenant/org
323
298
  // guards, so it must be disabled on this direct-query path as documented
324
299
  // by QueryOptions.omitAutomaticTenantOrgScope.
325
- const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled()
326
- // Probe `search_tokens` only when this query actually searches (#4723): every consumer of
327
- // `searchActive` sits behind a like/ilike guard, so on a plain list load the answer is never
328
- // read — and the probe is a `LIMIT 1` the planner can resolve as a seq scan over a large
329
- // `search_tokens`. The join path below already probes lazily for the same reason.
330
- const hasSearchTokens = searchEnabled && searchFilters.length
331
- ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
300
+ const searchEnabled = !skipAutoScope && searchConfig.enabled && await this.tableExists('search_tokens')
301
+ const hasSearchTokens = searchEnabled
302
+ ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
332
303
  : false
333
304
  const searchActive = searchEnabled && hasSearchTokens
305
+ const joinSearchAvailability = new Map<string, boolean>()
306
+ const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike')
334
307
  if (searchFilters.length) {
335
308
  const fields = searchFilters.map((filter) => String(filter.field))
336
309
  this.logSearchDebug('search:init', {
@@ -360,45 +333,6 @@ export class BasicQueryEngine implements QueryEngine {
360
333
  organizationScope: orgScope,
361
334
  })
362
335
  }
363
- const fallbackFields = searchFilters
364
- .filter((filter) => !searchActive || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
365
- .map((filter) => String(filter.field))
366
- if (fallbackFields.length) {
367
- await warnOnCiphertextLikeFallback({
368
- entity: String(entity),
369
- fields: fallbackFields,
370
- tenantId: opts.tenantId ?? null,
371
- // `searchEnabled` also folds in the missing-table and
372
- // omitAutomaticTenantOrgScope cases, which are "no usable tokens"
373
- // rather than "the operator switched search off".
374
- reason: searchActive
375
- ? 'no-indexable-tokens'
376
- : searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
377
- service: this.getEncryptionService(),
378
- })
379
- }
380
- }
381
- for (const [alias, joinedFilters] of joinFilters) {
382
- const filters = joinedFilters.filter((entry) => entry.op === 'like' || entry.op === 'ilike')
383
- if (!filters.length) continue
384
- const join = joinMap.get(alias)
385
- if (!join?.entityId) continue
386
- const hasJoinedTokens = searchEnabled
387
- ? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
388
- : false
389
- const fallbackFields = filters
390
- .filter((filter) => !hasJoinedTokens || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
391
- .map((filter) => filter.column)
392
- if (!fallbackFields.length) continue
393
- await warnOnCiphertextLikeFallback({
394
- entity: join.entityId,
395
- fields: fallbackFields,
396
- tenantId: opts.tenantId ?? null,
397
- reason: hasJoinedTokens
398
- ? 'no-indexable-tokens'
399
- : searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
400
- service: this.getEncryptionService(),
401
- })
402
336
  }
403
337
  const recordIdColumn = qualify('id')
404
338
 
@@ -462,7 +396,11 @@ export class BasicQueryEngine implements QueryEngine {
462
396
  if (!['like', 'ilike'].includes(filter.op)) return { applied: false, builder }
463
397
  if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return { applied: false, builder }
464
398
 
465
- const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
399
+ let searchAvailable = joinSearchAvailability.get(join.entityId)
400
+ if (searchAvailable === undefined) {
401
+ searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
402
+ joinSearchAvailability.set(join.entityId, searchAvailable)
403
+ }
466
404
  if (!searchAvailable) return { applied: false, builder }
467
405
 
468
406
  const tokens = tokenizeText(String(filter.value), searchConfig)
@@ -533,8 +471,8 @@ export class BasicQueryEngine implements QueryEngine {
533
471
  // today's complete selection (base fields + CF projections + extension joins).
534
472
  // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
535
473
  // candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
536
- // twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
537
- // so the second pass hits no extra DB calls.
474
+ // twice is cheap: every `columnExists`/`tableExists` check is memoized on
475
+ // `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
538
476
  const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
539
477
  const isSortKeysProjection = projection === 'sortKeys'
540
478
  let q: AnyBuilder = db.selectFrom(table as any)
@@ -1198,6 +1136,51 @@ export class BasicQueryEngine implements QueryEngine {
1198
1136
  return present
1199
1137
  }
1200
1138
 
1139
+ private async tableExists(table: string): Promise<boolean> {
1140
+ if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
1141
+ const db = this.getDb()
1142
+ const exists = await db
1143
+ .selectFrom('information_schema.tables' as any)
1144
+ .select(sql<number>`1`.as('one'))
1145
+ .where('table_name' as any, '=', table)
1146
+ .limit(1)
1147
+ .executeTakeFirst()
1148
+ const present = !!exists
1149
+ this.tableCache.set(table, present)
1150
+ return present
1151
+ }
1152
+
1153
+ private async hasSearchTokens(
1154
+ entity: string,
1155
+ tenantId: string | null,
1156
+ orgScope?: { ids: string[]; includeNull: boolean } | null
1157
+ ): Promise<boolean> {
1158
+ try {
1159
+ const db = this.getDb()
1160
+ let query: AnyBuilder = db
1161
+ .selectFrom('search_tokens' as any)
1162
+ .select(sql<number>`1`.as('one'))
1163
+ .where('entity_type' as any, '=', entity)
1164
+ .limit(1)
1165
+ if (tenantId !== undefined) {
1166
+ query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
1167
+ }
1168
+ if (orgScope) {
1169
+ query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
1170
+ }
1171
+ const row = await query.executeTakeFirst()
1172
+ return !!row
1173
+ } catch (err) {
1174
+ this.logSearchDebug('search:has-tokens-error', {
1175
+ entity,
1176
+ tenantId,
1177
+ organizationScope: orgScope,
1178
+ error: err instanceof Error ? err.message : String(err),
1179
+ })
1180
+ return false
1181
+ }
1182
+ }
1183
+
1201
1184
  private applySearchTokens(
1202
1185
  q: AnyBuilder,
1203
1186
  opts: {
@@ -108,10 +108,11 @@ export type QueryOptions = {
108
108
  * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise
109
109
  * queries return cross-tenant rows.
110
110
  *
111
- * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine
112
- * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read
113
- * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does
114
- * not match the standard `organization_id = X AND tenant_id = Y` shape.
111
+ * When this flag is set, the hybrid query engine delegates to the basic engine, which means
112
+ * custom-field (`cf:*`) filters/sorts, `search_tokens` fulltext filtering, and vector-search
113
+ * branches are BYPASSED. Only use this on entities whose scoping does not match the standard
114
+ * `organization_id = X AND tenant_id = Y` shape and which do not rely on custom-field/search
115
+ * features.
115
116
  */
116
117
  omitAutomaticTenantOrgScope?: boolean
117
118
  // Soft-delete behavior: when false (default), rows with non-null deleted_at
@@ -5,11 +5,9 @@ describe('parseRedisUrl', () => {
5
5
  expect(parseRedisUrl('rediss://:secret@example.cache.amazonaws.com:6379/2')).toEqual({
6
6
  host: 'example.cache.amazonaws.com',
7
7
  port: 6379,
8
- username: undefined,
9
8
  password: 'secret',
10
9
  db: 2,
11
10
  tls: {},
12
- family: undefined,
13
11
  })
14
12
  })
15
13
 
@@ -17,23 +15,9 @@ describe('parseRedisUrl', () => {
17
15
  expect(parseRedisUrl('redis://:secret@localhost:6379/0')).toEqual({
18
16
  host: 'localhost',
19
17
  port: 6379,
20
- username: undefined,
21
18
  password: 'secret',
22
19
  db: 0,
23
20
  tls: undefined,
24
- family: undefined,
25
- })
26
- })
27
-
28
- it('preserves ACL credentials and supported address-family options', () => {
29
- expect(parseRedisUrl('rediss://app%2Duser:p%40ss@example.com:6380/4?family=6')).toEqual({
30
- host: 'example.com',
31
- port: 6380,
32
- username: 'app-user',
33
- password: 'p@ss',
34
- db: 4,
35
- tls: {},
36
- family: 6,
37
21
  })
38
22
  })
39
23
  })
@@ -21,11 +21,9 @@ const logger = createLogger('shared').child({ component: 'redis' })
21
21
  export type ParsedRedisConnection = {
22
22
  host: string
23
23
  port: number
24
- username?: string
25
24
  password?: string
26
25
  db?: number
27
26
  tls?: Record<string, unknown>
28
- family?: number
29
27
  }
30
28
 
31
29
  /**
@@ -61,8 +59,12 @@ export function getRedisUrlOrThrow(prefix?: string): string {
61
59
  }
62
60
 
63
61
  /**
64
- * Parse a redis:// URL into structured connection options suitable for
65
- * BullMQ and ioredis.
62
+ * Parse a redis:// URL into a {host, port, password, db} object
63
+ * suitable for BullMQ / ioredis structured connection options.
64
+ *
65
+ * @deprecated Prefer passing the full URL via `{ url: getRedisUrl(...) }` to
66
+ * BullMQ/ioredis — this preserves rediss://, username, database, and query
67
+ * params that structured parsing may lose. Kept for backward compatibility.
66
68
  */
67
69
  export function parseRedisUrl(url: string): ParsedRedisConnection {
68
70
  try {
@@ -70,17 +72,12 @@ export function parseRedisUrl(url: string): ParsedRedisConnection {
70
72
  const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''
71
73
  const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN
72
74
  const db = Number.isNaN(dbParsed) ? undefined : dbParsed
73
- const familyValue = parsed.searchParams.get('family')
74
- const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN
75
- const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined
76
75
  return {
77
76
  host: parsed.hostname || 'localhost',
78
77
  port: parseInt(parsed.port, 10) || 6379,
79
- username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
80
- password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
78
+ password: parsed.password || undefined,
81
79
  db,
82
80
  tls: parsed.protocol === 'rediss:' ? {} : undefined,
83
- family,
84
81
  }
85
82
  } catch {
86
83
  const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, '//<redacted>@')
@@ -1,12 +1,7 @@
1
1
  import {
2
- DEFAULT_SEARCH_MAX_FIELD_CHARS,
3
- DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
4
- DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD,
5
2
  DEFAULT_SEARCH_MIN_TOKEN_LENGTH,
6
- isSearchFieldBlocklisted,
7
3
  resolveSearchConfig,
8
4
  resolveSearchMinTokenLength,
9
- resolveSearchTokenLimits,
10
5
  } from '../config'
11
6
 
12
7
  describe('resolveSearchMinTokenLength', () => {
@@ -45,175 +40,3 @@ describe('resolveSearchMinTokenLength', () => {
45
40
  expect(resolveSearchConfig().minTokenLength).toBe(resolveSearchMinTokenLength())
46
41
  })
47
42
  })
48
-
49
- describe('OM_SEARCH_FIELD_BLOCKLIST parsing', () => {
50
- const originalValue = process.env.OM_SEARCH_FIELD_BLOCKLIST
51
-
52
- afterEach(() => {
53
- if (originalValue === undefined) {
54
- delete process.env.OM_SEARCH_FIELD_BLOCKLIST
55
- } else {
56
- process.env.OM_SEARCH_FIELD_BLOCKLIST = originalValue
57
- }
58
- })
59
-
60
- it('always includes the built-in defaults', () => {
61
- delete process.env.OM_SEARCH_FIELD_BLOCKLIST
62
- const config = resolveSearchConfig()
63
- expect(config.blocklistedFields).toEqual(expect.arrayContaining(['password', 'token', 'secret', 'hash']))
64
- expect(config.entityBlocklistedFields).toEqual({})
65
- })
66
-
67
- it('treats an unprefixed entry as global and lowercases it', () => {
68
- process.env.OM_SEARCH_FIELD_BLOCKLIST = ' Body , body '
69
- const config = resolveSearchConfig()
70
- expect(config.blocklistedFields.filter((entry) => entry === 'body')).toHaveLength(1)
71
- expect(config.entityBlocklistedFields).toEqual({})
72
- })
73
-
74
- it('routes an entityType@field entry into the per-entity map only', () => {
75
- process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@body'
76
- const config = resolveSearchConfig()
77
- expect(config.blocklistedFields).not.toContain('body')
78
- expect(config.entityBlocklistedFields).toEqual({ 'customers:customer_interaction': ['body'] })
79
- })
80
-
81
- it('groups several fields under the same entity type', () => {
82
- process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@body,customers:customer_interaction@subject'
83
- expect(resolveSearchConfig().entityBlocklistedFields).toEqual({
84
- 'customers:customer_interaction': ['body', 'subject'],
85
- })
86
- })
87
-
88
- it('ignores entries whose field part is empty', () => {
89
- process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@,@,body'
90
- const config = resolveSearchConfig()
91
- expect(config.blocklistedFields).toContain('body')
92
- expect(config.entityBlocklistedFields).toEqual({})
93
- })
94
-
95
- it('treats a leading separator as a global entry', () => {
96
- process.env.OM_SEARCH_FIELD_BLOCKLIST = '@body'
97
- const config = resolveSearchConfig()
98
- expect(config.blocklistedFields).toContain('body')
99
- expect(config.entityBlocklistedFields).toEqual({})
100
- })
101
-
102
- it('stores entity types that collide with inherited object keys as own entries', () => {
103
- process.env.OM_SEARCH_FIELD_BLOCKLIST = 'constructor@body,toString@summary,__proto__@notes'
104
- const config = resolveSearchConfig()
105
-
106
- expect(Object.getPrototypeOf(config.entityBlocklistedFields)).toBeNull()
107
- expect(config.entityBlocklistedFields?.['constructor']).toEqual(['body'])
108
- expect(config.entityBlocklistedFields?.['tostring']).toEqual(['summary'])
109
- expect(config.entityBlocklistedFields?.['__proto__']).toEqual(['notes'])
110
- expect(isSearchFieldBlocklisted('body', 'constructor', config)).toBe(true)
111
- expect(isSearchFieldBlocklisted('summary', 'toString', config)).toBe(true)
112
- expect(isSearchFieldBlocklisted('notes', '__proto__', config)).toBe(true)
113
- })
114
- })
115
-
116
- describe('search token limits', () => {
117
- const variableNames = [
118
- 'OM_SEARCH_MAX_FIELD_CHARS',
119
- 'OM_SEARCH_MAX_TOKENS_PER_FIELD',
120
- 'OM_SEARCH_MAX_TOKENS_PER_RECORD',
121
- ] as const
122
- const originalValues = Object.fromEntries(variableNames.map((name) => [name, process.env[name]]))
123
-
124
- afterEach(() => {
125
- for (const name of variableNames) {
126
- const original = originalValues[name]
127
- if (original === undefined) delete process.env[name]
128
- else process.env[name] = original
129
- }
130
- })
131
-
132
- it('uses safe defaults when limits are unset', () => {
133
- for (const name of variableNames) delete process.env[name]
134
-
135
- expect(resolveSearchTokenLimits(resolveSearchConfig())).toEqual({
136
- maxFieldChars: DEFAULT_SEARCH_MAX_FIELD_CHARS,
137
- maxTokensPerField: DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
138
- maxTokensPerRecord: DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD,
139
- })
140
- })
141
-
142
- it('accepts zero to disable individual limits', () => {
143
- for (const name of variableNames) process.env[name] = '0'
144
-
145
- expect(resolveSearchTokenLimits(resolveSearchConfig())).toEqual({
146
- maxFieldChars: 0,
147
- maxTokensPerField: 0,
148
- maxTokensPerRecord: 0,
149
- })
150
- })
151
-
152
- it('normalizes invalid custom config values to defaults', () => {
153
- expect(resolveSearchTokenLimits({
154
- enabled: true,
155
- minTokenLength: 3,
156
- enablePartials: true,
157
- hashAlgorithm: 'sha256',
158
- storeRawTokens: false,
159
- blocklistedFields: [],
160
- maxFieldChars: Number.NaN,
161
- maxTokensPerField: -1,
162
- maxTokensPerRecord: 4.8,
163
- })).toEqual({
164
- maxFieldChars: DEFAULT_SEARCH_MAX_FIELD_CHARS,
165
- maxTokensPerField: DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
166
- maxTokensPerRecord: 4,
167
- })
168
- })
169
- })
170
-
171
- describe('isSearchFieldBlocklisted', () => {
172
- const baseConfig = {
173
- enabled: true,
174
- minTokenLength: 3,
175
- enablePartials: true,
176
- hashAlgorithm: 'sha256' as const,
177
- storeRawTokens: false,
178
- }
179
-
180
- it('matches global entries by substring for any entity type', () => {
181
- const config = { ...baseConfig, blocklistedFields: ['password'], entityBlocklistedFields: {} }
182
- expect(isSearchFieldBlocklisted('password_hash', 'customers:person', config)).toBe(true)
183
- expect(isSearchFieldBlocklisted('password_hash', null, config)).toBe(true)
184
- expect(isSearchFieldBlocklisted('display_name', 'customers:person', config)).toBe(false)
185
- })
186
-
187
- it('applies an entity-scoped entry only to its own entity type', () => {
188
- const config = {
189
- ...baseConfig,
190
- blocklistedFields: [],
191
- entityBlocklistedFields: { 'customers:customer_interaction': ['body'] },
192
- }
193
- expect(isSearchFieldBlocklisted('body', 'customers:customer_interaction', config)).toBe(true)
194
- expect(isSearchFieldBlocklisted('body', 'customers:person', config)).toBe(false)
195
- expect(isSearchFieldBlocklisted('body', null, config)).toBe(false)
196
- })
197
-
198
- it('compares entity types case-insensitively', () => {
199
- const config = {
200
- ...baseConfig,
201
- blocklistedFields: [],
202
- entityBlocklistedFields: { 'customers:customer_interaction': ['body'] },
203
- }
204
- expect(isSearchFieldBlocklisted('BODY', ' Customers:Customer_Interaction ', config)).toBe(true)
205
- })
206
-
207
- it('ignores inherited Object.prototype keys when looking up an entity type', () => {
208
- const config = { ...baseConfig, blocklistedFields: [], entityBlocklistedFields: {} }
209
- expect(isSearchFieldBlocklisted('body', 'constructor', config)).toBe(false)
210
- expect(isSearchFieldBlocklisted('body', 'toString', config)).toBe(false)
211
- expect(isSearchFieldBlocklisted('body', '__proto__', config)).toBe(false)
212
- })
213
-
214
- it('tolerates a config without the per-entity map', () => {
215
- const config = { ...baseConfig, blocklistedFields: ['secret'] }
216
- expect(isSearchFieldBlocklisted('client_secret', 'customers:person', config)).toBe(true)
217
- expect(isSearchFieldBlocklisted('body', 'customers:person', config)).toBe(false)
218
- })
219
- })
@@ -1,6 +1,5 @@
1
1
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
2
  import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
3
- import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
4
3
 
5
4
  export type SearchConfig = {
6
5
  enabled: boolean
@@ -9,27 +8,12 @@ export type SearchConfig = {
9
8
  hashAlgorithm: 'sha256' | 'sha1' | 'md5'
10
9
  storeRawTokens: boolean
11
10
  blocklistedFields: string[]
12
- entityBlocklistedFields?: Record<string, string[]>
13
- maxFieldChars?: number
14
- maxTokensPerField?: number
15
- maxTokensPerRecord?: number
16
11
  }
17
12
 
18
13
  export const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3
19
- export const DEFAULT_SEARCH_MAX_FIELD_CHARS = 20_000
20
- export const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5_000
21
- export const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 20_000
22
-
23
- export type SearchTokenLimits = {
24
- maxFieldChars: number
25
- maxTokensPerField: number
26
- maxTokensPerRecord: number
27
- }
28
14
 
29
15
  const DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']
30
16
 
31
- const ENTITY_BLOCKLIST_SEPARATOR = '@'
32
-
33
17
  function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
34
18
  return parseBooleanWithDefault(raw, fallback)
35
19
  }
@@ -38,19 +22,6 @@ function parseNumber(raw: string | undefined, fallback: number, min = 1): number
38
22
  return parseNumberWithDefault(raw, fallback, { integer: true, min })
39
23
  }
40
24
 
41
- export function resolveSearchTokenLimits(config: SearchConfig): SearchTokenLimits {
42
- const resolveLimit = (value: number | undefined, fallback: number): number => {
43
- if (value === undefined) return fallback
44
- if (!Number.isFinite(value) || value < 0) return fallback
45
- return Math.trunc(value)
46
- }
47
- return {
48
- maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),
49
- maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),
50
- maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD),
51
- }
52
- }
53
-
54
25
  function parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {
55
26
  const value = (raw ?? '').trim().toLowerCase()
56
27
  if (value === 'sha1') return 'sha1'
@@ -58,93 +29,24 @@ function parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5'
58
29
  return 'sha256'
59
30
  }
60
31
 
61
- /**
62
- * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.
63
- *
64
- * Why: a deployment often needs to keep one large free-text column out of the token
65
- * index (e-mail bodies on `customers:customer_interaction`) while still indexing the
66
- * same-named column elsewhere. A flat global list cannot express that.
67
- *
68
- * How to apply: entries are comma-separated; an entry may carry an optional
69
- * `entityType@` prefix — `body` blocks the field everywhere, while
70
- * `customers:customer_interaction@body` blocks it only for that entity type. Entries
71
- * whose field part is empty are ignored so malformed env input cannot break indexing.
72
- */
73
- function parseFieldBlocklist(raw: string | undefined): {
74
- global: string[]
75
- byEntity: Record<string, string[]>
76
- } {
77
- const global: string[] = []
78
- const byEntity = new Map<string, string[]>()
79
-
80
- for (const rawEntry of parseCommaSeparatedList(raw)) {
81
- const entry = rawEntry.toLowerCase()
82
- const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)
83
- const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''
84
- const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry
85
- if (!field.length) continue
86
-
87
- if (!entityType.length) {
88
- if (!global.includes(field)) global.push(field)
89
- continue
90
- }
91
-
92
- const scoped = byEntity.get(entityType) ?? []
93
- if (!scoped.includes(field)) scoped.push(field)
94
- byEntity.set(entityType, scoped)
95
- }
96
-
97
- for (const fallback of DEFAULT_BLOCKLIST) {
98
- if (!global.includes(fallback)) global.push(fallback)
99
- }
100
-
101
- const scopedBlocklist = Object.create(null) as Record<string, string[]>
102
- for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields
103
-
104
- return { global, byEntity: scopedBlocklist }
105
- }
106
-
107
32
  export function resolveSearchConfig(): SearchConfig {
108
- const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)
109
33
  return {
110
34
  enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),
111
35
  minTokenLength: resolveSearchMinTokenLength(),
112
36
  enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),
113
37
  hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
114
38
  storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
115
- blocklistedFields: blocklist.global,
116
- entityBlocklistedFields: blocklist.byEntity,
117
- maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),
118
- maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),
119
- maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0),
39
+ blocklistedFields: (process.env.OM_SEARCH_FIELD_BLOCKLIST ?? '')
40
+ .split(',')
41
+ .map((entry) => entry.trim())
42
+ .filter((entry) => entry.length > 0)
43
+ .filter((value, index, arr) => arr.indexOf(value) === index)
44
+ .map((entry) => entry.toLowerCase())
45
+ .concat(DEFAULT_BLOCKLIST)
46
+ .filter((value, index, arr) => arr.indexOf(value) === index),
120
47
  }
121
48
  }
122
49
 
123
- /**
124
- * Single matcher for "should this field be kept out of the search index?".
125
- *
126
- * Why: the per-field token path and the `search_text` aggregate previously each
127
- * decided this on their own, and the aggregate simply never consulted the config —
128
- * so a blocklisted column's text came back into the index under the aggregate's
129
- * field name (#4624). Both paths now share this function so they cannot drift.
130
- *
131
- * How to apply: pass the document's field name and the entity type being indexed;
132
- * `entityType` may be omitted when unknown, in which case only global entries apply.
133
- * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).
134
- */
135
- export function isSearchFieldBlocklisted(
136
- field: string,
137
- entityType: string | null | undefined,
138
- config: SearchConfig,
139
- ): boolean {
140
- const lower = field.toLowerCase()
141
- if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true
142
- if (!entityType) return false
143
- const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]
144
- if (!Array.isArray(scoped) || !scoped.length) return false
145
- return scoped.some((blocked) => lower.includes(blocked))
146
- }
147
-
148
50
  /**
149
51
  * Browser-safe accessor for the minimum search token length.
150
52
  *