@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +6 -57
- package/build.mjs +4 -2
- package/dist/lib/auth/server.js +2 -15
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +43 -117
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -5
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +11 -2
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -63
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +0 -4
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/engine.js +11 -37
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +12 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -42
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +6 -48
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +0 -56
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +10 -2
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/index.js +3 -47
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +1 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/engine.js +40 -54
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +2 -7
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/config.js +2 -61
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +11 -23
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/index.js +1 -13
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +7 -12
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +1 -50
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +1 -8
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +0 -1
- package/dist/modules/registry.js.map +2 -2
- package/dist/security/enabledModulesRegistry.js +11 -56
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +9 -19
- package/src/lib/ai/llm-provider.ts +0 -33
- package/src/lib/auth/__tests__/server.test.ts +0 -23
- package/src/lib/auth/server.ts +2 -23
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
- package/src/lib/bootstrap/dynamicLoader.ts +65 -213
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
- package/src/lib/commands/command-bus.ts +1 -5
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
- package/src/lib/crud/enricher-runner.ts +11 -2
- package/src/lib/crud/factory.ts +1 -69
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +0 -3
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
- package/src/lib/data/engine.ts +16 -50
- package/src/lib/db/__tests__/mikro.test.ts +25 -0
- package/src/lib/db/mikro.ts +38 -13
- package/src/lib/db/pg-errors.ts +0 -58
- package/src/lib/di/container.ts +8 -72
- package/src/lib/email/__tests__/send.test.ts +1 -37
- package/src/lib/email/send.ts +0 -79
- package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
- package/src/lib/encryption/subscriber.ts +14 -2
- package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +2 -6
- package/src/lib/logger/__tests__/logger.test.ts +0 -77
- package/src/lib/logger/index.ts +1 -59
- package/src/lib/modules/registry.ts +0 -10
- package/src/lib/query/__tests__/engine.test.ts +13 -65
- package/src/lib/query/engine.ts +58 -75
- package/src/lib/query/types.ts +5 -4
- package/src/lib/redis/__tests__/connection.test.ts +0 -16
- package/src/lib/redis/connection.ts +7 -10
- package/src/lib/search/__tests__/config.test.ts +0 -177
- package/src/lib/search/config.ts +8 -106
- package/src/lib/search/tokenize.ts +11 -33
- package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
- package/src/lib/webhooks/index.ts +1 -19
- package/src/lib/webhooks/verify.ts +9 -17
- package/src/modules/__tests__/registry.test.ts +2 -10
- package/src/modules/analytics.ts +0 -7
- package/src/modules/customer-auth.ts +0 -1
- package/src/modules/encryption.ts +0 -3
- package/src/modules/navigation/backendChrome.ts +0 -15
- package/src/modules/overrides.ts +0 -103
- package/src/modules/payment_gateways/types.ts +0 -41
- package/src/modules/registry.ts +0 -3
- package/src/security/enabledModulesRegistry.ts +12 -64
- package/dist/lib/ai/safety-identifier.js +0 -32
- package/dist/lib/ai/safety-identifier.js.map +0 -7
- package/dist/lib/auth/organizationScope.js +0 -34
- package/dist/lib/auth/organizationScope.js.map +0 -7
- package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
- package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
- package/dist/lib/data/consistency.js +0 -19
- package/dist/lib/data/consistency.js.map +0 -7
- package/dist/lib/db/entityMetadata.js +0 -26
- package/dist/lib/db/entityMetadata.js.map +0 -7
- package/dist/lib/logger/extension.js +0 -29
- package/dist/lib/logger/extension.js.map +0 -7
- package/dist/lib/modules/surfaceFingerprint.js +0 -47
- package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
- package/dist/lib/query/ciphertext-search-warning.js +0 -45
- package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
- package/dist/lib/search/auto-indexing.js +0 -14
- package/dist/lib/search/auto-indexing.js.map +0 -7
- package/dist/lib/search/availability.js +0 -107
- package/dist/lib/search/availability.js.map +0 -7
- package/dist/lib/search/tokenLookup.js +0 -46
- package/dist/lib/search/tokenLookup.js.map +0 -7
- package/dist/lib/telemetry/runtime.js +0 -35
- package/dist/lib/telemetry/runtime.js.map +0 -7
- package/dist/lib/webhooks/body.js +0 -57
- package/dist/lib/webhooks/body.js.map +0 -7
- package/dist/lib/webhooks/inbound-types.js +0 -1
- package/dist/lib/webhooks/inbound-types.js.map +0 -7
- package/dist/modules/widgets/extension-points.js +0 -96
- package/dist/modules/widgets/extension-points.js.map +0 -7
- package/dist/security/featurePolicy.js +0 -62
- package/dist/security/featurePolicy.js.map +0 -7
- package/scripts/versionSource.cjs +0 -65
- package/src/lib/__tests__/versionSource.test.ts +0 -46
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
- package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
- package/src/lib/ai/safety-identifier.ts +0 -73
- package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
- package/src/lib/auth/organizationScope.ts +0 -65
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
- package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
- package/src/lib/data/__tests__/consistency.test.ts +0 -38
- package/src/lib/data/consistency.ts +0 -17
- package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
- package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
- package/src/lib/db/entityMetadata.ts +0 -43
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
- package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
- package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
- package/src/lib/i18n/__tests__/config.test.ts +0 -8
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
- package/src/lib/logger/extension.ts +0 -59
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
- package/src/lib/modules/surfaceFingerprint.ts +0 -87
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
- package/src/lib/query/ciphertext-search-warning.ts +0 -95
- package/src/lib/search/__tests__/availability.test.ts +0 -211
- package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
- package/src/lib/search/__tests__/tokenize.test.ts +0 -49
- package/src/lib/search/auto-indexing.ts +0 -22
- package/src/lib/search/availability.ts +0 -232
- package/src/lib/search/tokenLookup.ts +0 -133
- package/src/lib/telemetry/runtime.ts +0 -72
- package/src/lib/webhooks/__tests__/body.test.ts +0 -95
- package/src/lib/webhooks/body.ts +0 -70
- package/src/lib/webhooks/inbound-types.ts +0 -125
- package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
- package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
- package/src/modules/widgets/extension-points.ts +0 -524
- package/src/security/__tests__/featurePolicy.test.ts +0 -166
- package/src/security/featurePolicy.ts +0 -89
package/src/lib/query/engine.ts
CHANGED
|
@@ -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.
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
package/src/lib/query/types.ts
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
65
|
-
* BullMQ
|
|
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
|
-
|
|
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
|
-
})
|
package/src/lib/search/config.ts
CHANGED
|
@@ -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:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
*
|