@open-mercato/search 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/dist/di.js +7 -20
- package/dist/di.js.map +3 -3
- package/dist/lib/encryption-map-resolver.js +64 -0
- package/dist/lib/encryption-map-resolver.js.map +7 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js +2 -1
- package/dist/modules/search/subscribers/fulltext_upsert.js.map +2 -2
- package/dist/modules/search/subscribers/vector_delete.js +2 -1
- package/dist/modules/search/subscribers/vector_delete.js.map +2 -2
- package/dist/modules/search/subscribers/vector_upsert.js +2 -1
- package/dist/modules/search/subscribers/vector_upsert.js.map +2 -2
- package/package.json +5 -6
- package/src/__tests__/di-singletons.test.ts +15 -0
- package/src/__tests__/encryption-map-resolver.test.ts +200 -0
- package/src/__tests__/encryption-policy-process-coherence.test.ts +108 -0
- package/src/__tests__/fixtures/encryption-policy-process-worker.ts +51 -0
- package/src/di.ts +7 -41
- package/src/lib/encryption-map-resolver.ts +110 -0
- package/src/modules/search/subscribers/__tests__/scope-lookup-isolation.test.ts +53 -0
- package/src/modules/search/subscribers/fulltext_upsert.ts +2 -1
- package/src/modules/search/subscribers/vector_delete.ts +2 -1
- package/src/modules/search/subscribers/vector_upsert.ts +2 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:search] found
|
|
1
|
+
[build:search] found 99 entry points
|
|
2
2
|
[build:search] built successfully
|
package/dist/di.js
CHANGED
|
@@ -6,6 +6,8 @@ import { FullTextSearchStrategy } from "./strategies/fulltext.strategy.js";
|
|
|
6
6
|
import { createFulltextDriver } from "./fulltext/drivers/index.js";
|
|
7
7
|
import { SearchIndexer } from "./indexer/search-indexer.js";
|
|
8
8
|
import { createPresenterEnricher } from "./lib/presenter-enricher.js";
|
|
9
|
+
import { createEncryptionMapResolver } from "./lib/encryption-map-resolver.js";
|
|
10
|
+
import { createEncryptionMapResolver as createEncryptionMapResolver2 } from "./lib/encryption-map-resolver.js";
|
|
9
11
|
const FULLTEXT_DRIVER_KEY = "__omSearchFulltextDriver__";
|
|
10
12
|
function getSearchModuleGlobals() {
|
|
11
13
|
return globalThis;
|
|
@@ -14,26 +16,9 @@ function shouldExcludeEncryptedFields() {
|
|
|
14
16
|
const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? "").toLowerCase();
|
|
15
17
|
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
16
18
|
}
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return async (entityId) => {
|
|
21
|
-
const cached = cache.get(entityId);
|
|
22
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
23
|
-
return cached.entries;
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
const row = await db.selectFrom("encryption_maps").select(["fields_json"]).where("entity_id", "=", entityId).where("is_active", "=", true).where("deleted_at", "is", null).executeTakeFirst();
|
|
27
|
-
const fieldsJson = row?.fields_json;
|
|
28
|
-
const entries = Array.isArray(fieldsJson) ? fieldsJson.map((f) => ({
|
|
29
|
-
field: f.field,
|
|
30
|
-
hashField: f.hashField ?? null
|
|
31
|
-
})) : [];
|
|
32
|
-
cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
33
|
-
return entries;
|
|
34
|
-
} catch {
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
19
|
+
function createUnavailableEncryptionMapResolver() {
|
|
20
|
+
return async () => {
|
|
21
|
+
throw new Error("[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields");
|
|
37
22
|
};
|
|
38
23
|
}
|
|
39
24
|
function registerSearchModule(container, options) {
|
|
@@ -79,6 +64,7 @@ function registerSearchModule(container, options) {
|
|
|
79
64
|
const db = em.getKysely();
|
|
80
65
|
encryptionMapResolver = createEncryptionMapResolver(db);
|
|
81
66
|
} catch {
|
|
67
|
+
encryptionMapResolver = createUnavailableEncryptionMapResolver();
|
|
82
68
|
}
|
|
83
69
|
}
|
|
84
70
|
fulltextDriver = createFulltextDriver({
|
|
@@ -166,6 +152,7 @@ function addSearchStrategy(container, strategy) {
|
|
|
166
152
|
}
|
|
167
153
|
export {
|
|
168
154
|
addSearchStrategy,
|
|
155
|
+
createEncryptionMapResolver2 as createEncryptionMapResolver,
|
|
169
156
|
registerSearchModule
|
|
170
157
|
};
|
|
171
158
|
//# sourceMappingURL=di.js.map
|
package/dist/di.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/di.ts"],
|
|
4
|
-
"sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport type { FullTextSearchDriver } from './fulltext/types'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\n\nconst FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'\n\ntype SearchModuleGlobals = {\n [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver\n}\n\nfunction getSearchModuleGlobals(): SearchModuleGlobals {\n return globalThis as unknown as SearchModuleGlobals\n}\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\n/**\n * Create an encryption map resolver that queries the database.\n * Falls back to empty array if query fails.\n */\nfunction createEncryptionMapResolver(\n db: Kysely<any>,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n // Cache encryption maps per entity to avoid repeated queries\n const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()\n const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const cached = cache.get(entityId)\n if (cached && cached.expiresAt > Date.now()) {\n return cached.entries\n }\n\n try {\n const row = await db\n .selectFrom('encryption_maps' as any)\n .select(['fields_json' as any])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .executeTakeFirst() as { fields_json?: unknown } | undefined\n\n const fieldsJson = row?.fields_json\n const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)\n ? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({\n field: f.field,\n hashField: f.hashField ?? null,\n }))\n : []\n\n cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })\n return entries\n } catch {\n // Query failed, return empty array (don't exclude any fields)\n return []\n }\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n const excludeEncrypted = shouldExcludeEncryptedFields()\n const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n const g = getSearchModuleGlobals()\n\n // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is\n // OFF \u2014 in that case the driver holds no request-scoped Kysely reference. When the flag\n // is ON the encryptionMapResolver captures a per-request db handle and must stay\n // per-request to avoid cross-request data leaks.\n const canMemoize = singletonCacheEnabled && !excludeEncrypted\n\n let fulltextDriver: FullTextSearchDriver | null = canMemoize\n ? (g[FULLTEXT_DRIVER_KEY] ?? null)\n : null\n\n if (!fulltextDriver) {\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (excludeEncrypted) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n // Kysely not available, encrypted field filtering disabled\n }\n }\n\n fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (canMemoize && fulltextDriver) {\n g[FULLTEXT_DRIVER_KEY] = fulltextDriver\n }\n }\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,eAAe;AAGxB,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,4BAAmD;AAC5D,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,qBAAqB;AAkB9B,SAAS,+BAA+B;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { asValue } from 'awilix'\nimport type { Kysely } from 'kysely'\nimport type { FullTextSearchDriver } from './fulltext/types'\nimport { SearchService } from './service'\nimport { TokenSearchStrategy } from './strategies/token.strategy'\nimport { VectorSearchStrategy, type EmbeddingService } from './strategies/vector.strategy'\nimport { FullTextSearchStrategy } from './strategies/fulltext.strategy'\nimport { createFulltextDriver } from './fulltext/drivers'\nimport { SearchIndexer } from './indexer/search-indexer'\nimport type {\n SearchStrategy,\n ResultMergeConfig,\n SearchModuleConfig,\n SearchFieldPolicy,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from './types'\nimport type { VectorDriver } from './vector/types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from './queue/fulltext-indexing'\nimport type { VectorIndexJobPayload } from './queue/vector-indexing'\nimport type { EncryptionMapEntry } from './lib/field-policy'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { createPresenterEnricher } from './lib/presenter-enricher'\nimport { createEncryptionMapResolver } from './lib/encryption-map-resolver'\n\nexport { createEncryptionMapResolver } from './lib/encryption-map-resolver'\n\nconst FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'\n\ntype SearchModuleGlobals = {\n [FULLTEXT_DRIVER_KEY]?: FullTextSearchDriver\n}\n\nfunction getSearchModuleGlobals(): SearchModuleGlobals {\n return globalThis as unknown as SearchModuleGlobals\n}\n\n/**\n * Check if encrypted fields should be excluded from search indexing.\n * Controlled by SEARCH_EXCLUDE_ENCRYPTED_FIELDS environment variable.\n * Default: false (index all fields including decrypted data)\n */\nfunction shouldExcludeEncryptedFields(): boolean {\n const raw = (process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS ?? '').toLowerCase()\n return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'\n}\n\nfunction createUnavailableEncryptionMapResolver(): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n return async () => {\n throw new Error('[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields')\n }\n}\n\n/**\n * Container interface - minimal subset needed for registration.\n */\nexport interface SearchContainer {\n resolve<T = unknown>(name: string): T\n register(registrations: Record<string, unknown>): void\n}\n\n/**\n * Configuration options for search module registration.\n */\nexport type SearchModuleOptions = {\n /** Override default strategies to use */\n defaultStrategies?: SearchStrategyId[]\n /** Override merge configuration */\n mergeConfig?: ResultMergeConfig\n /** Skip token strategy registration */\n skipTokens?: boolean\n /** Skip vector strategy registration */\n skipVector?: boolean\n /** Skip fulltext strategy registration */\n skipFulltext?: boolean\n /** Module configurations (from generated/search.generated.ts) */\n moduleConfigs?: SearchModuleConfig[]\n}\n\n/**\n * Register the search module in the DI container.\n *\n * This creates and registers:\n * - SearchService instance\n * - All configured search strategies\n *\n * @param container - Awilix container\n * @param options - Optional configuration overrides\n */\nexport function registerSearchModule(\n container: SearchContainer,\n options?: SearchModuleOptions,\n): void {\n const strategies: SearchStrategy[] = []\n\n // Token strategy (always available unless explicitly skipped)\n if (!options?.skipTokens) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n strategies.push(new TokenSearchStrategy(db))\n } catch {\n // Kysely not available via em, skipping TokenSearchStrategy\n }\n }\n\n // Vector strategy (requires embedding service and driver)\n // Note: We register even if not currently available - availability is checked at search time\n // via isAvailable(). The embedding config may be loaded later from the database.\n if (!options?.skipVector) {\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const primaryDriver = drivers?.[0]\n\n if (embeddingService && primaryDriver) {\n strategies.push(new VectorSearchStrategy(embeddingService, primaryDriver))\n }\n } catch {\n // Vector module not available, skipping VectorSearchStrategy\n }\n }\n\n // Build entity config map for field policy resolution\n const entityConfigMap = new Map<EntityId, SearchEntityConfig>()\n for (const moduleConfig of (options?.moduleConfigs ?? [])) {\n for (const entityConfig of moduleConfig.entities) {\n if (entityConfig.enabled !== false) {\n entityConfigMap.set(entityConfig.entityId as EntityId, entityConfig)\n }\n }\n }\n\n // Fulltext strategy (requires driver configuration, e.g., MEILISEARCH_HOST)\n if (!options?.skipFulltext) {\n const excludeEncrypted = shouldExcludeEncryptedFields()\n const singletonCacheEnabled = process.env.SEARCH_DISABLE_SINGLETON_CACHE !== '1'\n const g = getSearchModuleGlobals()\n\n // The fulltext driver is safe to memoize only when SEARCH_EXCLUDE_ENCRYPTED_FIELDS is\n // OFF \u2014 in that case the driver holds no request-scoped Kysely reference. When the flag\n // is ON the encryptionMapResolver captures a per-request db handle and must stay\n // per-request to avoid cross-request data leaks.\n const canMemoize = singletonCacheEnabled && !excludeEncrypted\n\n let fulltextDriver: FullTextSearchDriver | null = canMemoize\n ? (g[FULLTEXT_DRIVER_KEY] ?? null)\n : null\n\n if (!fulltextDriver) {\n let encryptionMapResolver: ((entityId: EntityId) => Promise<EncryptionMapEntry[]>) | undefined\n if (excludeEncrypted) {\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n encryptionMapResolver = createEncryptionMapResolver(db)\n } catch {\n encryptionMapResolver = createUnavailableEncryptionMapResolver()\n }\n }\n\n fulltextDriver = createFulltextDriver({\n fieldPolicyResolver: (entityId: EntityId): SearchFieldPolicy | undefined => {\n const config = entityConfigMap.get(entityId)\n return config?.fieldPolicy\n },\n encryptionMapResolver,\n })\n\n if (canMemoize && fulltextDriver) {\n g[FULLTEXT_DRIVER_KEY] = fulltextDriver\n }\n }\n\n if (fulltextDriver) {\n strategies.push(new FullTextSearchStrategy(fulltextDriver))\n }\n }\n\n // Determine default strategies based on what's available\n const defaultStrategies = options?.defaultStrategies ?? determineDefaultStrategies(strategies)\n\n // Try to resolve queryEngine for reindex support and presenter enrichment\n let queryEngine: QueryEngine | undefined\n try {\n queryEngine = container.resolve<QueryEngine>('queryEngine')\n } catch {\n // QueryEngine not available, reindex will be disabled\n }\n\n // Resolve encryption service for decrypting presenter data\n let encryptionService: TenantDataEncryptionService | null = null\n try {\n encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n } catch {\n // Encryption service not available, presenters won't be decrypted\n }\n\n // Create presenter enricher for database-based presenter resolution\n let presenterEnricher: PresenterEnricherFn | undefined\n try {\n const em = container.resolve<any>('em')\n const db = em.getKysely() as Kysely<any>\n presenterEnricher = createPresenterEnricher(db, entityConfigMap, queryEngine, encryptionService)\n } catch {\n // Kysely not available, presenter enrichment disabled\n }\n\n // Create search service\n const searchService = new SearchService({\n strategies,\n defaultStrategies,\n fallbackStrategy: 'tokens',\n mergeConfig: options?.mergeConfig ?? {\n duplicateHandling: 'highest_score',\n strategyWeights: {\n fulltext: 1.2,\n vector: 1.0,\n tokens: 0.8,\n },\n },\n presenterEnricher,\n })\n\n // Create search indexer with module configs\n const moduleConfigs = options?.moduleConfigs ?? []\n\n // Try to resolve fulltextIndexQueue for queue-based reindexing\n let fulltextQueue: Queue<FulltextIndexJobPayload> | undefined\n try {\n fulltextQueue = container.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n // Queue not available, queue-based fulltext reindex will be disabled\n }\n\n // Try to resolve vectorIndexQueue for queue-based vector reindexing\n let vectorQueue: Queue<VectorIndexJobPayload> | undefined\n try {\n vectorQueue = container.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n // Queue not available, queue-based vector reindex will be disabled\n }\n\n const searchIndexer = new SearchIndexer(searchService, moduleConfigs, {\n queryEngine,\n fulltextQueue,\n vectorQueue,\n })\n\n // Register in container\n container.register({\n searchService: asValue(searchService),\n searchStrategies: asValue(strategies),\n searchIndexer: asValue(searchIndexer),\n })\n}\n\n/**\n * Determine default strategy order based on available strategies.\n * Prefers fulltext > vector > tokens.\n */\nfunction determineDefaultStrategies(strategies: SearchStrategy[]): SearchStrategyId[] {\n const available = new Set(strategies.map((s) => s.id))\n const defaults: SearchStrategyId[] = []\n\n if (available.has('fulltext')) defaults.push('fulltext')\n if (available.has('vector')) defaults.push('vector')\n if (available.has('tokens')) defaults.push('tokens')\n\n return defaults.length > 0 ? defaults : ['tokens']\n}\n\n/**\n * Helper to add a custom strategy to an existing SearchService.\n *\n * @param container - DI container\n * @param strategy - Strategy to add\n */\nexport function addSearchStrategy(container: SearchContainer, strategy: SearchStrategy): void {\n const service = container.resolve<SearchService>('searchService')\n service.registerStrategy(strategy)\n\n const strategies = container.resolve<SearchStrategy[]>('searchStrategies')\n strategies.push(strategy)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe;AAGxB,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,4BAAmD;AAC5D,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,qBAAqB;AAkB9B,SAAS,+BAA+B;AACxC,SAAS,mCAAmC;AAE5C,SAAS,+BAAAA,oCAAmC;AAE5C,MAAM,sBAAsB;AAM5B,SAAS,yBAA8C;AACrD,SAAO;AACT;AAOA,SAAS,+BAAwC;AAC/C,QAAM,OAAO,QAAQ,IAAI,mCAAmC,IAAI,YAAY;AAC5E,SAAO,QAAQ,OAAO,QAAQ,UAAU,QAAQ,SAAS,QAAQ;AACnE;AAEA,SAAS,yCAAgG;AACvG,SAAO,YAAY;AACjB,UAAM,IAAI,MAAM,iGAAiG;AAAA,EACnH;AACF;AAsCO,SAAS,qBACd,WACA,SACM;AACN,QAAM,aAA+B,CAAC;AAGtC,MAAI,CAAC,SAAS,YAAY;AACxB,QAAI;AACF,YAAM,KAAK,UAAU,QAAa,IAAI;AACtC,YAAM,KAAK,GAAG,UAAU;AACxB,iBAAW,KAAK,IAAI,oBAAoB,EAAE,CAAC;AAAA,IAC7C,QAAQ;AAAA,IAER;AAAA,EACF;AAKA,MAAI,CAAC,SAAS,YAAY;AACxB,QAAI;AACF,YAAM,mBAAmB,UAAU,QAA0B,wBAAwB;AACrF,YAAM,UAAU,UAAU,QAAwB,eAAe;AACjE,YAAM,gBAAgB,UAAU,CAAC;AAEjC,UAAI,oBAAoB,eAAe;AACrC,mBAAW,KAAK,IAAI,qBAAqB,kBAAkB,aAAa,CAAC;AAAA,MAC3E;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AAGA,QAAM,kBAAkB,oBAAI,IAAkC;AAC9D,aAAW,gBAAiB,SAAS,iBAAiB,CAAC,GAAI;AACzD,eAAW,gBAAgB,aAAa,UAAU;AAChD,UAAI,aAAa,YAAY,OAAO;AAClC,wBAAgB,IAAI,aAAa,UAAsB,YAAY;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AAGA,MAAI,CAAC,SAAS,cAAc;AAC1B,UAAM,mBAAmB,6BAA6B;AACtD,UAAM,wBAAwB,QAAQ,IAAI,mCAAmC;AAC7E,UAAM,IAAI,uBAAuB;AAMjC,UAAM,aAAa,yBAAyB,CAAC;AAE7C,QAAI,iBAA8C,aAC7C,EAAE,mBAAmB,KAAK,OAC3B;AAEJ,QAAI,CAAC,gBAAgB;AACnB,UAAI;AACJ,UAAI,kBAAkB;AACpB,YAAI;AACF,gBAAM,KAAK,UAAU,QAAa,IAAI;AACtC,gBAAM,KAAK,GAAG,UAAU;AACxB,kCAAwB,4BAA4B,EAAE;AAAA,QACxD,QAAQ;AACN,kCAAwB,uCAAuC;AAAA,QACjE;AAAA,MACF;AAEA,uBAAiB,qBAAqB;AAAA,QACpC,qBAAqB,CAAC,aAAsD;AAC1E,gBAAM,SAAS,gBAAgB,IAAI,QAAQ;AAC3C,iBAAO,QAAQ;AAAA,QACjB;AAAA,QACA;AAAA,MACF,CAAC;AAED,UAAI,cAAc,gBAAgB;AAChC,UAAE,mBAAmB,IAAI;AAAA,MAC3B;AAAA,IACF;AAEA,QAAI,gBAAgB;AAClB,iBAAW,KAAK,IAAI,uBAAuB,cAAc,CAAC;AAAA,IAC5D;AAAA,EACF;AAGA,QAAM,oBAAoB,SAAS,qBAAqB,2BAA2B,UAAU;AAG7F,MAAI;AACJ,MAAI;AACF,kBAAc,UAAU,QAAqB,aAAa;AAAA,EAC5D,QAAQ;AAAA,EAER;AAGA,MAAI,oBAAwD;AAC5D,MAAI;AACF,wBAAoB,UAAU,QAAqC,yBAAyB;AAAA,EAC9F,QAAQ;AAAA,EAER;AAGA,MAAI;AACJ,MAAI;AACF,UAAM,KAAK,UAAU,QAAa,IAAI;AACtC,UAAM,KAAK,GAAG,UAAU;AACxB,wBAAoB,wBAAwB,IAAI,iBAAiB,aAAa,iBAAiB;AAAA,EACjG,QAAQ;AAAA,EAER;AAGA,QAAM,gBAAgB,IAAI,cAAc;AAAA,IACtC;AAAA,IACA;AAAA,IACA,kBAAkB;AAAA,IAClB,aAAa,SAAS,eAAe;AAAA,MACnC,mBAAmB;AAAA,MACnB,iBAAiB;AAAA,QACf,UAAU;AAAA,QACV,QAAQ;AAAA,QACR,QAAQ;AAAA,MACV;AAAA,IACF;AAAA,IACA;AAAA,EACF,CAAC;AAGD,QAAM,gBAAgB,SAAS,iBAAiB,CAAC;AAGjD,MAAI;AACJ,MAAI;AACF,oBAAgB,UAAU,QAAwC,oBAAoB;AAAA,EACxF,QAAQ;AAAA,EAER;AAGA,MAAI;AACJ,MAAI;AACF,kBAAc,UAAU,QAAsC,kBAAkB;AAAA,EAClF,QAAQ;AAAA,EAER;AAEA,QAAM,gBAAgB,IAAI,cAAc,eAAe,eAAe;AAAA,IACpE;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AAGD,YAAU,SAAS;AAAA,IACjB,eAAe,QAAQ,aAAa;AAAA,IACpC,kBAAkB,QAAQ,UAAU;AAAA,IACpC,eAAe,QAAQ,aAAa;AAAA,EACtC,CAAC;AACH;AAMA,SAAS,2BAA2B,YAAkD;AACpF,QAAM,YAAY,IAAI,IAAI,WAAW,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AACrD,QAAM,WAA+B,CAAC;AAEtC,MAAI,UAAU,IAAI,UAAU,EAAG,UAAS,KAAK,UAAU;AACvD,MAAI,UAAU,IAAI,QAAQ,EAAG,UAAS,KAAK,QAAQ;AACnD,MAAI,UAAU,IAAI,QAAQ,EAAG,UAAS,KAAK,QAAQ;AAEnD,SAAO,SAAS,SAAS,IAAI,WAAW,CAAC,QAAQ;AACnD;AAQO,SAAS,kBAAkB,WAA4B,UAAgC;AAC5F,QAAM,UAAU,UAAU,QAAuB,eAAe;AAChE,UAAQ,iBAAiB,QAAQ;AAEjC,QAAM,aAAa,UAAU,QAA0B,kBAAkB;AACzE,aAAW,KAAK,QAAQ;AAC1B;",
|
|
6
|
+
"names": ["createEncryptionMapResolver"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function compareNullableScope(left, right) {
|
|
2
|
+
if (left == null && right == null) return 0;
|
|
3
|
+
if (left == null) return -1;
|
|
4
|
+
if (right == null) return 1;
|
|
5
|
+
return String(left).localeCompare(String(right));
|
|
6
|
+
}
|
|
7
|
+
function compareCreatedAt(left, right) {
|
|
8
|
+
const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ""));
|
|
9
|
+
const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ""));
|
|
10
|
+
if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime;
|
|
11
|
+
return String(left ?? "").localeCompare(String(right ?? ""));
|
|
12
|
+
}
|
|
13
|
+
function compareEncryptionMapRows(left, right) {
|
|
14
|
+
return compareNullableScope(left.tenant_id, right.tenant_id) || compareNullableScope(left.organization_id, right.organization_id) || compareCreatedAt(left.created_at, right.created_at) || String(left.id ?? "").localeCompare(String(right.id ?? ""));
|
|
15
|
+
}
|
|
16
|
+
function parseEncryptionMapFields(value) {
|
|
17
|
+
if (Array.isArray(value)) return value;
|
|
18
|
+
if (typeof value !== "string") return [];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(value);
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function mergeEncryptionMapEntries(rows) {
|
|
27
|
+
const merged = [];
|
|
28
|
+
const byField = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const row of [...rows].sort(compareEncryptionMapRows)) {
|
|
30
|
+
for (const candidate of parseEncryptionMapFields(row.fields_json)) {
|
|
31
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
32
|
+
const rule = candidate;
|
|
33
|
+
const field = typeof rule.field === "string" ? rule.field.trim() : "";
|
|
34
|
+
if (!field) continue;
|
|
35
|
+
const hashField = typeof rule.hashField === "string" && rule.hashField.length > 0 ? rule.hashField : null;
|
|
36
|
+
const existing = byField.get(field);
|
|
37
|
+
if (!existing) {
|
|
38
|
+
const entry = { field, hashField };
|
|
39
|
+
byField.set(field, entry);
|
|
40
|
+
merged.push(entry);
|
|
41
|
+
} else if (!existing.hashField && hashField) {
|
|
42
|
+
existing.hashField = hashField;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
function createEncryptionMapResolver(db, legacyPolicyVersionCache) {
|
|
49
|
+
void legacyPolicyVersionCache;
|
|
50
|
+
return async (entityId) => {
|
|
51
|
+
const rows = await db.selectFrom("encryption_maps").select([
|
|
52
|
+
"id",
|
|
53
|
+
"tenant_id",
|
|
54
|
+
"organization_id",
|
|
55
|
+
"fields_json",
|
|
56
|
+
"created_at"
|
|
57
|
+
]).where("entity_id", "=", entityId).where("is_active", "=", true).where("deleted_at", "is", null).orderBy("tenant_id", "asc").orderBy("organization_id", "asc").orderBy("created_at", "asc").orderBy("id", "asc").execute();
|
|
58
|
+
return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : []);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
createEncryptionMapResolver
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=encryption-map-resolver.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/encryption-map-resolver.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Kysely } from 'kysely'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { EncryptionMapEntry } from './field-policy'\n\ntype EncryptionMapRow = {\n id?: unknown\n tenant_id?: unknown\n organization_id?: unknown\n fields_json?: unknown\n created_at?: unknown\n}\n\ntype LegacyEncryptionMapPolicyVersionCache = {\n get(key: string): Promise<unknown | null>\n}\n\nfunction compareNullableScope(left: unknown, right: unknown): number {\n if (left == null && right == null) return 0\n if (left == null) return -1\n if (right == null) return 1\n return String(left).localeCompare(String(right))\n}\n\nfunction compareCreatedAt(left: unknown, right: unknown): number {\n const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ''))\n const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ''))\n if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime\n return String(left ?? '').localeCompare(String(right ?? ''))\n}\n\nfunction compareEncryptionMapRows(left: EncryptionMapRow, right: EncryptionMapRow): number {\n return compareNullableScope(left.tenant_id, right.tenant_id)\n || compareNullableScope(left.organization_id, right.organization_id)\n || compareCreatedAt(left.created_at, right.created_at)\n || String(left.id ?? '').localeCompare(String(right.id ?? ''))\n}\n\nfunction parseEncryptionMapFields(value: unknown): unknown[] {\n if (Array.isArray(value)) return value\n if (typeof value !== 'string') return []\n try {\n const parsed = JSON.parse(value) as unknown\n return Array.isArray(parsed) ? parsed : []\n } catch {\n return []\n }\n}\n\nfunction mergeEncryptionMapEntries(rows: readonly EncryptionMapRow[]): EncryptionMapEntry[] {\n const merged: EncryptionMapEntry[] = []\n const byField = new Map<string, EncryptionMapEntry>()\n for (const row of [...rows].sort(compareEncryptionMapRows)) {\n for (const candidate of parseEncryptionMapFields(row.fields_json)) {\n if (!candidate || typeof candidate !== 'object') continue\n const rule = candidate as { field?: unknown; hashField?: unknown }\n const field = typeof rule.field === 'string' ? rule.field.trim() : ''\n if (!field) continue\n const hashField = typeof rule.hashField === 'string' && rule.hashField.length > 0\n ? rule.hashField\n : null\n const existing = byField.get(field)\n if (!existing) {\n const entry = { field, hashField }\n byField.set(field, entry)\n merged.push(entry)\n } else if (!existing.hashField && hashField) {\n existing.hashField = hashField\n }\n }\n }\n return merged\n}\n\n/**\n * Resolve every active live declaration for an entity. Search indexing has no\n * tenant/scope argument at this boundary, so the safe policy is the union of\n * all scopes: over-excluding a field is preferable to publishing plaintext.\n *\n * The legacy version-cache argument remains optional for call-site compatibility but is\n * intentionally ignored. A version publication can fail after the database commit, so neither\n * that channel nor process-local positive memory can authorize reuse of an older policy.\n */\nexport function createEncryptionMapResolver(\n db: Kysely<any>,\n legacyPolicyVersionCache?: LegacyEncryptionMapPolicyVersionCache,\n): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {\n void legacyPolicyVersionCache\n\n return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {\n const rows = await db\n .selectFrom('encryption_maps' as any)\n .select([\n 'id' as any,\n 'tenant_id' as any,\n 'organization_id' as any,\n 'fields_json' as any,\n 'created_at' as any,\n ])\n .where('entity_id' as any, '=', entityId)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null)\n .orderBy('tenant_id' as any, 'asc')\n .orderBy('organization_id' as any, 'asc')\n .orderBy('created_at' as any, 'asc')\n .orderBy('id' as any, 'asc')\n .execute() as EncryptionMapRow[]\n\n return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : [])\n }\n}\n"],
|
|
5
|
+
"mappings": "AAgBA,SAAS,qBAAqB,MAAe,OAAwB;AACnE,MAAI,QAAQ,QAAQ,SAAS,KAAM,QAAO;AAC1C,MAAI,QAAQ,KAAM,QAAO;AACzB,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,OAAO,IAAI,EAAE,cAAc,OAAO,KAAK,CAAC;AACjD;AAEA,SAAS,iBAAiB,MAAe,OAAwB;AAC/D,QAAM,WAAW,gBAAgB,OAAO,KAAK,QAAQ,IAAI,KAAK,MAAM,OAAO,QAAQ,EAAE,CAAC;AACtF,QAAM,YAAY,iBAAiB,OAAO,MAAM,QAAQ,IAAI,KAAK,MAAM,OAAO,SAAS,EAAE,CAAC;AAC1F,MAAI,OAAO,SAAS,QAAQ,KAAK,OAAO,SAAS,SAAS,EAAG,QAAO,WAAW;AAC/E,SAAO,OAAO,QAAQ,EAAE,EAAE,cAAc,OAAO,SAAS,EAAE,CAAC;AAC7D;AAEA,SAAS,yBAAyB,MAAwB,OAAiC;AACzF,SAAO,qBAAqB,KAAK,WAAW,MAAM,SAAS,KACtD,qBAAqB,KAAK,iBAAiB,MAAM,eAAe,KAChE,iBAAiB,KAAK,YAAY,MAAM,UAAU,KAClD,OAAO,KAAK,MAAM,EAAE,EAAE,cAAc,OAAO,MAAM,MAAM,EAAE,CAAC;AACjE;AAEA,SAAS,yBAAyB,OAA2B;AAC3D,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,MAAI,OAAO,UAAU,SAAU,QAAO,CAAC;AACvC,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,KAAK;AAC/B,WAAO,MAAM,QAAQ,MAAM,IAAI,SAAS,CAAC;AAAA,EAC3C,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAEA,SAAS,0BAA0B,MAAyD;AAC1F,QAAM,SAA+B,CAAC;AACtC,QAAM,UAAU,oBAAI,IAAgC;AACpD,aAAW,OAAO,CAAC,GAAG,IAAI,EAAE,KAAK,wBAAwB,GAAG;AAC1D,eAAW,aAAa,yBAAyB,IAAI,WAAW,GAAG;AACjE,UAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,YAAM,OAAO;AACb,YAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACnE,UAAI,CAAC,MAAO;AACZ,YAAM,YAAY,OAAO,KAAK,cAAc,YAAY,KAAK,UAAU,SAAS,IAC5E,KAAK,YACL;AACJ,YAAM,WAAW,QAAQ,IAAI,KAAK;AAClC,UAAI,CAAC,UAAU;AACb,cAAM,QAAQ,EAAE,OAAO,UAAU;AACjC,gBAAQ,IAAI,OAAO,KAAK;AACxB,eAAO,KAAK,KAAK;AAAA,MACnB,WAAW,CAAC,SAAS,aAAa,WAAW;AAC3C,iBAAS,YAAY;AAAA,MACvB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,4BACd,IACA,0BACuD;AACvD,OAAK;AAEL,SAAO,OAAO,aAAsD;AAClE,UAAM,OAAO,MAAM,GAChB,WAAW,iBAAwB,EACnC,OAAO;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC,EACA,MAAM,aAAoB,KAAK,QAAQ,EACvC,MAAM,aAAoB,KAAK,IAAI,EACnC,MAAM,cAAqB,MAAM,IAAI,EACrC,QAAQ,aAAoB,KAAK,EACjC,QAAQ,mBAA0B,KAAK,EACvC,QAAQ,cAAqB,KAAK,EAClC,QAAQ,MAAa,KAAK,EAC1B,QAAQ;AAEX,WAAO,0BAA0B,MAAM,QAAQ,IAAI,IAAI,OAAO,CAAC,CAAC;AAAA,EAClE;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -18,7 +18,8 @@ async function handle(payload, ctx) {
|
|
|
18
18
|
}
|
|
19
19
|
if ((organizationId == null || tenantId == null) && em) {
|
|
20
20
|
try {
|
|
21
|
-
const
|
|
21
|
+
const lookupEm = typeof em.fork === "function" ? em.fork() : em;
|
|
22
|
+
const db = lookupEm.getKysely();
|
|
22
23
|
const table = resolveEntityTableName(em, entityType);
|
|
23
24
|
const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
|
|
24
25
|
if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/subscribers/fulltext_upsert.ts"],
|
|
4
|
-
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'search.index_record', persistent: false }\n\ntype Payload = {\n entityId?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityId ?? '')\n const recordId = String(payload?.recordId ?? '')\n\n if (!entityType || !recordId) {\n return\n }\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n // Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,uBAAuB,YAAY,MAAM;AAW1E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,YAAY,EAAE;AACjD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAE/C,MAAI,CAAC,cAAc,CAAC,UAAU;AAC5B;AAAA,EACF;AAEA,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAGA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,
|
|
4
|
+
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { FulltextIndexJobPayload } from '../../../queue/fulltext-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'search.index_record', persistent: false }\n\ntype Payload = {\n entityId?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityId ?? '')\n const recordId = String(payload?.recordId ?? '')\n\n if (!entityType || !recordId) {\n return\n }\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n // Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) {\n return\n }\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) {\n return\n }\n\n let queue: Queue<FulltextIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<FulltextIndexJobPayload>>('fulltextIndexQueue')\n } catch {\n searchDebugWarn('search.fulltext', 'fulltextIndexQueue not available, skipping fulltext indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.fulltext', 'Failed to enqueue fulltext index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,uBAAuB,YAAY,MAAM;AAW1E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,YAAY,EAAE;AACjD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAE/C,MAAI,CAAC,cAAc,CAAC,UAAU;AAC5B;AAAA,EACF;AAEA,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAGA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,UAAU;AACb;AAAA,EACF;AAEA,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,qBAAqB;AACxB;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAwC,oBAAoB;AAAA,EAC1E,QAAQ;AACN,oBAAgB,mBAAmB,8DAA8D;AACjG;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,mBAAmB,wCAAwC;AAAA,MACrE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -16,7 +16,8 @@ async function handle(payload, ctx) {
|
|
|
16
16
|
}
|
|
17
17
|
if ((organizationId == null || tenantId == null) && em) {
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
19
|
+
const lookupEm = typeof em.fork === "function" ? em.fork() : em;
|
|
20
|
+
const db = lookupEm.getKysely();
|
|
20
21
|
const table = resolveEntityTableName(em, entityType);
|
|
21
22
|
const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
|
|
22
23
|
if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/subscribers/vector_delete.ts"],
|
|
4
|
-
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.delete_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,0BAA0B,YAAY,MAAM;AAW7E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,
|
|
4
|
+
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.delete_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector delete')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'delete',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector delete job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,0BAA0B,YAAY,MAAM;AAW7E,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,wDAAwD;AACzF;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,uCAAuC;AAAA,MAClE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -16,7 +16,8 @@ async function handle(payload, ctx) {
|
|
|
16
16
|
}
|
|
17
17
|
if ((organizationId == null || tenantId == null) && em) {
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
19
|
+
const lookupEm = typeof em.fork === "function" ? em.fork() : em;
|
|
20
|
+
const db = lookupEm.getKysely();
|
|
20
21
|
const table = resolveEntityTableName(em, entityType);
|
|
21
22
|
const row = await db.selectFrom(table).select(["organization_id", "tenant_id"]).where("id", "=", recordId).executeTakeFirst();
|
|
22
23
|
if (organizationId == null) organizationId = row?.organization_id ?? organizationId;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/subscribers/vector_upsert.ts"],
|
|
4
|
-
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.vectorize_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,6BAA6B,YAAY,MAAM;AAWhF,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,
|
|
4
|
+
"sourcesContent": ["import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport type { Queue } from '@open-mercato/queue'\nimport type { VectorIndexJobPayload } from '../../../queue/vector-indexing'\nimport { resolveAutoIndexingEnabled } from '../lib/auto-indexing'\nimport { searchDebugWarn, searchError } from '../../../lib/debug'\n\nexport const metadata = { event: 'query_index.vectorize_one', persistent: false }\n\ntype Payload = {\n entityType?: string\n recordId?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\nexport default async function handle(payload: Payload, ctx: HandlerContext) {\n const entityType = String(payload?.entityType ?? '')\n const recordId = String(payload?.recordId ?? '')\n if (!entityType || !recordId) return\n\n let organizationId = payload?.organizationId ?? null\n let tenantId = payload?.tenantId ?? null\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let em: any | null = null\n try {\n em = ctx.resolve('em')\n } catch {\n em = null\n }\n\n if ((organizationId == null || tenantId == null) && em) {\n try {\n const lookupEm = typeof em.fork === 'function' ? em.fork() : em\n const db = lookupEm.getKysely()\n const table = resolveEntityTableName(em, entityType)\n const row = await db\n .selectFrom(table as any)\n .select(['organization_id' as any, 'tenant_id' as any])\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as { organization_id?: string | null; tenant_id?: string | null } | undefined\n if (organizationId == null) organizationId = row?.organization_id ?? organizationId\n if (tenantId == null) tenantId = row?.tenant_id ?? tenantId\n } catch {\n // Ignore lookup errors\n }\n }\n\n if (!tenantId) return\n\n const autoIndexingEnabled = await resolveAutoIndexingEnabled(ctx, { defaultValue: true })\n if (!autoIndexingEnabled) return\n\n let queue: Queue<VectorIndexJobPayload>\n try {\n queue = ctx.resolve<Queue<VectorIndexJobPayload>>('vectorIndexQueue')\n } catch {\n searchDebugWarn('search.vector', 'vectorIndexQueue not available, skipping vector indexing')\n return\n }\n\n try {\n await queue.enqueue({\n jobType: 'index',\n entityType,\n recordId,\n tenantId: String(tenantId),\n organizationId: organizationId ? String(organizationId) : null,\n })\n } catch (error) {\n searchError('search.vector', 'Failed to enqueue vector index job', {\n entityType,\n recordId,\n error: error instanceof Error ? error.message : error,\n })\n throw error // Propagate to caller so failure is visible\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AAGvC,SAAS,kCAAkC;AAC3C,SAAS,iBAAiB,mBAAmB;AAEtC,MAAM,WAAW,EAAE,OAAO,6BAA6B,YAAY,MAAM;AAWhF,eAAO,OAA8B,SAAkB,KAAqB;AAC1E,QAAM,aAAa,OAAO,SAAS,cAAc,EAAE;AACnD,QAAM,WAAW,OAAO,SAAS,YAAY,EAAE;AAC/C,MAAI,CAAC,cAAc,CAAC,SAAU;AAE9B,MAAI,iBAAiB,SAAS,kBAAkB;AAChD,MAAI,WAAW,SAAS,YAAY;AAGpC,MAAI,KAAiB;AACrB,MAAI;AACF,SAAK,IAAI,QAAQ,IAAI;AAAA,EACvB,QAAQ;AACN,SAAK;AAAA,EACP;AAEA,OAAK,kBAAkB,QAAQ,YAAY,SAAS,IAAI;AACtD,QAAI;AACF,YAAM,WAAW,OAAO,GAAG,SAAS,aAAa,GAAG,KAAK,IAAI;AAC7D,YAAM,KAAK,SAAS,UAAU;AAC9B,YAAM,QAAQ,uBAAuB,IAAI,UAAU;AACnD,YAAM,MAAM,MAAM,GACf,WAAW,KAAY,EACvB,OAAO,CAAC,mBAA0B,WAAkB,CAAC,EACrD,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AACpB,UAAI,kBAAkB,KAAM,kBAAiB,KAAK,mBAAmB;AACrE,UAAI,YAAY,KAAM,YAAW,KAAK,aAAa;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,MAAI,CAAC,SAAU;AAEf,QAAM,sBAAsB,MAAM,2BAA2B,KAAK,EAAE,cAAc,KAAK,CAAC;AACxF,MAAI,CAAC,oBAAqB;AAE1B,MAAI;AACJ,MAAI;AACF,YAAQ,IAAI,QAAsC,kBAAkB;AAAA,EACtE,QAAQ;AACN,oBAAgB,iBAAiB,0DAA0D;AAC3F;AAAA,EACF;AAEA,MAAI;AACF,UAAM,MAAM,QAAQ;AAAA,MAClB,SAAS;AAAA,MACT;AAAA,MACA;AAAA,MACA,UAAU,OAAO,QAAQ;AAAA,MACzB,gBAAgB,iBAAiB,OAAO,cAAc,IAAI;AAAA,IAC5D,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,iBAAiB,sCAAsC;AAAA,MACjE;AAAA,MACA;AAAA,MACA,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,UAAM;AAAA,EACR;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"zod": "^4.4.3"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
|
-
"@open-mercato/core": "0.
|
|
131
|
-
"@open-mercato/queue": "0.
|
|
132
|
-
"@open-mercato/shared": "0.
|
|
130
|
+
"@open-mercato/core": "0.9.0",
|
|
131
|
+
"@open-mercato/queue": "0.9.0",
|
|
132
|
+
"@open-mercato/shared": "0.9.0"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -144,6 +144,5 @@
|
|
|
144
144
|
"type": "git",
|
|
145
145
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
146
146
|
"directory": "packages/search"
|
|
147
|
-
}
|
|
148
|
-
"stableVersion": "0.8.0"
|
|
147
|
+
}
|
|
149
148
|
}
|
|
@@ -164,4 +164,19 @@ describe('registerSearchModule — fulltext driver canMemoize guard', () => {
|
|
|
164
164
|
|
|
165
165
|
expect(jest.mocked(createFulltextDriver)).toHaveBeenCalledTimes(2)
|
|
166
166
|
})
|
|
167
|
+
|
|
168
|
+
it('installs a fail-closed resolver when encrypted-field filtering cannot reach the database', async () => {
|
|
169
|
+
process.env.SEARCH_EXCLUDE_ENCRYPTED_FIELDS = 'true'
|
|
170
|
+
const container = makeMockContainer()
|
|
171
|
+
|
|
172
|
+
registerSearchModule(container as never, { skipVector: true, skipTokens: true })
|
|
173
|
+
|
|
174
|
+
const options = jest.mocked(createFulltextDriver).mock.calls[0]?.[0] as {
|
|
175
|
+
encryptionMapResolver?: (entityId: string) => Promise<unknown>
|
|
176
|
+
}
|
|
177
|
+
expect(options.encryptionMapResolver).toEqual(expect.any(Function))
|
|
178
|
+
await expect(options.encryptionMapResolver?.('demo:item') as Promise<unknown>).rejects.toThrow(
|
|
179
|
+
'refusing to index potentially encrypted fields',
|
|
180
|
+
)
|
|
181
|
+
})
|
|
167
182
|
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
jest.mock('../fulltext/drivers', () => ({
|
|
2
|
+
createFulltextDriver: jest.fn(),
|
|
3
|
+
}))
|
|
4
|
+
|
|
5
|
+
import { createEncryptionMapResolver } from '../di'
|
|
6
|
+
import { encryptionMapPolicyVersionCacheKey } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
7
|
+
|
|
8
|
+
type QueryResult = Array<Record<string, unknown>> | Error
|
|
9
|
+
|
|
10
|
+
function createDatabase(results: QueryResult[]) {
|
|
11
|
+
const queue = [...results]
|
|
12
|
+
const query: Record<string, jest.Mock> = {}
|
|
13
|
+
for (const method of ['select', 'where', 'orderBy']) {
|
|
14
|
+
query[method] = jest.fn(() => query)
|
|
15
|
+
}
|
|
16
|
+
query.execute = jest.fn(async () => {
|
|
17
|
+
const result = queue.shift() ?? []
|
|
18
|
+
if (result instanceof Error) throw result
|
|
19
|
+
return result
|
|
20
|
+
})
|
|
21
|
+
return {
|
|
22
|
+
db: { selectFrom: jest.fn(() => query) },
|
|
23
|
+
query,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createPolicyVersionCache(entityId: string) {
|
|
28
|
+
const storage = new Map<string, unknown>([
|
|
29
|
+
[encryptionMapPolicyVersionCacheKey(entityId), 'version-1'],
|
|
30
|
+
])
|
|
31
|
+
const cache = {
|
|
32
|
+
get: jest.fn(async (key: string) => storage.get(key) ?? null),
|
|
33
|
+
set: jest.fn(async (key: string, value: unknown) => { storage.set(key, value) }),
|
|
34
|
+
delete: jest.fn(async (key: string) => storage.delete(key)),
|
|
35
|
+
}
|
|
36
|
+
return { cache, storage }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('search encryption-map resolver', () => {
|
|
40
|
+
it('deterministically unions legacy duplicate rows and completes a missing hash target', async () => {
|
|
41
|
+
const { db } = createDatabase([[
|
|
42
|
+
{
|
|
43
|
+
id: 'b',
|
|
44
|
+
tenant_id: 'tenant-1',
|
|
45
|
+
organization_id: 'org-1',
|
|
46
|
+
created_at: '2026-01-02T00:00:00.000Z',
|
|
47
|
+
fields_json: [{ field: 'email', hashField: 'email_hash' }, { field: 'phone' }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'a',
|
|
51
|
+
tenant_id: 'tenant-1',
|
|
52
|
+
organization_id: 'org-1',
|
|
53
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
54
|
+
fields_json: [{ field: 'email' }, { field: 'display_name' }],
|
|
55
|
+
},
|
|
56
|
+
]])
|
|
57
|
+
|
|
58
|
+
await expect(createEncryptionMapResolver(db as never)('customers:person')).resolves.toEqual([
|
|
59
|
+
{ field: 'email', hashField: 'email_hash' },
|
|
60
|
+
{ field: 'display_name', hashField: null },
|
|
61
|
+
{ field: 'phone', hashField: null },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('unions global, tenant-global, and organization-scoped declarations fail-safe', async () => {
|
|
66
|
+
const { db, query } = createDatabase([[
|
|
67
|
+
{
|
|
68
|
+
id: 'org',
|
|
69
|
+
tenant_id: 'tenant-1',
|
|
70
|
+
organization_id: 'org-1',
|
|
71
|
+
created_at: '2026-01-03T00:00:00.000Z',
|
|
72
|
+
fields_json: [{ field: 'organization_secret' }],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'global',
|
|
76
|
+
tenant_id: null,
|
|
77
|
+
organization_id: null,
|
|
78
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
79
|
+
fields_json: '[{"field":"global_secret"}]',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'tenant',
|
|
83
|
+
tenant_id: 'tenant-1',
|
|
84
|
+
organization_id: null,
|
|
85
|
+
created_at: '2026-01-02T00:00:00.000Z',
|
|
86
|
+
fields_json: [{ field: 'tenant_secret' }],
|
|
87
|
+
},
|
|
88
|
+
]])
|
|
89
|
+
|
|
90
|
+
await expect(createEncryptionMapResolver(db as never)('demo:item')).resolves.toEqual([
|
|
91
|
+
{ field: 'global_secret', hashField: null },
|
|
92
|
+
{ field: 'tenant_secret', hashField: null },
|
|
93
|
+
{ field: 'organization_secret', hashField: null },
|
|
94
|
+
])
|
|
95
|
+
expect(query.where).toHaveBeenCalledWith('is_active', '=', true)
|
|
96
|
+
expect(query.where).toHaveBeenCalledWith('deleted_at', 'is', null)
|
|
97
|
+
expect(query.orderBy).toHaveBeenCalledTimes(4)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('fails closed on lookup errors and retries instead of caching an empty policy', async () => {
|
|
101
|
+
const { db, query } = createDatabase([
|
|
102
|
+
new Error('database unavailable'),
|
|
103
|
+
[{
|
|
104
|
+
id: 'recovered',
|
|
105
|
+
tenant_id: null,
|
|
106
|
+
organization_id: null,
|
|
107
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
108
|
+
fields_json: [{ field: 'secret' }],
|
|
109
|
+
}],
|
|
110
|
+
])
|
|
111
|
+
const resolveMap = createEncryptionMapResolver(db as never)
|
|
112
|
+
|
|
113
|
+
await expect(resolveMap('demo:item')).rejects.toThrow('database unavailable')
|
|
114
|
+
await expect(resolveMap('demo:item')).resolves.toEqual([{ field: 'secret', hashField: null }])
|
|
115
|
+
expect(query.execute).toHaveBeenCalledTimes(2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('does not retain a successful empty lookup for five minutes', async () => {
|
|
119
|
+
const entityId = 'demo:item'
|
|
120
|
+
const { db, query } = createDatabase([
|
|
121
|
+
[],
|
|
122
|
+
[{
|
|
123
|
+
id: 'created-later',
|
|
124
|
+
tenant_id: 'tenant-1',
|
|
125
|
+
organization_id: null,
|
|
126
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
127
|
+
fields_json: [{ field: 'new_secret' }],
|
|
128
|
+
}],
|
|
129
|
+
[{
|
|
130
|
+
id: 'created-later',
|
|
131
|
+
tenant_id: 'tenant-1',
|
|
132
|
+
organization_id: null,
|
|
133
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
134
|
+
fields_json: [{ field: 'new_secret' }],
|
|
135
|
+
}],
|
|
136
|
+
])
|
|
137
|
+
const { cache } = createPolicyVersionCache(entityId)
|
|
138
|
+
const resolveMap = createEncryptionMapResolver(db as never, cache)
|
|
139
|
+
|
|
140
|
+
await expect(resolveMap(entityId)).resolves.toEqual([])
|
|
141
|
+
await expect(resolveMap(entityId)).resolves.toEqual([{ field: 'new_secret', hashField: null }])
|
|
142
|
+
await expect(resolveMap(entityId)).resolves.toEqual([{ field: 'new_secret', hashField: null }])
|
|
143
|
+
expect(query.execute).toHaveBeenCalledTimes(3)
|
|
144
|
+
expect(cache.get).not.toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('re-reads a positive policy when a protected field is added without a published version', async () => {
|
|
148
|
+
const entityId = 'demo:versioned_policy'
|
|
149
|
+
const { db, query } = createDatabase([
|
|
150
|
+
[{
|
|
151
|
+
id: 'existing',
|
|
152
|
+
tenant_id: 'tenant-1',
|
|
153
|
+
organization_id: 'org-1',
|
|
154
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
155
|
+
fields_json: [{ field: 'old_secret' }],
|
|
156
|
+
}],
|
|
157
|
+
[{
|
|
158
|
+
id: 'existing',
|
|
159
|
+
tenant_id: 'tenant-1',
|
|
160
|
+
organization_id: 'org-1',
|
|
161
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
162
|
+
fields_json: [{ field: 'old_secret' }, { field: 'new_secret' }],
|
|
163
|
+
}],
|
|
164
|
+
])
|
|
165
|
+
const { cache } = createPolicyVersionCache(entityId)
|
|
166
|
+
const resolveMap = createEncryptionMapResolver(db as never, cache)
|
|
167
|
+
|
|
168
|
+
await expect(resolveMap(entityId)).resolves.toEqual([
|
|
169
|
+
{ field: 'old_secret', hashField: null },
|
|
170
|
+
])
|
|
171
|
+
await expect(resolveMap(entityId)).resolves.toEqual([
|
|
172
|
+
{ field: 'old_secret', hashField: null },
|
|
173
|
+
{ field: 'new_secret', hashField: null },
|
|
174
|
+
])
|
|
175
|
+
expect(query.execute).toHaveBeenCalledTimes(2)
|
|
176
|
+
expect(cache.get).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('re-reads canonical policy while the legacy version cache is unavailable', async () => {
|
|
180
|
+
const { db, query } = createDatabase([[
|
|
181
|
+
{
|
|
182
|
+
id: 'current',
|
|
183
|
+
tenant_id: null,
|
|
184
|
+
organization_id: null,
|
|
185
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
186
|
+
fields_json: [{ field: 'protected' }],
|
|
187
|
+
},
|
|
188
|
+
]])
|
|
189
|
+
const cache = {
|
|
190
|
+
get: jest.fn(async () => { throw new Error('cache unavailable') }),
|
|
191
|
+
}
|
|
192
|
+
const resolveMap = createEncryptionMapResolver(db as never, cache)
|
|
193
|
+
|
|
194
|
+
await expect(resolveMap('demo:cache_outage')).resolves.toEqual([
|
|
195
|
+
{ field: 'protected', hashField: null },
|
|
196
|
+
])
|
|
197
|
+
expect(query.execute).toHaveBeenCalledTimes(1)
|
|
198
|
+
expect(cache.get).not.toHaveBeenCalled()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { fork, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
type WorkerResponse = {
|
|
7
|
+
id?: number
|
|
8
|
+
ready?: boolean
|
|
9
|
+
ok?: boolean
|
|
10
|
+
value?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function waitForMessage(worker: ChildProcess, predicate: (message: WorkerResponse) => boolean): Promise<WorkerResponse> {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const onMessage = (message: WorkerResponse) => {
|
|
16
|
+
if (!predicate(message)) return
|
|
17
|
+
cleanup()
|
|
18
|
+
resolve(message)
|
|
19
|
+
}
|
|
20
|
+
const onExit = (code: number | null) => {
|
|
21
|
+
cleanup()
|
|
22
|
+
reject(new Error(`[internal] Search policy worker exited with code ${String(code)}`))
|
|
23
|
+
}
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
worker.off('message', onMessage)
|
|
26
|
+
worker.off('exit', onExit)
|
|
27
|
+
}
|
|
28
|
+
worker.on('message', onMessage)
|
|
29
|
+
worker.on('exit', onExit)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function startWorker(statePath: string, cacheStatePath: string): Promise<ChildProcess> {
|
|
34
|
+
const worker = fork(join(__dirname, 'fixtures/encryption-policy-process-worker.ts'), [], {
|
|
35
|
+
execArgv: ['--import', require.resolve('tsx')],
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
SEARCH_POLICY_STATE_PATH: statePath,
|
|
39
|
+
SEARCH_POLICY_CACHE_STATE_PATH: cacheStatePath,
|
|
40
|
+
},
|
|
41
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
|
|
42
|
+
})
|
|
43
|
+
await waitForMessage(worker, (message) => message.ready === true)
|
|
44
|
+
return worker
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let commandId = 0
|
|
48
|
+
|
|
49
|
+
async function resolvePolicy(worker: ChildProcess, entityId: string) {
|
|
50
|
+
const id = ++commandId
|
|
51
|
+
const response = waitForMessage(worker, (message) => message.id === id)
|
|
52
|
+
worker.send({ id, entityId })
|
|
53
|
+
return response
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('Search encryption policy coherence across processes', () => {
|
|
57
|
+
it('re-reads positive policy after mutation, failed publication, and cache recovery', async () => {
|
|
58
|
+
const directory = await mkdtemp(join(tmpdir(), 'om-search-policy-'))
|
|
59
|
+
const statePath = join(directory, 'policy.json')
|
|
60
|
+
const cacheStatePath = join(directory, 'cache.json')
|
|
61
|
+
const entityId = 'demo:process_policy'
|
|
62
|
+
let worker: ChildProcess | undefined
|
|
63
|
+
try {
|
|
64
|
+
await writeFile(statePath, JSON.stringify([{
|
|
65
|
+
id: 'map',
|
|
66
|
+
tenant_id: 'tenant-1',
|
|
67
|
+
organization_id: 'org-1',
|
|
68
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
69
|
+
fields_json: [{ field: 'old_secret' }],
|
|
70
|
+
}]))
|
|
71
|
+
await writeFile(cacheStatePath, JSON.stringify({ available: true, version: 'version-1' }))
|
|
72
|
+
worker = await startWorker(statePath, cacheStatePath)
|
|
73
|
+
|
|
74
|
+
await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
|
|
75
|
+
ok: true,
|
|
76
|
+
value: [{ field: 'old_secret', hashField: null }],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await writeFile(statePath, JSON.stringify([{
|
|
80
|
+
id: 'map',
|
|
81
|
+
tenant_id: 'tenant-1',
|
|
82
|
+
organization_id: 'org-1',
|
|
83
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
84
|
+
fields_json: [{ field: 'old_secret' }, { field: 'new_secret' }],
|
|
85
|
+
}]))
|
|
86
|
+
await writeFile(cacheStatePath, JSON.stringify({ available: false, version: 'version-1' }))
|
|
87
|
+
await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
|
|
88
|
+
ok: true,
|
|
89
|
+
value: [
|
|
90
|
+
{ field: 'old_secret', hashField: null },
|
|
91
|
+
{ field: 'new_secret', hashField: null },
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await writeFile(cacheStatePath, JSON.stringify({ available: true, version: 'version-2' }))
|
|
96
|
+
await expect(resolvePolicy(worker, entityId)).resolves.toMatchObject({
|
|
97
|
+
ok: true,
|
|
98
|
+
value: [
|
|
99
|
+
{ field: 'old_secret', hashField: null },
|
|
100
|
+
{ field: 'new_secret', hashField: null },
|
|
101
|
+
],
|
|
102
|
+
})
|
|
103
|
+
} finally {
|
|
104
|
+
worker?.kill()
|
|
105
|
+
await rm(directory, { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { createEncryptionMapResolver } from '../../lib/encryption-map-resolver'
|
|
3
|
+
|
|
4
|
+
type WorkerCommand = {
|
|
5
|
+
id: number
|
|
6
|
+
entityId: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const statePath = process.env.SEARCH_POLICY_STATE_PATH
|
|
10
|
+
const cacheStatePath = process.env.SEARCH_POLICY_CACHE_STATE_PATH
|
|
11
|
+
|
|
12
|
+
if (!statePath || !cacheStatePath) {
|
|
13
|
+
throw new Error('[internal] Search policy fixture paths are required')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const query: Record<string, unknown> = {}
|
|
17
|
+
for (const method of ['select', 'where', 'orderBy']) {
|
|
18
|
+
query[method] = () => query
|
|
19
|
+
}
|
|
20
|
+
query.execute = async () => JSON.parse(await readFile(statePath, 'utf8')) as unknown[]
|
|
21
|
+
|
|
22
|
+
const legacyPolicyVersionCache = {
|
|
23
|
+
get: async () => {
|
|
24
|
+
const state = JSON.parse(await readFile(cacheStatePath, 'utf8')) as {
|
|
25
|
+
available: boolean
|
|
26
|
+
version: string
|
|
27
|
+
}
|
|
28
|
+
if (!state.available) throw new Error('policy version cache unavailable')
|
|
29
|
+
return state.version
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resolveMap = createEncryptionMapResolver(
|
|
34
|
+
{ selectFrom: () => query } as never,
|
|
35
|
+
legacyPolicyVersionCache,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
process.on('message', async (command: WorkerCommand) => {
|
|
39
|
+
try {
|
|
40
|
+
const value = await resolveMap(command.entityId as never)
|
|
41
|
+
process.send?.({ id: command.id, ok: true, value })
|
|
42
|
+
} catch (error) {
|
|
43
|
+
process.send?.({
|
|
44
|
+
id: command.id,
|
|
45
|
+
ok: false,
|
|
46
|
+
error: error instanceof Error ? error.message : String(error),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
process.send?.({ ready: true })
|
package/src/di.ts
CHANGED
|
@@ -25,6 +25,9 @@ import type { VectorIndexJobPayload } from './queue/vector-indexing'
|
|
|
25
25
|
import type { EncryptionMapEntry } from './lib/field-policy'
|
|
26
26
|
import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
27
27
|
import { createPresenterEnricher } from './lib/presenter-enricher'
|
|
28
|
+
import { createEncryptionMapResolver } from './lib/encryption-map-resolver'
|
|
29
|
+
|
|
30
|
+
export { createEncryptionMapResolver } from './lib/encryption-map-resolver'
|
|
28
31
|
|
|
29
32
|
const FULLTEXT_DRIVER_KEY = '__omSearchFulltextDriver__'
|
|
30
33
|
|
|
@@ -46,46 +49,9 @@ function shouldExcludeEncryptedFields(): boolean {
|
|
|
46
49
|
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*/
|
|
53
|
-
function createEncryptionMapResolver(
|
|
54
|
-
db: Kysely<any>,
|
|
55
|
-
): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
|
|
56
|
-
// Cache encryption maps per entity to avoid repeated queries
|
|
57
|
-
const cache = new Map<string, { entries: EncryptionMapEntry[]; expiresAt: number }>()
|
|
58
|
-
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
59
|
-
|
|
60
|
-
return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {
|
|
61
|
-
const cached = cache.get(entityId)
|
|
62
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
63
|
-
return cached.entries
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const row = await db
|
|
68
|
-
.selectFrom('encryption_maps' as any)
|
|
69
|
-
.select(['fields_json' as any])
|
|
70
|
-
.where('entity_id' as any, '=', entityId)
|
|
71
|
-
.where('is_active' as any, '=', true)
|
|
72
|
-
.where('deleted_at' as any, 'is', null)
|
|
73
|
-
.executeTakeFirst() as { fields_json?: unknown } | undefined
|
|
74
|
-
|
|
75
|
-
const fieldsJson = row?.fields_json
|
|
76
|
-
const entries: EncryptionMapEntry[] = Array.isArray(fieldsJson)
|
|
77
|
-
? fieldsJson.map((f: { field: string; hashField?: string | null }) => ({
|
|
78
|
-
field: f.field,
|
|
79
|
-
hashField: f.hashField ?? null,
|
|
80
|
-
}))
|
|
81
|
-
: []
|
|
82
|
-
|
|
83
|
-
cache.set(entityId, { entries, expiresAt: Date.now() + CACHE_TTL_MS })
|
|
84
|
-
return entries
|
|
85
|
-
} catch {
|
|
86
|
-
// Query failed, return empty array (don't exclude any fields)
|
|
87
|
-
return []
|
|
88
|
-
}
|
|
52
|
+
function createUnavailableEncryptionMapResolver(): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
|
|
53
|
+
return async () => {
|
|
54
|
+
throw new Error('[internal] Encryption map lookup is unavailable; refusing to index potentially encrypted fields')
|
|
89
55
|
}
|
|
90
56
|
}
|
|
91
57
|
|
|
@@ -193,7 +159,7 @@ export function registerSearchModule(
|
|
|
193
159
|
const db = em.getKysely() as Kysely<any>
|
|
194
160
|
encryptionMapResolver = createEncryptionMapResolver(db)
|
|
195
161
|
} catch {
|
|
196
|
-
|
|
162
|
+
encryptionMapResolver = createUnavailableEncryptionMapResolver()
|
|
197
163
|
}
|
|
198
164
|
}
|
|
199
165
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely'
|
|
2
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
3
|
+
import type { EncryptionMapEntry } from './field-policy'
|
|
4
|
+
|
|
5
|
+
type EncryptionMapRow = {
|
|
6
|
+
id?: unknown
|
|
7
|
+
tenant_id?: unknown
|
|
8
|
+
organization_id?: unknown
|
|
9
|
+
fields_json?: unknown
|
|
10
|
+
created_at?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type LegacyEncryptionMapPolicyVersionCache = {
|
|
14
|
+
get(key: string): Promise<unknown | null>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function compareNullableScope(left: unknown, right: unknown): number {
|
|
18
|
+
if (left == null && right == null) return 0
|
|
19
|
+
if (left == null) return -1
|
|
20
|
+
if (right == null) return 1
|
|
21
|
+
return String(left).localeCompare(String(right))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function compareCreatedAt(left: unknown, right: unknown): number {
|
|
25
|
+
const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left ?? ''))
|
|
26
|
+
const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right ?? ''))
|
|
27
|
+
if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) return leftTime - rightTime
|
|
28
|
+
return String(left ?? '').localeCompare(String(right ?? ''))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function compareEncryptionMapRows(left: EncryptionMapRow, right: EncryptionMapRow): number {
|
|
32
|
+
return compareNullableScope(left.tenant_id, right.tenant_id)
|
|
33
|
+
|| compareNullableScope(left.organization_id, right.organization_id)
|
|
34
|
+
|| compareCreatedAt(left.created_at, right.created_at)
|
|
35
|
+
|| String(left.id ?? '').localeCompare(String(right.id ?? ''))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseEncryptionMapFields(value: unknown): unknown[] {
|
|
39
|
+
if (Array.isArray(value)) return value
|
|
40
|
+
if (typeof value !== 'string') return []
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(value) as unknown
|
|
43
|
+
return Array.isArray(parsed) ? parsed : []
|
|
44
|
+
} catch {
|
|
45
|
+
return []
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeEncryptionMapEntries(rows: readonly EncryptionMapRow[]): EncryptionMapEntry[] {
|
|
50
|
+
const merged: EncryptionMapEntry[] = []
|
|
51
|
+
const byField = new Map<string, EncryptionMapEntry>()
|
|
52
|
+
for (const row of [...rows].sort(compareEncryptionMapRows)) {
|
|
53
|
+
for (const candidate of parseEncryptionMapFields(row.fields_json)) {
|
|
54
|
+
if (!candidate || typeof candidate !== 'object') continue
|
|
55
|
+
const rule = candidate as { field?: unknown; hashField?: unknown }
|
|
56
|
+
const field = typeof rule.field === 'string' ? rule.field.trim() : ''
|
|
57
|
+
if (!field) continue
|
|
58
|
+
const hashField = typeof rule.hashField === 'string' && rule.hashField.length > 0
|
|
59
|
+
? rule.hashField
|
|
60
|
+
: null
|
|
61
|
+
const existing = byField.get(field)
|
|
62
|
+
if (!existing) {
|
|
63
|
+
const entry = { field, hashField }
|
|
64
|
+
byField.set(field, entry)
|
|
65
|
+
merged.push(entry)
|
|
66
|
+
} else if (!existing.hashField && hashField) {
|
|
67
|
+
existing.hashField = hashField
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return merged
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve every active live declaration for an entity. Search indexing has no
|
|
76
|
+
* tenant/scope argument at this boundary, so the safe policy is the union of
|
|
77
|
+
* all scopes: over-excluding a field is preferable to publishing plaintext.
|
|
78
|
+
*
|
|
79
|
+
* The legacy version-cache argument remains optional for call-site compatibility but is
|
|
80
|
+
* intentionally ignored. A version publication can fail after the database commit, so neither
|
|
81
|
+
* that channel nor process-local positive memory can authorize reuse of an older policy.
|
|
82
|
+
*/
|
|
83
|
+
export function createEncryptionMapResolver(
|
|
84
|
+
db: Kysely<any>,
|
|
85
|
+
legacyPolicyVersionCache?: LegacyEncryptionMapPolicyVersionCache,
|
|
86
|
+
): (entityId: EntityId) => Promise<EncryptionMapEntry[]> {
|
|
87
|
+
void legacyPolicyVersionCache
|
|
88
|
+
|
|
89
|
+
return async (entityId: EntityId): Promise<EncryptionMapEntry[]> => {
|
|
90
|
+
const rows = await db
|
|
91
|
+
.selectFrom('encryption_maps' as any)
|
|
92
|
+
.select([
|
|
93
|
+
'id' as any,
|
|
94
|
+
'tenant_id' as any,
|
|
95
|
+
'organization_id' as any,
|
|
96
|
+
'fields_json' as any,
|
|
97
|
+
'created_at' as any,
|
|
98
|
+
])
|
|
99
|
+
.where('entity_id' as any, '=', entityId)
|
|
100
|
+
.where('is_active' as any, '=', true)
|
|
101
|
+
.where('deleted_at' as any, 'is', null)
|
|
102
|
+
.orderBy('tenant_id' as any, 'asc')
|
|
103
|
+
.orderBy('organization_id' as any, 'asc')
|
|
104
|
+
.orderBy('created_at' as any, 'asc')
|
|
105
|
+
.orderBy('id' as any, 'asc')
|
|
106
|
+
.execute() as EncryptionMapRow[]
|
|
107
|
+
|
|
108
|
+
return mergeEncryptionMapEntries(Array.isArray(rows) ? rows : [])
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
jest.mock('@open-mercato/shared/lib/query/engine', () => ({
|
|
2
|
+
resolveEntityTableName: jest.fn(() => 'feature_toggles'),
|
|
3
|
+
}))
|
|
4
|
+
jest.mock('../../lib/auto-indexing', () => ({
|
|
5
|
+
resolveAutoIndexingEnabled: jest.fn().mockResolvedValue(false),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import vectorUpsert from '../vector_upsert'
|
|
9
|
+
import vectorDelete from '../vector_delete'
|
|
10
|
+
import fulltextUpsert from '../fulltext_upsert'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The unscoped-record lookup reads `organization_id`/`tenant_id` from the
|
|
14
|
+
* entity table and swallows failures (global tables such as `feature_toggles`
|
|
15
|
+
* lack those columns). Running it on the shared request EntityManager let the
|
|
16
|
+
* failed statement land inside whatever write transaction that EM currently
|
|
17
|
+
* held (e.g. the audit redo finalization flush), aborting it. The lookup must
|
|
18
|
+
* run on a fork so a swallowed error can never poison a caller's transaction.
|
|
19
|
+
*/
|
|
20
|
+
function buildContext() {
|
|
21
|
+
const executeTakeFirst = jest.fn().mockRejectedValue(new Error('column "organization_id" does not exist'))
|
|
22
|
+
const where = jest.fn(() => ({ executeTakeFirst }))
|
|
23
|
+
const select = jest.fn(() => ({ where }))
|
|
24
|
+
const forkKysely = { selectFrom: jest.fn(() => ({ select })) }
|
|
25
|
+
const forkEm = { getKysely: jest.fn(() => forkKysely) }
|
|
26
|
+
const requestEm = { getKysely: jest.fn(), fork: jest.fn(() => forkEm) }
|
|
27
|
+
const ctx = {
|
|
28
|
+
resolve: jest.fn((name: string) => {
|
|
29
|
+
if (name === 'em') return requestEm
|
|
30
|
+
throw new Error(`[internal] not registered: ${name}`)
|
|
31
|
+
}),
|
|
32
|
+
}
|
|
33
|
+
return { ctx, requestEm, forkEm, executeTakeFirst }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SubscriberHandler = (payload: Record<string, unknown>, ctx: unknown) => Promise<void>
|
|
37
|
+
|
|
38
|
+
describe('search subscriber scope lookup isolation', () => {
|
|
39
|
+
it.each<[string, SubscriberHandler, Record<string, unknown>]>([
|
|
40
|
+
['query_index.vectorize_one', vectorUpsert as SubscriberHandler, { entityType: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
|
|
41
|
+
['query_index.delete_one', vectorDelete as SubscriberHandler, { entityType: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
|
|
42
|
+
['search.index_record', fulltextUpsert as SubscriberHandler, { entityId: 'feature_toggles:feature_toggle', recordId: 'r1', tenantId: null, organizationId: null }],
|
|
43
|
+
])('%s never runs the unscoped lookup on the request EntityManager', async (_event, handler, payload) => {
|
|
44
|
+
const { ctx, requestEm, forkEm, executeTakeFirst } = buildContext()
|
|
45
|
+
|
|
46
|
+
await expect(handler(payload, ctx)).resolves.toBeUndefined()
|
|
47
|
+
|
|
48
|
+
expect(requestEm.getKysely).not.toHaveBeenCalled()
|
|
49
|
+
expect(requestEm.fork).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(forkEm.getKysely).toHaveBeenCalledTimes(1)
|
|
51
|
+
expect(executeTakeFirst).toHaveBeenCalledTimes(1)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -37,7 +37,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
|
|
|
37
37
|
// Resolve missing scope from DB if needed (same pattern as vector_upsert.ts)
|
|
38
38
|
if ((organizationId == null || tenantId == null) && em) {
|
|
39
39
|
try {
|
|
40
|
-
const
|
|
40
|
+
const lookupEm = typeof em.fork === 'function' ? em.fork() : em
|
|
41
|
+
const db = lookupEm.getKysely()
|
|
41
42
|
const table = resolveEntityTableName(em, entityType)
|
|
42
43
|
const row = await db
|
|
43
44
|
.selectFrom(table as any)
|
|
@@ -33,7 +33,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
|
|
|
33
33
|
|
|
34
34
|
if ((organizationId == null || tenantId == null) && em) {
|
|
35
35
|
try {
|
|
36
|
-
const
|
|
36
|
+
const lookupEm = typeof em.fork === 'function' ? em.fork() : em
|
|
37
|
+
const db = lookupEm.getKysely()
|
|
37
38
|
const table = resolveEntityTableName(em, entityType)
|
|
38
39
|
const row = await db
|
|
39
40
|
.selectFrom(table as any)
|
|
@@ -33,7 +33,8 @@ export default async function handle(payload: Payload, ctx: HandlerContext) {
|
|
|
33
33
|
|
|
34
34
|
if ((organizationId == null || tenantId == null) && em) {
|
|
35
35
|
try {
|
|
36
|
-
const
|
|
36
|
+
const lookupEm = typeof em.fork === 'function' ? em.fork() : em
|
|
37
|
+
const db = lookupEm.getKysely()
|
|
37
38
|
const table = resolveEntityTableName(em, entityType)
|
|
38
39
|
const row = await db
|
|
39
40
|
.selectFrom(table as any)
|