@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- 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 +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +72 -9
- 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 +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- 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/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.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 +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.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/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- 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/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +82 -6
- 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 +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -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/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Module, BackendRouteManifestEntry } from '../../../modules/registry'
|
|
2
|
+
import { getBackendRouteManifests } from '../../../modules/registry'
|
|
3
|
+
import { getModules } from '../registry'
|
|
4
|
+
import { getModuleSurfaceFingerprint } from '../surfaceFingerprint'
|
|
5
|
+
|
|
6
|
+
jest.mock('../registry', () => ({
|
|
7
|
+
getModules: jest.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.mock('../../../modules/registry', () => ({
|
|
11
|
+
getBackendRouteManifests: jest.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const mockGetModules = jest.mocked(getModules)
|
|
15
|
+
const mockGetBackendRouteManifests = jest.mocked(getBackendRouteManifests)
|
|
16
|
+
|
|
17
|
+
function route(pattern: string, overrides: Partial<BackendRouteManifestEntry> = {}): BackendRouteManifestEntry {
|
|
18
|
+
return {
|
|
19
|
+
moduleId: 'auth',
|
|
20
|
+
pattern,
|
|
21
|
+
title: 'Page',
|
|
22
|
+
load: async () => null,
|
|
23
|
+
...overrides,
|
|
24
|
+
} as BackendRouteManifestEntry
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ModuleSurface = string | { id: string; features: Array<{ id: string; module?: string }> }
|
|
28
|
+
|
|
29
|
+
function setSurface(modules: ModuleSurface[], routes: BackendRouteManifestEntry[]): void {
|
|
30
|
+
mockGetModules.mockReturnValue(modules.map((mod) => (typeof mod === 'string' ? { id: mod } : mod) as Module))
|
|
31
|
+
mockGetBackendRouteManifests.mockReturnValue(routes)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('getModuleSurfaceFingerprint', () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
jest.resetAllMocks()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('is stable for the same enabled modules and route manifest', () => {
|
|
40
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
41
|
+
const first = getModuleSurfaceFingerprint()
|
|
42
|
+
|
|
43
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
44
|
+
expect(getModuleSurfaceFingerprint()).toBe(first)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('changes when a module is enabled — the deploy that used to serve a stale nav', () => {
|
|
48
|
+
setSurface(['auth'], [route('/backend/dashboard')])
|
|
49
|
+
const before = getModuleSurfaceFingerprint()
|
|
50
|
+
|
|
51
|
+
setSurface(['auth', 'search'], [route('/backend/dashboard')])
|
|
52
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('changes when a backend route is added', () => {
|
|
56
|
+
setSurface(['auth'], [route('/backend/dashboard')])
|
|
57
|
+
const before = getModuleSurfaceFingerprint()
|
|
58
|
+
|
|
59
|
+
setSurface(['auth'], [route('/backend/dashboard'), route('/backend/search')])
|
|
60
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('changes when only a route metadata field changes', () => {
|
|
64
|
+
setSurface(['auth'], [route('/backend/dashboard', { title: 'Dashboard' })])
|
|
65
|
+
const before = getModuleSurfaceFingerprint()
|
|
66
|
+
|
|
67
|
+
setSurface(['auth'], [route('/backend/dashboard', { title: 'Overview' })])
|
|
68
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('changes when a module declares a new feature and no route moves', () => {
|
|
72
|
+
const routes = [route('/backend/dashboard')]
|
|
73
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }] }], routes)
|
|
74
|
+
const before = getModuleSurfaceFingerprint()
|
|
75
|
+
|
|
76
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }, { id: 'auth.manage' }] }], routes)
|
|
77
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('changes when a feature declares a different owning module', () => {
|
|
81
|
+
const routes = [route('/backend/dashboard')]
|
|
82
|
+
setSurface([{ id: 'auth', features: [{ id: 'analytics.view' }] }], routes)
|
|
83
|
+
const before = getModuleSurfaceFingerprint()
|
|
84
|
+
|
|
85
|
+
setSurface([{ id: 'auth', features: [{ id: 'analytics.view', module: 'reporting' }] }], routes)
|
|
86
|
+
expect(getModuleSurfaceFingerprint()).not.toBe(before)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ignores feature ordering within a module', () => {
|
|
90
|
+
const routes = [route('/backend/dashboard')]
|
|
91
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.view' }, { id: 'auth.manage' }] }], routes)
|
|
92
|
+
const forward = getModuleSurfaceFingerprint()
|
|
93
|
+
|
|
94
|
+
setSurface([{ id: 'auth', features: [{ id: 'auth.manage' }, { id: 'auth.view' }] }], routes)
|
|
95
|
+
expect(getModuleSurfaceFingerprint()).toBe(forward)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('ignores route manifest ordering', () => {
|
|
99
|
+
setSurface(['auth'], [route('/backend/a'), route('/backend/b')])
|
|
100
|
+
const forward = getModuleSurfaceFingerprint()
|
|
101
|
+
|
|
102
|
+
setSurface(['auth'], [route('/backend/b'), route('/backend/a')])
|
|
103
|
+
expect(getModuleSurfaceFingerprint()).toBe(forward)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('falls back to the route manifest alone when the module registry is not populated', () => {
|
|
107
|
+
mockGetModules.mockImplementation(() => {
|
|
108
|
+
throw new Error('[Bootstrap] Modules not registered.')
|
|
109
|
+
})
|
|
110
|
+
mockGetBackendRouteManifests.mockReturnValue([route('/backend/dashboard')])
|
|
111
|
+
|
|
112
|
+
expect(getModuleSurfaceFingerprint()).toMatch(/^[0-9a-f]{12}$/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('degrades to a constant rather than throwing when route metadata cannot be serialized', () => {
|
|
116
|
+
const circular = route('/backend/dashboard') as BackendRouteManifestEntry & { self?: unknown }
|
|
117
|
+
circular.self = circular
|
|
118
|
+
setSurface(['auth'], [circular])
|
|
119
|
+
|
|
120
|
+
expect(getModuleSurfaceFingerprint()).toBe('unknown')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -69,3 +69,13 @@ export function getModules(): Module[] {
|
|
|
69
69
|
}
|
|
70
70
|
return modules
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Non-throwing counterpart of `getModules()` for call sites that have a
|
|
75
|
+
* meaningful degraded behavior when bootstrap has not run — route unit tests
|
|
76
|
+
* exercise a single handler without `registerModules()`, and a hard throw there
|
|
77
|
+
* turns an unrelated assertion into a bootstrap error.
|
|
78
|
+
*/
|
|
79
|
+
export function tryGetModules(): Module[] | null {
|
|
80
|
+
return getGlobalModules()
|
|
81
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-time fingerprint of the registered module surface.
|
|
3
|
+
*
|
|
4
|
+
* Some cached payloads are derived from state that only changes when the app
|
|
5
|
+
* is redeployed — the enabled module set and the backend route manifest. That
|
|
6
|
+
* state has no database write to hang a tag invalidation off, so a cache key
|
|
7
|
+
* that omits it keeps serving the pre-deploy payload to anyone with a warm
|
|
8
|
+
* entry (see `/api/auth/admin/nav`, whose payload embeds
|
|
9
|
+
* `filterGrantsByEnabledModules(...)` computed at write time).
|
|
10
|
+
*
|
|
11
|
+
* Mixing this fingerprint into such keys makes new processes write to new
|
|
12
|
+
* keys, so old and new pods coexist during a rolling deploy without a purge
|
|
13
|
+
* step and without any ordering constraint between purge and rollout.
|
|
14
|
+
*
|
|
15
|
+
* Covered inputs: the enabled module ids, each module's declared `features`
|
|
16
|
+
* (which drive `filterGrantsByEnabledModules`, including the `*` superadmin
|
|
17
|
+
* expansion and off-convention prefixes), and every JSON-serializable field
|
|
18
|
+
* of the backend route manifest.
|
|
19
|
+
*
|
|
20
|
+
* NOT covered: a route's `icon`. It is a React element whose `type` is a
|
|
21
|
+
* function, which `JSON.stringify` drops — two different icon components
|
|
22
|
+
* serialize identically. Swapping an icon heals on the caller's TTL, not on
|
|
23
|
+
* the fingerprint. Callers MUST therefore still pass a `ttl`.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import type { Module } from '../../modules/registry'
|
|
27
|
+
import { getBackendRouteManifests } from '../../modules/registry'
|
|
28
|
+
import { getModules } from './registry'
|
|
29
|
+
|
|
30
|
+
const UNKNOWN_FINGERPRINT = 'unknown'
|
|
31
|
+
|
|
32
|
+
let cachedFingerprint: string | null = null
|
|
33
|
+
let cachedManifestsRef: unknown = null
|
|
34
|
+
let cachedModulesRef: unknown = null
|
|
35
|
+
|
|
36
|
+
function readModulesRef(): readonly Module[] | null {
|
|
37
|
+
try {
|
|
38
|
+
return getModules()
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byCodeUnit(a: string, b: string): number {
|
|
45
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function describeModules(modules: readonly Module[] | null): string[] {
|
|
49
|
+
if (!modules) return []
|
|
50
|
+
return modules
|
|
51
|
+
.map((mod) => {
|
|
52
|
+
const features = (Array.isArray(mod.features) ? mod.features : [])
|
|
53
|
+
.filter((feature) => feature && typeof feature.id === 'string' && feature.id.length > 0)
|
|
54
|
+
.map((feature) => `${feature.id}:${feature.module || mod.id}`)
|
|
55
|
+
.sort(byCodeUnit)
|
|
56
|
+
return JSON.stringify({ id: mod.id, features })
|
|
57
|
+
})
|
|
58
|
+
.sort(byCodeUnit)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getModuleSurfaceFingerprint(): string {
|
|
62
|
+
const manifests = getBackendRouteManifests()
|
|
63
|
+
const modulesRef = readModulesRef()
|
|
64
|
+
if (cachedFingerprint !== null && cachedManifestsRef === manifests && cachedModulesRef === modulesRef) {
|
|
65
|
+
return cachedFingerprint
|
|
66
|
+
}
|
|
67
|
+
// The manifest carries third-party route metadata and module-scope React
|
|
68
|
+
// elements, so serialization is not fully under this repo's control. Every
|
|
69
|
+
// other fallible step in the nav handler degrades rather than throws, and a
|
|
70
|
+
// throw here would blank the entire admin chrome — fall back to a constant,
|
|
71
|
+
// which is exactly the pre-fingerprint behavior.
|
|
72
|
+
let fingerprint: string
|
|
73
|
+
try {
|
|
74
|
+
const modules = describeModules(modulesRef)
|
|
75
|
+
const routes = manifests.map((route) => JSON.stringify(route)).sort(byCodeUnit)
|
|
76
|
+
fingerprint = createHash('sha1')
|
|
77
|
+
.update(JSON.stringify({ modules, routes }))
|
|
78
|
+
.digest('hex')
|
|
79
|
+
.slice(0, 12)
|
|
80
|
+
} catch {
|
|
81
|
+
fingerprint = UNKNOWN_FINGERPRINT
|
|
82
|
+
}
|
|
83
|
+
cachedManifestsRef = manifests
|
|
84
|
+
cachedModulesRef = modulesRef
|
|
85
|
+
cachedFingerprint = fingerprint
|
|
86
|
+
return fingerprint
|
|
87
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { BasicQueryEngine } from '../engine'
|
|
2
2
|
import { SortDir } from '../types'
|
|
3
3
|
import { registerModules } from '../../i18n/server'
|
|
4
|
+
import { clearSearchTokenPresenceCache } from '../../search/availability'
|
|
5
|
+
|
|
6
|
+
// The token-presence answer is cached process-wide (TTL); without clearing it,
|
|
7
|
+
// probe-count assertions would observe hits from earlier tests in this file.
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
clearSearchTokenPresenceCache()
|
|
10
|
+
})
|
|
4
11
|
|
|
5
12
|
// Mock modules with one entity extension
|
|
6
13
|
const mockModules = [
|
|
@@ -355,6 +362,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
355
362
|
const fakeDb = createFakeKysely({
|
|
356
363
|
customer_entities: [],
|
|
357
364
|
customer_people: [],
|
|
365
|
+
search_tokens: [{ one: 1 }],
|
|
366
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
358
367
|
'information_schema.columns': [
|
|
359
368
|
{ table_name: 'customer_entities', column_name: 'tenant_id' },
|
|
360
369
|
{ table_name: 'customer_people', column_name: 'id' },
|
|
@@ -362,8 +371,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
362
371
|
],
|
|
363
372
|
})
|
|
364
373
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
365
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
366
|
-
jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
367
374
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
368
375
|
|
|
369
376
|
await engine.query('customers:customer_entity', {
|
|
@@ -409,7 +416,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
409
416
|
],
|
|
410
417
|
})
|
|
411
418
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
412
|
-
jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
|
|
413
419
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
414
420
|
|
|
415
421
|
await engine.query('customers:customer_entity', {
|
|
@@ -445,10 +451,10 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
445
451
|
test('uses search tokens for index document fields on base entities', async () => {
|
|
446
452
|
const fakeDb = createFakeKysely({
|
|
447
453
|
todos: [],
|
|
454
|
+
search_tokens: [{ one: 1 }],
|
|
455
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
448
456
|
})
|
|
449
457
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
450
|
-
const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
|
|
451
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
452
458
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
453
459
|
|
|
454
460
|
await engine.query('example:todo', {
|
|
@@ -461,12 +467,15 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
461
467
|
page: { page: 1, pageSize: 10 },
|
|
462
468
|
})
|
|
463
469
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
|
|
469
|
-
)
|
|
470
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string; wheres: unknown[][] } }>
|
|
471
|
+
const tableProbe = calls.find((call) =>
|
|
472
|
+
call._ops.table === 'information_schema.tables' &&
|
|
473
|
+
call._ops.wheres.some((where) => where[0] === 'table_name' && where[2] === 'search_tokens'))
|
|
474
|
+
expect(tableProbe).toBeTruthy()
|
|
475
|
+
const tokenProbe = calls.find((call) =>
|
|
476
|
+
call._ops.table === 'search_tokens' &&
|
|
477
|
+
call._ops.wheres.some((where) => where[0] === 'entity_type' && where[2] === 'example:todo'))
|
|
478
|
+
expect(tokenProbe).toBeTruthy()
|
|
470
479
|
expect(applySearchTokensSpy).toHaveBeenCalledWith(
|
|
471
480
|
expect.anything(),
|
|
472
481
|
expect.objectContaining({
|
|
@@ -488,7 +497,6 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
488
497
|
],
|
|
489
498
|
})
|
|
490
499
|
const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
|
|
491
|
-
const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
|
|
492
500
|
const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
|
|
493
501
|
|
|
494
502
|
await engine.query('example:todo', {
|
|
@@ -501,7 +509,8 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
501
509
|
page: { page: 1, pageSize: 10 },
|
|
502
510
|
})
|
|
503
511
|
|
|
504
|
-
|
|
512
|
+
const calls = fakeDb._calls as Array<{ _ops: { table: string } }>
|
|
513
|
+
expect(calls.some((call) => call._ops.table === 'search_tokens')).toBe(false)
|
|
505
514
|
expect(applySearchTokensSpy).not.toHaveBeenCalled()
|
|
506
515
|
const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === 'todos')
|
|
507
516
|
expect(baseCall?._ops.wheres).toContainEqual(['todos.search_text', 'ilike', '%avision%'])
|
|
@@ -933,4 +942,47 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
933
942
|
expect(baseCall._ops.limits).toBe(10)
|
|
934
943
|
expect(baseCall._ops.offsets).toBe(10)
|
|
935
944
|
})
|
|
945
|
+
|
|
946
|
+
describe('search_tokens coverage probe (#4723 parity)', () => {
|
|
947
|
+
type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
|
|
948
|
+
|
|
949
|
+
const countProbes = (fakeDb: ProbeDbLog): number =>
|
|
950
|
+
fakeDb._calls.filter((call) => call._ops.table === 'search_tokens').length
|
|
951
|
+
|
|
952
|
+
const buildEngine = (fakeDb: unknown): BasicQueryEngine => new BasicQueryEngine(
|
|
953
|
+
{} as ConstructorParameters<typeof BasicQueryEngine>[0],
|
|
954
|
+
(() => fakeDb) as unknown as NonNullable<ConstructorParameters<typeof BasicQueryEngine>[1]>,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
const buildDb = () => createFakeKysely({
|
|
958
|
+
users: [],
|
|
959
|
+
'information_schema.tables': [{ table_name: 'search_tokens' }],
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
test('is skipped when the query carries no like/ilike filter', async () => {
|
|
963
|
+
const fakeDb = buildDb()
|
|
964
|
+
const engine = buildEngine(fakeDb)
|
|
965
|
+
|
|
966
|
+
await engine.query('auth:user', {
|
|
967
|
+
tenantId: 't1',
|
|
968
|
+
organizationId: 'org1',
|
|
969
|
+
filters: { is_active: { $eq: true } },
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
expect(countProbes(fakeDb)).toBe(0)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test('still runs when the query actually searches', async () => {
|
|
976
|
+
const fakeDb = buildDb()
|
|
977
|
+
const engine = buildEngine(fakeDb)
|
|
978
|
+
|
|
979
|
+
await engine.query('auth:user', {
|
|
980
|
+
tenantId: 't1',
|
|
981
|
+
organizationId: 'org1',
|
|
982
|
+
filters: { email: { $ilike: '%abc%' } },
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
expect(countProbes(fakeDb)).toBeGreaterThan(0)
|
|
986
|
+
})
|
|
987
|
+
})
|
|
936
988
|
})
|
|
@@ -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
|
+
}
|