@open-mercato/shared 0.6.7-develop.6706.1.b3a4c759bb → 0.6.7-develop.6726.1.983ae8a07e
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 +35 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +35 -30
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +15 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +31 -0
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/config.js +38 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/package.json +6 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +55 -42
- package/src/lib/encryption/tenantDataEncryptionService.ts +16 -2
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +41 -0
- package/src/lib/search/__tests__/config.test.ts +118 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/config.ts +78 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/navigation/backendChrome.ts +14 -0
- package/src/modules/overrides.ts +103 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeColumnName,
|
|
3
|
+
resetCiphertextLikeWarnCache,
|
|
4
|
+
warnOnCiphertextLikeFallback,
|
|
5
|
+
} from '../ciphertext-search-warning'
|
|
6
|
+
import { createLogger } from '../../logger'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../logger', () => {
|
|
9
|
+
const warn = jest.fn()
|
|
10
|
+
const debug = jest.fn()
|
|
11
|
+
const child = jest.fn(() => ({ warn, debug }))
|
|
12
|
+
return { createLogger: jest.fn(() => ({ child })), __warn: warn, __debug: debug }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock; __debug: jest.Mock }
|
|
16
|
+
|
|
17
|
+
function createService(encryptedFields: string[], overrides: Record<string, unknown> = {}) {
|
|
18
|
+
return {
|
|
19
|
+
isEnabled: () => true,
|
|
20
|
+
getEncryptedFieldNames: jest.fn(async () => encryptedFields),
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('warnOnCiphertextLikeFallback', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
resetCiphertextLikeWarnCache()
|
|
28
|
+
loggerModule.__warn.mockClear()
|
|
29
|
+
loggerModule.__debug.mockClear()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('warns when the filtered column is covered by an encryption map', async () => {
|
|
33
|
+
const service = createService(['email', 'display_name'])
|
|
34
|
+
await warnOnCiphertextLikeFallback({
|
|
35
|
+
entity: 'customers:customer_entity',
|
|
36
|
+
fields: ['display_name'],
|
|
37
|
+
tenantId: 'tenant-1',
|
|
38
|
+
reason: 'no-search-tokens',
|
|
39
|
+
service,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
43
|
+
const [message, payload] = loggerModule.__warn.mock.calls[0]
|
|
44
|
+
expect(message).toBe('Text search filter cannot match an encrypted column')
|
|
45
|
+
expect(payload).toMatchObject({
|
|
46
|
+
entity: 'customers:customer_entity',
|
|
47
|
+
field: 'display_name',
|
|
48
|
+
reason: 'no-search-tokens',
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('stays quiet for columns outside the encryption map', async () => {
|
|
53
|
+
await warnOnCiphertextLikeFallback({
|
|
54
|
+
entity: 'currencies:currency',
|
|
55
|
+
fields: ['code', 'symbol'],
|
|
56
|
+
tenantId: 'tenant-1',
|
|
57
|
+
reason: 'no-search-tokens',
|
|
58
|
+
service: createService(['unrelated']),
|
|
59
|
+
})
|
|
60
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not cache a non-encrypted lookup as already warned', async () => {
|
|
64
|
+
await warnOnCiphertextLikeFallback({
|
|
65
|
+
entity: 'auth:user', fields: ['email'], tenantId: 'tenant-1', reason: 'no-search-tokens', service: createService([]),
|
|
66
|
+
})
|
|
67
|
+
await warnOnCiphertextLikeFallback({
|
|
68
|
+
entity: 'auth:user', fields: ['email'], tenantId: 'tenant-1', reason: 'no-search-tokens', service: createService(['email']),
|
|
69
|
+
})
|
|
70
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('matches camelCase filter fields against snake_case map entries', async () => {
|
|
74
|
+
await warnOnCiphertextLikeFallback({
|
|
75
|
+
entity: 'checkout:checkout_transaction',
|
|
76
|
+
fields: ['firstName'],
|
|
77
|
+
tenantId: null,
|
|
78
|
+
reason: 'no-search-tokens',
|
|
79
|
+
service: createService(['first_name']),
|
|
80
|
+
})
|
|
81
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({ field: 'first_name' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('warns once per entity, tenant and field', async () => {
|
|
86
|
+
const service = createService(['email'])
|
|
87
|
+
const params = {
|
|
88
|
+
entity: 'auth:user',
|
|
89
|
+
fields: ['email'],
|
|
90
|
+
tenantId: 'tenant-1',
|
|
91
|
+
reason: 'no-search-tokens' as const,
|
|
92
|
+
service,
|
|
93
|
+
}
|
|
94
|
+
await warnOnCiphertextLikeFallback(params)
|
|
95
|
+
await warnOnCiphertextLikeFallback(params)
|
|
96
|
+
await warnOnCiphertextLikeFallback(params)
|
|
97
|
+
|
|
98
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
99
|
+
expect(service.getEncryptedFieldNames).toHaveBeenCalledTimes(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('warns separately for a different tenant', async () => {
|
|
103
|
+
const service = createService(['email'])
|
|
104
|
+
await warnOnCiphertextLikeFallback({
|
|
105
|
+
entity: 'auth:user', fields: ['email'], tenantId: 'tenant-1', reason: 'no-search-tokens', service,
|
|
106
|
+
})
|
|
107
|
+
await warnOnCiphertextLikeFallback({
|
|
108
|
+
entity: 'auth:user', fields: ['email'], tenantId: 'tenant-2', reason: 'no-search-tokens', service,
|
|
109
|
+
})
|
|
110
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(2)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('does nothing when encryption is unavailable or disabled', async () => {
|
|
114
|
+
await warnOnCiphertextLikeFallback({
|
|
115
|
+
entity: 'auth:user', fields: ['email'], tenantId: null, reason: 'no-search-tokens', service: null,
|
|
116
|
+
})
|
|
117
|
+
await warnOnCiphertextLikeFallback({
|
|
118
|
+
entity: 'auth:user',
|
|
119
|
+
fields: ['email'],
|
|
120
|
+
tenantId: null,
|
|
121
|
+
reason: 'no-search-tokens',
|
|
122
|
+
service: createService(['email'], { isEnabled: () => false }),
|
|
123
|
+
})
|
|
124
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('ignores custom-field filters', async () => {
|
|
128
|
+
const service = createService(['cf:secret_note'])
|
|
129
|
+
await warnOnCiphertextLikeFallback({
|
|
130
|
+
entity: 'example:todo', fields: ['cf:secret_note'], tenantId: null, reason: 'no-search-tokens', service,
|
|
131
|
+
})
|
|
132
|
+
expect(service.getEncryptedFieldNames).not.toHaveBeenCalled()
|
|
133
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reports the disabled-search reason with its own hint', async () => {
|
|
137
|
+
await warnOnCiphertextLikeFallback({
|
|
138
|
+
entity: 'auth:user', fields: ['email'], tenantId: null, reason: 'search-disabled', service: createService(['email']),
|
|
139
|
+
})
|
|
140
|
+
expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({ reason: 'search-disabled' })
|
|
141
|
+
expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('OM_SEARCH_ENABLED')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reports values that are too short to tokenize', async () => {
|
|
145
|
+
await warnOnCiphertextLikeFallback({
|
|
146
|
+
entity: 'auth:user', fields: ['email'], tenantId: null, reason: 'no-indexable-tokens', service: createService(['email']),
|
|
147
|
+
})
|
|
148
|
+
expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({ reason: 'no-indexable-tokens' })
|
|
149
|
+
expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('OM_SEARCH_MIN_LEN')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('never rethrows when the encryption lookup fails', async () => {
|
|
153
|
+
const service = {
|
|
154
|
+
isEnabled: () => true,
|
|
155
|
+
getEncryptedFieldNames: jest.fn(async () => { throw new Error('kms down') }),
|
|
156
|
+
}
|
|
157
|
+
await expect(warnOnCiphertextLikeFallback({
|
|
158
|
+
entity: 'auth:user', fields: ['email'], tenantId: null, reason: 'no-search-tokens', service,
|
|
159
|
+
})).resolves.toBeUndefined()
|
|
160
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
161
|
+
expect(loggerModule.__debug).toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('normalizeColumnName', () => {
|
|
166
|
+
it('converts camelCase to snake_case and lowercases', () => {
|
|
167
|
+
expect(normalizeColumnName('firstName')).toBe('first_name')
|
|
168
|
+
expect(normalizeColumnName('primaryEmail')).toBe('primary_email')
|
|
169
|
+
expect(normalizeColumnName('display_name')).toBe('display_name')
|
|
170
|
+
expect(normalizeColumnName('Email')).toBe('email')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('logger wiring', () => {
|
|
175
|
+
it('uses the shared query logger namespace', () => {
|
|
176
|
+
expect(createLogger).toHaveBeenCalledWith('shared')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createLogger } from '../logger'
|
|
2
|
+
import type { EntityId } from '../../modules/entities'
|
|
3
|
+
|
|
4
|
+
const logger = createLogger('shared').child({ component: 'query' })
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal slice of `TenantDataEncryptionService` needed to decide whether a
|
|
8
|
+
* text filter targets an encrypted column.
|
|
9
|
+
*/
|
|
10
|
+
export type CiphertextWarningEncryptionService = {
|
|
11
|
+
getEncryptedFieldNames?: (
|
|
12
|
+
entityId: EntityId,
|
|
13
|
+
tenantId?: string | null,
|
|
14
|
+
organizationId?: string | null,
|
|
15
|
+
) => Promise<readonly string[]>
|
|
16
|
+
isEnabled?: () => boolean
|
|
17
|
+
} | null | undefined
|
|
18
|
+
|
|
19
|
+
export type CiphertextLikeFallbackReason = 'no-indexable-tokens' | 'no-search-tokens' | 'search-disabled'
|
|
20
|
+
|
|
21
|
+
// One warning per entity/tenant/field per process — the fallback repeats on
|
|
22
|
+
// every request, and an operator only needs to learn about it once.
|
|
23
|
+
const WARN_CACHE_CAP = 5000
|
|
24
|
+
const warned = new Set<string>()
|
|
25
|
+
|
|
26
|
+
const warnKey = (entity: string, tenantId: string | null, field: string): string =>
|
|
27
|
+
`${entity}|${tenantId ?? 'null'}|${field}`
|
|
28
|
+
|
|
29
|
+
export const normalizeColumnName = (value: string): string =>
|
|
30
|
+
value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
31
|
+
|
|
32
|
+
/** Test seam: the warn-once cache is process-global by design. */
|
|
33
|
+
export function resetCiphertextLikeWarnCache(): void {
|
|
34
|
+
warned.clear()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Warn when a `like`/`ilike` filter is about to run against a column that an
|
|
39
|
+
* encryption map covers while the token index is unavailable.
|
|
40
|
+
*
|
|
41
|
+
* Without this the query degrades silently: the predicate compares a plaintext
|
|
42
|
+
* pattern against ciphertext, matches nothing, and the endpoint returns an
|
|
43
|
+
* empty page that is indistinguishable from a genuine no-result. Issue #2990.
|
|
44
|
+
*
|
|
45
|
+
* Never throws and never blocks the query — a failure here is logged at debug
|
|
46
|
+
* level and the caller proceeds unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export async function warnOnCiphertextLikeFallback(params: {
|
|
49
|
+
entity: string
|
|
50
|
+
fields: readonly string[]
|
|
51
|
+
tenantId: string | null
|
|
52
|
+
reason: CiphertextLikeFallbackReason
|
|
53
|
+
service: CiphertextWarningEncryptionService
|
|
54
|
+
}): Promise<void> {
|
|
55
|
+
const { entity, tenantId, reason, service } = params
|
|
56
|
+
if (!service || service.isEnabled?.() === false) return
|
|
57
|
+
if (typeof service.getEncryptedFieldNames !== 'function') return
|
|
58
|
+
|
|
59
|
+
const candidates = params.fields
|
|
60
|
+
.filter((field) => typeof field === 'string' && !field.startsWith('cf:'))
|
|
61
|
+
.map((field) => normalizeColumnName(field))
|
|
62
|
+
.filter((field, index, all) => field.length > 0 && all.indexOf(field) === index)
|
|
63
|
+
.filter((field) => !warned.has(warnKey(entity, tenantId, field)))
|
|
64
|
+
if (!candidates.length) return
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// `organizationId: null` resolves the global map plus every per-organization
|
|
68
|
+
// override, so a field encrypted for any organization is reported.
|
|
69
|
+
const encrypted = new Set(
|
|
70
|
+
(await service.getEncryptedFieldNames(entity, tenantId, null)).map((field) =>
|
|
71
|
+
normalizeColumnName(String(field)),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
for (const field of candidates) {
|
|
75
|
+
if (!encrypted.has(field)) continue
|
|
76
|
+
if (warned.size >= WARN_CACHE_CAP) warned.clear()
|
|
77
|
+
warned.add(warnKey(entity, tenantId, field))
|
|
78
|
+
logger.warn('Text search filter cannot match an encrypted column', {
|
|
79
|
+
entity,
|
|
80
|
+
field,
|
|
81
|
+
reason,
|
|
82
|
+
hint: reason === 'search-disabled'
|
|
83
|
+
? 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.'
|
|
84
|
+
: reason === 'no-indexable-tokens'
|
|
85
|
+
? 'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.'
|
|
86
|
+
: 'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.debug('Ciphertext search warning check failed', {
|
|
91
|
+
entity,
|
|
92
|
+
err: err instanceof Error ? err.message : String(err),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type CustomFieldDefinitionRow,
|
|
21
21
|
type ResolvedCustomFieldDefinitions,
|
|
22
22
|
} from '../crud/custom-field-definition-index'
|
|
23
|
+
import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
23
24
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
24
25
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
25
26
|
import { createLogger } from '../logger'
|
|
@@ -333,6 +334,46 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
333
334
|
organizationScope: orgScope,
|
|
334
335
|
})
|
|
335
336
|
}
|
|
337
|
+
const fallbackFields = searchFilters
|
|
338
|
+
.filter((filter) => !searchActive || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
339
|
+
.map((filter) => String(filter.field))
|
|
340
|
+
if (fallbackFields.length) {
|
|
341
|
+
await warnOnCiphertextLikeFallback({
|
|
342
|
+
entity: String(entity),
|
|
343
|
+
fields: fallbackFields,
|
|
344
|
+
tenantId: opts.tenantId ?? null,
|
|
345
|
+
// `searchEnabled` also folds in the missing-table and
|
|
346
|
+
// omitAutomaticTenantOrgScope cases, which are "no usable tokens"
|
|
347
|
+
// rather than "the operator switched search off".
|
|
348
|
+
reason: searchActive
|
|
349
|
+
? 'no-indexable-tokens'
|
|
350
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
351
|
+
service: this.getEncryptionService(),
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
for (const [alias, joinedFilters] of joinFilters) {
|
|
356
|
+
const filters = joinedFilters.filter((entry) => entry.op === 'like' || entry.op === 'ilike')
|
|
357
|
+
if (!filters.length) continue
|
|
358
|
+
const join = joinMap.get(alias)
|
|
359
|
+
if (!join?.entityId) continue
|
|
360
|
+
const hasJoinedTokens = searchEnabled
|
|
361
|
+
? await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
362
|
+
: false
|
|
363
|
+
joinSearchAvailability.set(join.entityId, hasJoinedTokens)
|
|
364
|
+
const fallbackFields = filters
|
|
365
|
+
.filter((filter) => !hasJoinedTokens || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
366
|
+
.map((filter) => filter.column)
|
|
367
|
+
if (!fallbackFields.length) continue
|
|
368
|
+
await warnOnCiphertextLikeFallback({
|
|
369
|
+
entity: join.entityId,
|
|
370
|
+
fields: fallbackFields,
|
|
371
|
+
tenantId: opts.tenantId ?? null,
|
|
372
|
+
reason: hasJoinedTokens
|
|
373
|
+
? 'no-indexable-tokens'
|
|
374
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
375
|
+
service: this.getEncryptionService(),
|
|
376
|
+
})
|
|
336
377
|
}
|
|
337
378
|
const recordIdColumn = qualify('id')
|
|
338
379
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_SEARCH_MIN_TOKEN_LENGTH,
|
|
3
|
+
isSearchFieldBlocklisted,
|
|
3
4
|
resolveSearchConfig,
|
|
4
5
|
resolveSearchMinTokenLength,
|
|
5
6
|
} from '../config'
|
|
@@ -40,3 +41,120 @@ describe('resolveSearchMinTokenLength', () => {
|
|
|
40
41
|
expect(resolveSearchConfig().minTokenLength).toBe(resolveSearchMinTokenLength())
|
|
41
42
|
})
|
|
42
43
|
})
|
|
44
|
+
|
|
45
|
+
describe('OM_SEARCH_FIELD_BLOCKLIST parsing', () => {
|
|
46
|
+
const originalValue = process.env.OM_SEARCH_FIELD_BLOCKLIST
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
if (originalValue === undefined) {
|
|
50
|
+
delete process.env.OM_SEARCH_FIELD_BLOCKLIST
|
|
51
|
+
} else {
|
|
52
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = originalValue
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('always includes the built-in defaults', () => {
|
|
57
|
+
delete process.env.OM_SEARCH_FIELD_BLOCKLIST
|
|
58
|
+
const config = resolveSearchConfig()
|
|
59
|
+
expect(config.blocklistedFields).toEqual(expect.arrayContaining(['password', 'token', 'secret', 'hash']))
|
|
60
|
+
expect(config.entityBlocklistedFields).toEqual({})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('treats an unprefixed entry as global and lowercases it', () => {
|
|
64
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = ' Body , body '
|
|
65
|
+
const config = resolveSearchConfig()
|
|
66
|
+
expect(config.blocklistedFields.filter((entry) => entry === 'body')).toHaveLength(1)
|
|
67
|
+
expect(config.entityBlocklistedFields).toEqual({})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('routes an entityType@field entry into the per-entity map only', () => {
|
|
71
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@body'
|
|
72
|
+
const config = resolveSearchConfig()
|
|
73
|
+
expect(config.blocklistedFields).not.toContain('body')
|
|
74
|
+
expect(config.entityBlocklistedFields).toEqual({ 'customers:customer_interaction': ['body'] })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('groups several fields under the same entity type', () => {
|
|
78
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@body,customers:customer_interaction@subject'
|
|
79
|
+
expect(resolveSearchConfig().entityBlocklistedFields).toEqual({
|
|
80
|
+
'customers:customer_interaction': ['body', 'subject'],
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('ignores entries whose field part is empty', () => {
|
|
85
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = 'customers:customer_interaction@,@,body'
|
|
86
|
+
const config = resolveSearchConfig()
|
|
87
|
+
expect(config.blocklistedFields).toContain('body')
|
|
88
|
+
expect(config.entityBlocklistedFields).toEqual({})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('treats a leading separator as a global entry', () => {
|
|
92
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = '@body'
|
|
93
|
+
const config = resolveSearchConfig()
|
|
94
|
+
expect(config.blocklistedFields).toContain('body')
|
|
95
|
+
expect(config.entityBlocklistedFields).toEqual({})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('stores entity types that collide with inherited object keys as own entries', () => {
|
|
99
|
+
process.env.OM_SEARCH_FIELD_BLOCKLIST = 'constructor@body,toString@summary,__proto__@notes'
|
|
100
|
+
const config = resolveSearchConfig()
|
|
101
|
+
|
|
102
|
+
expect(Object.getPrototypeOf(config.entityBlocklistedFields)).toBeNull()
|
|
103
|
+
expect(config.entityBlocklistedFields?.['constructor']).toEqual(['body'])
|
|
104
|
+
expect(config.entityBlocklistedFields?.['tostring']).toEqual(['summary'])
|
|
105
|
+
expect(config.entityBlocklistedFields?.['__proto__']).toEqual(['notes'])
|
|
106
|
+
expect(isSearchFieldBlocklisted('body', 'constructor', config)).toBe(true)
|
|
107
|
+
expect(isSearchFieldBlocklisted('summary', 'toString', config)).toBe(true)
|
|
108
|
+
expect(isSearchFieldBlocklisted('notes', '__proto__', config)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('isSearchFieldBlocklisted', () => {
|
|
113
|
+
const baseConfig = {
|
|
114
|
+
enabled: true,
|
|
115
|
+
minTokenLength: 3,
|
|
116
|
+
enablePartials: true,
|
|
117
|
+
hashAlgorithm: 'sha256' as const,
|
|
118
|
+
storeRawTokens: false,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
it('matches global entries by substring for any entity type', () => {
|
|
122
|
+
const config = { ...baseConfig, blocklistedFields: ['password'], entityBlocklistedFields: {} }
|
|
123
|
+
expect(isSearchFieldBlocklisted('password_hash', 'customers:person', config)).toBe(true)
|
|
124
|
+
expect(isSearchFieldBlocklisted('password_hash', null, config)).toBe(true)
|
|
125
|
+
expect(isSearchFieldBlocklisted('display_name', 'customers:person', config)).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('applies an entity-scoped entry only to its own entity type', () => {
|
|
129
|
+
const config = {
|
|
130
|
+
...baseConfig,
|
|
131
|
+
blocklistedFields: [],
|
|
132
|
+
entityBlocklistedFields: { 'customers:customer_interaction': ['body'] },
|
|
133
|
+
}
|
|
134
|
+
expect(isSearchFieldBlocklisted('body', 'customers:customer_interaction', config)).toBe(true)
|
|
135
|
+
expect(isSearchFieldBlocklisted('body', 'customers:person', config)).toBe(false)
|
|
136
|
+
expect(isSearchFieldBlocklisted('body', null, config)).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('compares entity types case-insensitively', () => {
|
|
140
|
+
const config = {
|
|
141
|
+
...baseConfig,
|
|
142
|
+
blocklistedFields: [],
|
|
143
|
+
entityBlocklistedFields: { 'customers:customer_interaction': ['body'] },
|
|
144
|
+
}
|
|
145
|
+
expect(isSearchFieldBlocklisted('BODY', ' Customers:Customer_Interaction ', config)).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('ignores inherited Object.prototype keys when looking up an entity type', () => {
|
|
149
|
+
const config = { ...baseConfig, blocklistedFields: [], entityBlocklistedFields: {} }
|
|
150
|
+
expect(isSearchFieldBlocklisted('body', 'constructor', config)).toBe(false)
|
|
151
|
+
expect(isSearchFieldBlocklisted('body', 'toString', config)).toBe(false)
|
|
152
|
+
expect(isSearchFieldBlocklisted('body', '__proto__', config)).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('tolerates a config without the per-entity map', () => {
|
|
156
|
+
const config = { ...baseConfig, blocklistedFields: ['secret'] }
|
|
157
|
+
expect(isSearchFieldBlocklisted('client_secret', 'customers:person', config)).toBe(true)
|
|
158
|
+
expect(isSearchFieldBlocklisted('body', 'customers:person', config)).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findEntityIdsBySearchTokens,
|
|
3
|
+
findEntityIdsBySearchTokensCompat,
|
|
4
|
+
} from '../tokenLookup'
|
|
5
|
+
import { resolveSearchConfig } from '../config'
|
|
6
|
+
import { tokenizeText } from '../tokenize'
|
|
7
|
+
|
|
8
|
+
type KyselyCall = {
|
|
9
|
+
method: string
|
|
10
|
+
args: unknown[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createKyselyMock(rows: Array<{ entity_id: unknown }>) {
|
|
14
|
+
const calls: KyselyCall[] = []
|
|
15
|
+
const tableNameRef: { value: string | null } = { value: null }
|
|
16
|
+
const builder: Record<string, unknown> = {}
|
|
17
|
+
const passthrough = (method: string) =>
|
|
18
|
+
(...args: unknown[]) => {
|
|
19
|
+
calls.push({ method, args })
|
|
20
|
+
return builder
|
|
21
|
+
}
|
|
22
|
+
builder.select = passthrough('select')
|
|
23
|
+
builder.where = passthrough('where')
|
|
24
|
+
builder.groupBy = passthrough('groupBy')
|
|
25
|
+
builder.having = passthrough('having')
|
|
26
|
+
builder.execute = jest.fn(async () => rows)
|
|
27
|
+
|
|
28
|
+
const db = {
|
|
29
|
+
selectFrom: (table: string) => {
|
|
30
|
+
tableNameRef.value = table
|
|
31
|
+
return builder
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
return { db: db as never, calls, tableNameRef, builder }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function compileSql(raw: unknown): string {
|
|
38
|
+
if (raw && typeof raw === 'object' && 'toOperationNode' in raw && typeof raw.toOperationNode === 'function') {
|
|
39
|
+
const node = raw.toOperationNode() as { sqlFragments?: unknown }
|
|
40
|
+
return Array.isArray(node.sqlFragments) ? node.sqlFragments.join(' ? ') : ''
|
|
41
|
+
}
|
|
42
|
+
if (raw && typeof raw === 'object' && 'sql' in raw) return String(raw.sql)
|
|
43
|
+
return String(raw)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rawWhereFragments(calls: KyselyCall[]): string[] {
|
|
47
|
+
return calls
|
|
48
|
+
.filter((call) => call.method === 'where' && call.args.length === 1)
|
|
49
|
+
.map((call) => compileSql(call.args[0]))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('findEntityIdsBySearchTokens', () => {
|
|
53
|
+
const baseInput = {
|
|
54
|
+
entityType: 'customers:customer_entity',
|
|
55
|
+
query: 'Hello',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('skips the lookup for a blank query', async () => {
|
|
59
|
+
const { db, builder } = createKyselyMock([])
|
|
60
|
+
const result = await findEntityIdsBySearchTokens({ ...baseInput, db, query: ' ' })
|
|
61
|
+
expect(result).toEqual({ matched: false, reason: 'empty-query' })
|
|
62
|
+
expect(builder.execute).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('skips the lookup when token search is disabled', async () => {
|
|
66
|
+
const { db, builder } = createKyselyMock([])
|
|
67
|
+
const result = await findEntityIdsBySearchTokens({
|
|
68
|
+
...baseInput,
|
|
69
|
+
db,
|
|
70
|
+
config: { ...resolveSearchConfig(), enabled: false },
|
|
71
|
+
})
|
|
72
|
+
expect(result).toEqual({ matched: false, reason: 'search-disabled' })
|
|
73
|
+
expect(builder.execute).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('skips the lookup when the query yields no indexable tokens', async () => {
|
|
77
|
+
const { db, builder } = createKyselyMock([])
|
|
78
|
+
const result = await findEntityIdsBySearchTokens({ ...baseInput, db, query: '!' })
|
|
79
|
+
expect(result).toEqual({ matched: false, reason: 'no-tokens' })
|
|
80
|
+
expect(builder.execute).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('requires every query token to be present on the record', async () => {
|
|
84
|
+
const { db, calls } = createKyselyMock([{ entity_id: 'a' }])
|
|
85
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db, query: 'ada lovelace' })
|
|
86
|
+
|
|
87
|
+
const expectedHashes = tokenizeText('ada lovelace', resolveSearchConfig()).hashes
|
|
88
|
+
const hashFilter = calls.find((call) => call.args[0] === 'token_hash' && call.args[1] === 'in')
|
|
89
|
+
expect(hashFilter?.args[2]).toEqual(expectedHashes)
|
|
90
|
+
|
|
91
|
+
const having = calls.find((call) => call.method === 'having')
|
|
92
|
+
expect(compileSql(having?.args[0])).toContain('count(distinct token_hash) >=')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns the matched ids and drops non-string rows', async () => {
|
|
96
|
+
const { db, tableNameRef } = createKyselyMock([
|
|
97
|
+
{ entity_id: 'id-1' },
|
|
98
|
+
{ entity_id: null },
|
|
99
|
+
{ entity_id: 42 },
|
|
100
|
+
{ entity_id: '' },
|
|
101
|
+
{ entity_id: 'id-2' },
|
|
102
|
+
])
|
|
103
|
+
const result = await findEntityIdsBySearchTokens({ ...baseInput, db })
|
|
104
|
+
expect(result).toEqual({ matched: true, ids: ['id-1', 'id-2'] })
|
|
105
|
+
expect(tableNameRef.value).toBe('search_tokens')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('reports an empty match instead of a skip when nothing matched', async () => {
|
|
109
|
+
const { db } = createKyselyMock([])
|
|
110
|
+
const result = await findEntityIdsBySearchTokens({ ...baseInput, db })
|
|
111
|
+
expect(result).toEqual({ matched: true, ids: [] })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('narrows a single field with equality and multiple fields with IN', async () => {
|
|
115
|
+
const single = createKyselyMock([])
|
|
116
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db: single.db, fields: ['name'] })
|
|
117
|
+
expect(single.calls).toContainEqual({ method: 'where', args: ['field', '=', 'name'] })
|
|
118
|
+
|
|
119
|
+
const multi = createKyselyMock([])
|
|
120
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db: multi.db, fields: ['name', 'email'] })
|
|
121
|
+
expect(multi.calls).toContainEqual({ method: 'where', args: ['field', 'in', ['name', 'email']] })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('omits the field predicate when no fields are supplied', async () => {
|
|
125
|
+
const { db, calls } = createKyselyMock([])
|
|
126
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db, fields: [] })
|
|
127
|
+
expect(calls.some((call) => call.args[0] === 'field')).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('omits tenant and organization predicates when the scope is absent', async () => {
|
|
131
|
+
const { db, calls } = createKyselyMock([])
|
|
132
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db })
|
|
133
|
+
expect(rawWhereFragments(calls)).toEqual([])
|
|
134
|
+
expect(calls.some((call) => call.args[0] === 'organization_id')).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('emits a null-safe tenant predicate for an explicit null tenant', async () => {
|
|
138
|
+
const { db, calls } = createKyselyMock([])
|
|
139
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db, scope: { tenantId: null } })
|
|
140
|
+
expect(rawWhereFragments(calls).some((s) => s.includes('tenant_id is not distinct from'))).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('emits a null-safe organization predicate for an explicit null organization', async () => {
|
|
144
|
+
const { db, calls } = createKyselyMock([])
|
|
145
|
+
await findEntityIdsBySearchTokens({ ...baseInput, db, scope: { organizationId: null } })
|
|
146
|
+
expect(rawWhereFragments(calls).some((s) => s.includes('organization_id is not distinct from'))).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('prefers a concrete organization over the visible-organization list', async () => {
|
|
150
|
+
const { db, calls } = createKyselyMock([])
|
|
151
|
+
await findEntityIdsBySearchTokens({
|
|
152
|
+
...baseInput,
|
|
153
|
+
db,
|
|
154
|
+
scope: { organizationId: 'org-1', organizationIds: ['org-1', 'org-2'] },
|
|
155
|
+
})
|
|
156
|
+
expect(calls).toContainEqual({ method: 'where', args: ['organization_id', '=', 'org-1'] })
|
|
157
|
+
expect(calls.some((call) => call.args[1] === 'in' && call.args[0] === 'organization_id')).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('falls back to the visible-organization list when no organization is selected', async () => {
|
|
161
|
+
const { db, calls } = createKyselyMock([])
|
|
162
|
+
await findEntityIdsBySearchTokens({
|
|
163
|
+
...baseInput,
|
|
164
|
+
db,
|
|
165
|
+
scope: { organizationIds: ['org-1', 'org-2'] },
|
|
166
|
+
})
|
|
167
|
+
expect(calls).toContainEqual({ method: 'where', args: ['organization_id', 'in', ['org-1', 'org-2']] })
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('findEntityIdsBySearchTokensCompat', () => {
|
|
172
|
+
it('returns null only for a blank query', async () => {
|
|
173
|
+
const { db } = createKyselyMock([])
|
|
174
|
+
await expect(findEntityIdsBySearchTokensCompat({
|
|
175
|
+
db,
|
|
176
|
+
entityType: 'customers:customer_entity',
|
|
177
|
+
query: ' ',
|
|
178
|
+
})).resolves.toBeNull()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('returns an empty array for the remaining skip reasons', async () => {
|
|
182
|
+
const noTokens = createKyselyMock([])
|
|
183
|
+
await expect(findEntityIdsBySearchTokensCompat({
|
|
184
|
+
db: noTokens.db,
|
|
185
|
+
entityType: 'customers:customer_entity',
|
|
186
|
+
query: '!',
|
|
187
|
+
})).resolves.toEqual([])
|
|
188
|
+
|
|
189
|
+
const disabled = createKyselyMock([])
|
|
190
|
+
await expect(findEntityIdsBySearchTokensCompat({
|
|
191
|
+
db: disabled.db,
|
|
192
|
+
entityType: 'customers:customer_entity',
|
|
193
|
+
query: 'Hello',
|
|
194
|
+
config: { ...resolveSearchConfig(), enabled: false },
|
|
195
|
+
})).resolves.toEqual([])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('returns the matched ids', async () => {
|
|
199
|
+
const { db } = createKyselyMock([{ entity_id: 'id-1' }])
|
|
200
|
+
await expect(findEntityIdsBySearchTokensCompat({
|
|
201
|
+
db,
|
|
202
|
+
entityType: 'customers:customer_entity',
|
|
203
|
+
query: 'Hello',
|
|
204
|
+
})).resolves.toEqual(['id-1'])
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { parseBooleanToken } from '../boolean'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Module id backing the tenant-scoped vector auto-indexing switch. Kept as `vector`
|
|
5
|
+
* (rather than `search`) for backwards compatibility with rows written before the
|
|
6
|
+
* search module absorbed the vector settings.
|
|
7
|
+
*/
|
|
8
|
+
export const SEARCH_AUTO_INDEX_CONFIG_MODULE = 'vector'
|
|
9
|
+
export const SEARCH_AUTO_INDEX_CONFIG_KEY = 'auto_index_enabled'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Instance-wide kill switch for vector auto-indexing. Lives in `@open-mercato/shared`
|
|
13
|
+
* so consumers outside `@open-mercato/search` (for example the query index status page)
|
|
14
|
+
* can report vector coverage honestly without duplicating the env-var contract.
|
|
15
|
+
*/
|
|
16
|
+
export function envDisablesAutoIndexing(): boolean {
|
|
17
|
+
const raw =
|
|
18
|
+
process.env.OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING ??
|
|
19
|
+
process.env.DISABLE_VECTOR_SEARCH_AUTOINDEXING
|
|
20
|
+
if (!raw) return false
|
|
21
|
+
return parseBooleanToken(raw) === true
|
|
22
|
+
}
|