@open-mercato/search 0.10.1-develop.7766.1.666ed40a18 → 0.10.1-develop.7767.1.440f7c2b4c

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.
@@ -63,7 +63,8 @@ function stripSensitiveFields(record, config) {
63
63
  if (sensitive.size === 0) return record;
64
64
  const safe = {};
65
65
  for (const [key, value] of Object.entries(record)) {
66
- if (sensitive.has(key)) continue;
66
+ const fieldName = key.replace(/^cf[:_]/, "");
67
+ if (sensitive.has(key) || sensitive.has(fieldName)) continue;
67
68
  safe[key] = value;
68
69
  }
69
70
  return safe;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/search/ai-tools.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type {\n SearchEntityConfig,\n SearchResult,\n SearchStrategyId,\n} from '@open-mercato/shared/modules/search'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport {\n filterSearchResultsByEntityAccess,\n resolveReadableEntityTypes,\n type SearchEntityConfigLookup,\n} from './lib/entity-access'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { AwilixContainer } from 'awilix'\n\n/**\n * AI Tools definitions for the Search module.\n *\n * These tool definitions are discovered by the ai-assistant module's generator\n * and registered as MCP tools. The search module does not depend on ai-assistant.\n *\n * Tool Definition Format:\n * - name: Unique tool identifier (module_action format, no dots allowed)\n * - description: Human-readable description for AI clients\n * - inputSchema: Zod schema for input validation\n * - requiredFeatures: ACL features required to execute\n * - handler: Async function that executes the tool\n */\n\n/**\n * Tool context provided by the MCP server at execution time.\n */\ntype ToolContext = {\n tenantId: string | null\n organizationId: string | null\n userId: string | null\n container: {\n resolve: <T = unknown>(name: string) => T\n }\n userFeatures: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Tool definition structure.\n */\ntype AiToolDefinition = {\n name: string\n description: string\n inputSchema: z.ZodType<any>\n requiredFeatures?: string[]\n /**\n * Optional flag \u2014 when true, the tool is treated as a write by the\n * agent runtime and routed through the pending-action approval card.\n * Mirrors the public `AiToolDefinition.isMutation` flag without taking\n * a hard dependency on `@open-mercato/ai-assistant` here.\n */\n isMutation?: boolean\n handler: (input: any, ctx: ToolContext) => Promise<unknown>\n}\n\n/**\n * Minimal shape of the `searchIndexer` DI service consumed by the per-entity\n * ACL / field-policy resolution below. Aliased rather than redeclared so the\n * tools and the global-search route cannot drift apart, and still narrow enough\n * to avoid importing the full `SearchIndexer` class into the tool module.\n */\ntype SearchIndexerLike = SearchEntityConfigLookup\n\nclass SearchToolAuthorizationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'SearchToolAuthorizationError'\n }\n}\n\n/**\n * Resolve the search config for an entity type and enforce the per-entity view\n * feature(s). Data-returning tools (`search_get` / `search_aggregate`) MUST NOT\n * rely on the search-administration `search.view` feature to gate record reads.\n *\n * Fails closed: when the entity is not configured for search, or has no\n * `aclFeatures` declared, access is denied so records are never exposed without\n * an explicit owning-module grant.\n */\nfunction authorizeEntityAccess(entityType: string, ctx: ToolContext): SearchEntityConfig {\n const searchIndexer = ctx.container.resolve<SearchIndexerLike>('searchIndexer')\n const config = searchIndexer.getEntityConfig(entityType)\n\n if (!config) {\n throw new SearchToolAuthorizationError(\n `[internal] Entity type \"${entityType}\" is not configured for search`\n )\n }\n\n if (ctx.isSuperAdmin) return config\n\n const required = config.aclFeatures\n if (!required || required.length === 0) {\n throw new SearchToolAuthorizationError(\n `[internal] Entity type \"${entityType}\" does not declare aclFeatures; access denied`\n )\n }\n\n if (!authorizeFeatures(required, {\n grantedFeatures: ctx.userFeatures,\n unrestricted: ctx.isSuperAdmin,\n })) {\n throw new SearchToolAuthorizationError(\n `[internal] Insufficient permissions for entity \"${entityType}\". Required: ${required.join(', ')}`\n )\n }\n\n return config\n}\n\n/**\n * Resolve the actor's allowed-organization scope the same way the HTTP search\n * route does, so AI search tools can never return results outside a\n * restricted user's organization allowlist \u2014 even when their current\n * organization is null. Mirrors packages/search/.../api/search/route.ts.\n *\n * `organizationId` is the re-validated selection (`scope.selectedId`), not the\n * raw `ctx.organizationId` \u2014 `SearchOptions.organizationId` takes precedence\n * over `organizationIds` in `normalizeOrganizationFilter`, so passing the\n * unvalidated context value through would let a stale/out-of-allowlist\n * selection bypass the allowlist below.\n */\nasync function resolveActorOrganizationScope(ctx: ToolContext): Promise<{\n organizationId: string | undefined\n organizationIds: string[] | undefined\n denyAll: boolean\n}> {\n if (!ctx.userId) return { organizationId: undefined, organizationIds: undefined, denyAll: true }\n const auth: AuthContext = {\n sub: ctx.userId,\n tenantId: ctx.tenantId,\n orgId: ctx.organizationId,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n const scope = await resolveOrganizationScopeForRequest({\n container: ctx.container as unknown as AwilixContainer,\n auth,\n selectedId: ctx.organizationId,\n })\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const denyAll = Array.isArray(scope.filterIds) && scope.filterIds.length === 0\n const organizationId = typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0\n ? scope.selectedId.trim()\n : undefined\n return { organizationId, organizationIds: scopeFilter.organizationIds, denyAll }\n}\n\n/**\n * Strip fields that the entity's field policy marks as sensitive\n * (`hashOnly` exact-match PII or fully `excluded`) from a query-engine record.\n * Defense-in-depth so even an authorized caller never receives decrypted PII or\n * fields a `fieldPolicy` keeps out of search.\n */\nfunction stripSensitiveFields(\n record: Record<string, unknown>,\n config: SearchEntityConfig\n): Record<string, unknown> {\n const sensitive = new Set<string>([\n ...(config.fieldPolicy?.hashOnly ?? []),\n ...(config.fieldPolicy?.excluded ?? []),\n ])\n if (sensitive.size === 0) return record\n const safe: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(record)) {\n if (sensitive.has(key)) continue\n safe[key] = value\n }\n return safe\n}\n\n/**\n * Determine whether a field is a safe aggregation grouping key for an entity.\n * Only fields explicitly declared `searchable` are allowed; `hashOnly` and\n * `excluded` (PII / sensitive) fields can never be enumerated via group-by.\n */\nfunction isGroupByAllowed(field: string, config: SearchEntityConfig): boolean {\n const sensitive = new Set<string>([\n ...(config.fieldPolicy?.hashOnly ?? []),\n ...(config.fieldPolicy?.excluded ?? []),\n ])\n if (sensitive.has(field)) return false\n const searchable = config.fieldPolicy?.searchable ?? []\n return searchable.includes(field)\n}\n\n// =============================================================================\n// Tool Definitions\n// =============================================================================\n\nconst searchQueryTool: AiToolDefinition = {\n name: 'search_query',\n description: `Search across all data using hybrid search. Use this FIRST for finding records.\n\nReturns: title, subtitle, entityType, recordId, url for each match.\nSearches customers, products, orders, deals, and more in one call.`,\n inputSchema: z.object({\n query: z.string().min(1).describe('The search query text'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of results to return (default: 20)'),\n entityTypes: z\n .array(z.string())\n .optional()\n .describe(\n 'Filter to specific entity types (e.g., [\"customers:customer_person_profile\", \"catalog:product\"])'\n ),\n strategies: z\n .array(z.enum(['fulltext', 'vector', 'tokens']))\n .optional()\n .describe('Specific search strategies to use (default: all available)'),\n }),\n requiredFeatures: ['search.global'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search')\n }\n\n const searchService = ctx.container.resolve<{\n search: (query: string, options: any) => Promise<SearchResult[]>\n }>('searchService')\n\n // Same rule the HTTP palette applies: `search.global` authorizes using search,\n // not reading every indexed entity type. The query is narrowed to the readable\n // types so `limit` is not spent on records that would be filtered out, and the\n // results are filtered afterwards as defense in depth.\n const searchIndexer = ctx.container.resolve<SearchIndexerLike>('searchIndexer')\n const subject = { grantedFeatures: ctx.userFeatures, isSuperAdmin: ctx.isSuperAdmin }\n const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, input.entityTypes)\n if (readableEntityTypes && readableEntityTypes.length === 0) {\n return { query: input.query, totalResults: 0, results: [] }\n }\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return { query: input.query, totalResults: 0, results: [] }\n }\n\n const rawResults = await searchService.search(input.query, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n entityTypes: readableEntityTypes,\n strategies: input.strategies as SearchStrategyId[],\n limit: input.limit,\n })\n\n const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject)\n\n return {\n query: input.query,\n totalResults: results.length,\n results: results.map((result) => ({\n entityType: result.entityId,\n recordId: result.recordId,\n score: Math.round(result.score * 100) / 100,\n source: result.source,\n title: result.presenter?.title ?? result.recordId,\n subtitle: result.presenter?.subtitle,\n url: result.url,\n })),\n }\n },\n}\n\nconst searchStatusTool: AiToolDefinition = {\n name: 'search_status',\n description:\n 'Get the current status of the search module, including available search strategies and their availability.',\n inputSchema: z.object({}),\n requiredFeatures: ['search.view'],\n handler: async (_input, ctx) => {\n const searchService = ctx.container.resolve<{\n getStrategies: () => Array<{\n id: string\n name: string\n priority: number\n isAvailable: () => Promise<boolean>\n }>\n getDefaultStrategies: () => string[]\n }>('searchService')\n\n const strategies = searchService.getStrategies()\n const defaultStrategies = searchService.getDefaultStrategies()\n\n const strategyStatus = await Promise.all(\n strategies.map(async (strategy) => ({\n id: strategy.id,\n name: strategy.name,\n priority: strategy.priority,\n isAvailable: await strategy.isAvailable(),\n isDefault: defaultStrategies.includes(strategy.id),\n }))\n )\n\n return {\n strategiesRegistered: strategies.length,\n defaultStrategies,\n strategies: strategyStatus,\n }\n },\n}\n\n// =============================================================================\n// search.get - Retrieve full record details by entity type and ID\n// =============================================================================\n\nconst searchGetTool: AiToolDefinition = {\n name: 'search_get',\n description: `Get full record details by entityType and recordId from search_query results.`,\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type (e.g., \"customers:customer_company_profile\", \"customers:customer_deal\")'),\n recordId: z.string().describe('The record ID (UUID)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const entityConfig = authorizeEntityAccess(input.entityType, ctx)\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return {\n found: false,\n entityType: input.entityType,\n recordId: input.recordId,\n error: 'Record not found',\n }\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n filters: { id: input.recordId },\n includeCustomFields: true,\n page: { page: 1, pageSize: 1 },\n })\n\n const rawRecord = result.items[0] as Record<string, unknown> | undefined\n if (!rawRecord) {\n return {\n found: false,\n entityType: input.entityType,\n recordId: input.recordId,\n error: 'Record not found',\n }\n }\n\n const record = stripSensitiveFields(rawRecord, entityConfig)\n\n // Extract custom fields\n const customFields: Record<string, unknown> = {}\n const standardFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(record)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.replace(/^cf[:_]/, '')] = value\n } else {\n standardFields[key] = value\n }\n }\n\n // Build URL based on entity type\n let url: string | null = null\n const id = record.id ?? record.entity_id ?? input.recordId\n if (input.entityType.includes('person')) {\n url = `/backend/customers/people/${id}`\n } else if (input.entityType.includes('company')) {\n url = `/backend/customers/companies/${id}`\n } else if (input.entityType.includes('deal')) {\n url = `/backend/customers/deals/${id}`\n } else if (input.entityType.includes('activity')) {\n const entityId = record.entity_id ?? record.entityId\n url = entityId ? `/backend/customers/companies/${entityId}#activity-${id}` : null\n }\n\n return {\n found: true,\n entityType: input.entityType,\n recordId: input.recordId,\n record: standardFields,\n customFields: Object.keys(customFields).length > 0 ? customFields : undefined,\n url,\n }\n },\n}\n\n// =============================================================================\n// search.schema - Discover searchable entities and their fields\n// =============================================================================\n\nconst searchSchemaTool: AiToolDefinition = {\n name: 'search_schema',\n description:\n 'Discover searchable entities and their fields. Use this to learn what data can be searched and what fields are available for filtering.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe('Optional: Get schema for a specific entity type only'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n const searchIndexer = ctx.container.resolve<{\n getAllEntityConfigs: () => Array<{\n entityId: string\n enabled?: boolean\n priority?: number\n strategies?: string[]\n fieldPolicy?: {\n searchable?: string[]\n hashOnly?: string[]\n excluded?: string[]\n }\n }>\n }>('searchIndexer')\n\n const allConfigs = searchIndexer.getAllEntityConfigs()\n const entities: Array<{\n entityId: string\n enabled: boolean\n priority: number\n strategies?: string[]\n searchableFields?: string[]\n hashOnlyFields?: string[]\n excludedFields?: string[]\n }> = []\n\n for (const entityConfig of allConfigs) {\n if (input.entityType && entityConfig.entityId !== input.entityType) {\n continue\n }\n\n entities.push({\n entityId: entityConfig.entityId,\n enabled: entityConfig.enabled !== false,\n priority: entityConfig.priority ?? 5,\n strategies: entityConfig.strategies,\n searchableFields: entityConfig.fieldPolicy?.searchable,\n hashOnlyFields: entityConfig.fieldPolicy?.hashOnly,\n excludedFields: entityConfig.fieldPolicy?.excluded,\n })\n }\n\n if (input.entityType && entities.length === 0) {\n return {\n found: false,\n entityType: input.entityType,\n error: 'Entity type not configured for search',\n }\n }\n\n return {\n totalEntities: entities.length,\n entities: entities.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)),\n }\n },\n}\n\n// =============================================================================\n// search.aggregate - Get counts grouped by field values\n// =============================================================================\n\nconst searchAggregateTool: AiToolDefinition = {\n name: 'search_aggregate',\n description:\n 'Get record counts grouped by a field value. Useful for analytics like \"how many deals by stage?\" or \"customers by status\". Samples up to 100 records \u2014 percentages may not reflect the full dataset for large entity sets.',\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type to aggregate (e.g., \"customers:customer_deal\")'),\n groupBy: z\n .string()\n .describe('The field to group by (e.g., \"status\", \"industry\", \"pipeline_stage\")'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of buckets to return (default: 20)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const entityConfig = authorizeEntityAccess(input.entityType, ctx)\n\n if (!isGroupByAllowed(input.groupBy, entityConfig)) {\n throw new SearchToolAuthorizationError(\n `[internal] Field \"${input.groupBy}\" is not an allowed grouping key for \"${input.entityType}\"`\n )\n }\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return {\n entityType: input.entityType,\n groupBy: input.groupBy,\n total: 0,\n buckets: [],\n }\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n // Fetch records and aggregate in memory\n // Note: For large datasets, this should use database GROUP BY\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n page: { page: 1, pageSize: 100 },\n })\n\n const counts = new Map<string | null, number>()\n for (const item of result.items as Record<string, unknown>[]) {\n const value = item[input.groupBy]\n const key = value === null || value === undefined ? null : String(value)\n counts.set(key, (counts.get(key) ?? 0) + 1)\n }\n\n const total = result.items.length\n const buckets = Array.from(counts.entries())\n .map(([value, count]) => ({\n value,\n count,\n percentage: Math.round((count / total) * 100 * 100) / 100,\n }))\n .sort((a, b) => b.count - a.count)\n .slice(0, input.limit)\n\n return {\n entityType: input.entityType,\n groupBy: input.groupBy,\n total,\n buckets,\n }\n },\n}\n\nconst searchReindexTool: AiToolDefinition = {\n name: 'search_reindex',\n description:\n 'Trigger a reindex operation for search data. This rebuilds the search index for the specified entity type or all entities.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe(\n 'Specific entity type to reindex (e.g., \"customers:customer_person_profile\"). If not provided, reindexes all entities.'\n ),\n strategy: z\n .enum(['fulltext', 'vector'])\n .optional()\n .default('fulltext')\n .describe('Which search strategy to reindex (default: fulltext)'),\n recreateIndex: z\n .boolean()\n .optional()\n .default(false)\n .describe('Whether to recreate the index from scratch (default: false)'),\n }),\n requiredFeatures: ['search.reindex'],\n // Reindex changes server-side index state \u2014 must surface as a write so\n // any agent that whitelists it routes through the approval card.\n isMutation: true,\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for reindex')\n }\n\n const searchIndexer = ctx.container.resolve<{\n reindexEntityToFulltext: (params: any) => Promise<any>\n reindexAllToFulltext: (params: any) => Promise<any>\n reindexEntityToVector: (params: any) => Promise<void>\n reindexAllToVector: (params: any) => Promise<void>\n }>('searchIndexer')\n\n const baseParams = {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n recreateIndex: input.recreateIndex,\n useQueue: true,\n }\n\n if (input.strategy === 'vector') {\n if (input.entityType) {\n await searchIndexer.reindexEntityToVector({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'started',\n strategy: 'vector',\n entityType: input.entityType,\n message: `Vector reindex started for ${input.entityType}`,\n }\n } else {\n await searchIndexer.reindexAllToVector(baseParams)\n return {\n status: 'started',\n strategy: 'vector',\n entityType: 'all',\n message: 'Vector reindex started for all entities',\n }\n }\n } else {\n if (input.entityType) {\n const result = await searchIndexer.reindexEntityToFulltext({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: input.entityType,\n ...result,\n }\n } else {\n const result = await searchIndexer.reindexAllToFulltext(baseParams)\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: 'all',\n ...result,\n }\n }\n }\n },\n}\n\n// =============================================================================\n// Export\n// =============================================================================\n\n/**\n * All AI tools exported by the search module.\n * Discovered by ai-assistant module's generator.\n */\nexport const aiTools = [\n searchQueryTool,\n searchStatusTool,\n searchGetTool,\n searchSchemaTool,\n searchAggregateTool,\n searchReindexTool,\n]\n\nexport default aiTools\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type {\n SearchEntityConfig,\n SearchResult,\n SearchStrategyId,\n} from '@open-mercato/shared/modules/search'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport {\n filterSearchResultsByEntityAccess,\n resolveReadableEntityTypes,\n type SearchEntityConfigLookup,\n} from './lib/entity-access'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { AwilixContainer } from 'awilix'\n\n/**\n * AI Tools definitions for the Search module.\n *\n * These tool definitions are discovered by the ai-assistant module's generator\n * and registered as MCP tools. The search module does not depend on ai-assistant.\n *\n * Tool Definition Format:\n * - name: Unique tool identifier (module_action format, no dots allowed)\n * - description: Human-readable description for AI clients\n * - inputSchema: Zod schema for input validation\n * - requiredFeatures: ACL features required to execute\n * - handler: Async function that executes the tool\n */\n\n/**\n * Tool context provided by the MCP server at execution time.\n */\ntype ToolContext = {\n tenantId: string | null\n organizationId: string | null\n userId: string | null\n container: {\n resolve: <T = unknown>(name: string) => T\n }\n userFeatures: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Tool definition structure.\n */\ntype AiToolDefinition = {\n name: string\n description: string\n inputSchema: z.ZodType<any>\n requiredFeatures?: string[]\n /**\n * Optional flag \u2014 when true, the tool is treated as a write by the\n * agent runtime and routed through the pending-action approval card.\n * Mirrors the public `AiToolDefinition.isMutation` flag without taking\n * a hard dependency on `@open-mercato/ai-assistant` here.\n */\n isMutation?: boolean\n handler: (input: any, ctx: ToolContext) => Promise<unknown>\n}\n\n/**\n * Minimal shape of the `searchIndexer` DI service consumed by the per-entity\n * ACL / field-policy resolution below. Aliased rather than redeclared so the\n * tools and the global-search route cannot drift apart, and still narrow enough\n * to avoid importing the full `SearchIndexer` class into the tool module.\n */\ntype SearchIndexerLike = SearchEntityConfigLookup\n\nclass SearchToolAuthorizationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'SearchToolAuthorizationError'\n }\n}\n\n/**\n * Resolve the search config for an entity type and enforce the per-entity view\n * feature(s). Data-returning tools (`search_get` / `search_aggregate`) MUST NOT\n * rely on the search-administration `search.view` feature to gate record reads.\n *\n * Fails closed: when the entity is not configured for search, or has no\n * `aclFeatures` declared, access is denied so records are never exposed without\n * an explicit owning-module grant.\n */\nfunction authorizeEntityAccess(entityType: string, ctx: ToolContext): SearchEntityConfig {\n const searchIndexer = ctx.container.resolve<SearchIndexerLike>('searchIndexer')\n const config = searchIndexer.getEntityConfig(entityType)\n\n if (!config) {\n throw new SearchToolAuthorizationError(\n `[internal] Entity type \"${entityType}\" is not configured for search`\n )\n }\n\n if (ctx.isSuperAdmin) return config\n\n const required = config.aclFeatures\n if (!required || required.length === 0) {\n throw new SearchToolAuthorizationError(\n `[internal] Entity type \"${entityType}\" does not declare aclFeatures; access denied`\n )\n }\n\n if (!authorizeFeatures(required, {\n grantedFeatures: ctx.userFeatures,\n unrestricted: ctx.isSuperAdmin,\n })) {\n throw new SearchToolAuthorizationError(\n `[internal] Insufficient permissions for entity \"${entityType}\". Required: ${required.join(', ')}`\n )\n }\n\n return config\n}\n\n/**\n * Resolve the actor's allowed-organization scope the same way the HTTP search\n * route does, so AI search tools can never return results outside a\n * restricted user's organization allowlist \u2014 even when their current\n * organization is null. Mirrors packages/search/.../api/search/route.ts.\n *\n * `organizationId` is the re-validated selection (`scope.selectedId`), not the\n * raw `ctx.organizationId` \u2014 `SearchOptions.organizationId` takes precedence\n * over `organizationIds` in `normalizeOrganizationFilter`, so passing the\n * unvalidated context value through would let a stale/out-of-allowlist\n * selection bypass the allowlist below.\n */\nasync function resolveActorOrganizationScope(ctx: ToolContext): Promise<{\n organizationId: string | undefined\n organizationIds: string[] | undefined\n denyAll: boolean\n}> {\n if (!ctx.userId) return { organizationId: undefined, organizationIds: undefined, denyAll: true }\n const auth: AuthContext = {\n sub: ctx.userId,\n tenantId: ctx.tenantId,\n orgId: ctx.organizationId,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n const scope = await resolveOrganizationScopeForRequest({\n container: ctx.container as unknown as AwilixContainer,\n auth,\n selectedId: ctx.organizationId,\n })\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const denyAll = Array.isArray(scope.filterIds) && scope.filterIds.length === 0\n const organizationId = typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0\n ? scope.selectedId.trim()\n : undefined\n return { organizationId, organizationIds: scopeFilter.organizationIds, denyAll }\n}\n\n/**\n * Strip fields that the entity's field policy marks as sensitive\n * (`hashOnly` exact-match PII or fully `excluded`) from a query-engine record.\n * Defense-in-depth so even an authorized caller never receives decrypted PII or\n * fields a `fieldPolicy` keeps out of search.\n */\nfunction stripSensitiveFields(\n record: Record<string, unknown>,\n config: SearchEntityConfig\n): Record<string, unknown> {\n const sensitive = new Set<string>([\n ...(config.fieldPolicy?.hashOnly ?? []),\n ...(config.fieldPolicy?.excluded ?? []),\n ])\n if (sensitive.size === 0) return record\n const safe: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(record)) {\n const fieldName = key.replace(/^cf[:_]/, '')\n if (sensitive.has(key) || sensitive.has(fieldName)) continue\n safe[key] = value\n }\n return safe\n}\n\n/**\n * Determine whether a field is a safe aggregation grouping key for an entity.\n * Only fields explicitly declared `searchable` are allowed; `hashOnly` and\n * `excluded` (PII / sensitive) fields can never be enumerated via group-by.\n */\nfunction isGroupByAllowed(field: string, config: SearchEntityConfig): boolean {\n const sensitive = new Set<string>([\n ...(config.fieldPolicy?.hashOnly ?? []),\n ...(config.fieldPolicy?.excluded ?? []),\n ])\n if (sensitive.has(field)) return false\n const searchable = config.fieldPolicy?.searchable ?? []\n return searchable.includes(field)\n}\n\n// =============================================================================\n// Tool Definitions\n// =============================================================================\n\nconst searchQueryTool: AiToolDefinition = {\n name: 'search_query',\n description: `Search across all data using hybrid search. Use this FIRST for finding records.\n\nReturns: title, subtitle, entityType, recordId, url for each match.\nSearches customers, products, orders, deals, and more in one call.`,\n inputSchema: z.object({\n query: z.string().min(1).describe('The search query text'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of results to return (default: 20)'),\n entityTypes: z\n .array(z.string())\n .optional()\n .describe(\n 'Filter to specific entity types (e.g., [\"customers:customer_person_profile\", \"catalog:product\"])'\n ),\n strategies: z\n .array(z.enum(['fulltext', 'vector', 'tokens']))\n .optional()\n .describe('Specific search strategies to use (default: all available)'),\n }),\n requiredFeatures: ['search.global'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search')\n }\n\n const searchService = ctx.container.resolve<{\n search: (query: string, options: any) => Promise<SearchResult[]>\n }>('searchService')\n\n // Same rule the HTTP palette applies: `search.global` authorizes using search,\n // not reading every indexed entity type. The query is narrowed to the readable\n // types so `limit` is not spent on records that would be filtered out, and the\n // results are filtered afterwards as defense in depth.\n const searchIndexer = ctx.container.resolve<SearchIndexerLike>('searchIndexer')\n const subject = { grantedFeatures: ctx.userFeatures, isSuperAdmin: ctx.isSuperAdmin }\n const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, input.entityTypes)\n if (readableEntityTypes && readableEntityTypes.length === 0) {\n return { query: input.query, totalResults: 0, results: [] }\n }\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return { query: input.query, totalResults: 0, results: [] }\n }\n\n const rawResults = await searchService.search(input.query, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n entityTypes: readableEntityTypes,\n strategies: input.strategies as SearchStrategyId[],\n limit: input.limit,\n })\n\n const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject)\n\n return {\n query: input.query,\n totalResults: results.length,\n results: results.map((result) => ({\n entityType: result.entityId,\n recordId: result.recordId,\n score: Math.round(result.score * 100) / 100,\n source: result.source,\n title: result.presenter?.title ?? result.recordId,\n subtitle: result.presenter?.subtitle,\n url: result.url,\n })),\n }\n },\n}\n\nconst searchStatusTool: AiToolDefinition = {\n name: 'search_status',\n description:\n 'Get the current status of the search module, including available search strategies and their availability.',\n inputSchema: z.object({}),\n requiredFeatures: ['search.view'],\n handler: async (_input, ctx) => {\n const searchService = ctx.container.resolve<{\n getStrategies: () => Array<{\n id: string\n name: string\n priority: number\n isAvailable: () => Promise<boolean>\n }>\n getDefaultStrategies: () => string[]\n }>('searchService')\n\n const strategies = searchService.getStrategies()\n const defaultStrategies = searchService.getDefaultStrategies()\n\n const strategyStatus = await Promise.all(\n strategies.map(async (strategy) => ({\n id: strategy.id,\n name: strategy.name,\n priority: strategy.priority,\n isAvailable: await strategy.isAvailable(),\n isDefault: defaultStrategies.includes(strategy.id),\n }))\n )\n\n return {\n strategiesRegistered: strategies.length,\n defaultStrategies,\n strategies: strategyStatus,\n }\n },\n}\n\n// =============================================================================\n// search.get - Retrieve full record details by entity type and ID\n// =============================================================================\n\nconst searchGetTool: AiToolDefinition = {\n name: 'search_get',\n description: `Get full record details by entityType and recordId from search_query results.`,\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type (e.g., \"customers:customer_company_profile\", \"customers:customer_deal\")'),\n recordId: z.string().describe('The record ID (UUID)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const entityConfig = authorizeEntityAccess(input.entityType, ctx)\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return {\n found: false,\n entityType: input.entityType,\n recordId: input.recordId,\n error: 'Record not found',\n }\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n filters: { id: input.recordId },\n includeCustomFields: true,\n page: { page: 1, pageSize: 1 },\n })\n\n const rawRecord = result.items[0] as Record<string, unknown> | undefined\n if (!rawRecord) {\n return {\n found: false,\n entityType: input.entityType,\n recordId: input.recordId,\n error: 'Record not found',\n }\n }\n\n const record = stripSensitiveFields(rawRecord, entityConfig)\n\n // Extract custom fields\n const customFields: Record<string, unknown> = {}\n const standardFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(record)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.replace(/^cf[:_]/, '')] = value\n } else {\n standardFields[key] = value\n }\n }\n\n // Build URL based on entity type\n let url: string | null = null\n const id = record.id ?? record.entity_id ?? input.recordId\n if (input.entityType.includes('person')) {\n url = `/backend/customers/people/${id}`\n } else if (input.entityType.includes('company')) {\n url = `/backend/customers/companies/${id}`\n } else if (input.entityType.includes('deal')) {\n url = `/backend/customers/deals/${id}`\n } else if (input.entityType.includes('activity')) {\n const entityId = record.entity_id ?? record.entityId\n url = entityId ? `/backend/customers/companies/${entityId}#activity-${id}` : null\n }\n\n return {\n found: true,\n entityType: input.entityType,\n recordId: input.recordId,\n record: standardFields,\n customFields: Object.keys(customFields).length > 0 ? customFields : undefined,\n url,\n }\n },\n}\n\n// =============================================================================\n// search.schema - Discover searchable entities and their fields\n// =============================================================================\n\nconst searchSchemaTool: AiToolDefinition = {\n name: 'search_schema',\n description:\n 'Discover searchable entities and their fields. Use this to learn what data can be searched and what fields are available for filtering.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe('Optional: Get schema for a specific entity type only'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n const searchIndexer = ctx.container.resolve<{\n getAllEntityConfigs: () => Array<{\n entityId: string\n enabled?: boolean\n priority?: number\n strategies?: string[]\n fieldPolicy?: {\n searchable?: string[]\n hashOnly?: string[]\n excluded?: string[]\n }\n }>\n }>('searchIndexer')\n\n const allConfigs = searchIndexer.getAllEntityConfigs()\n const entities: Array<{\n entityId: string\n enabled: boolean\n priority: number\n strategies?: string[]\n searchableFields?: string[]\n hashOnlyFields?: string[]\n excludedFields?: string[]\n }> = []\n\n for (const entityConfig of allConfigs) {\n if (input.entityType && entityConfig.entityId !== input.entityType) {\n continue\n }\n\n entities.push({\n entityId: entityConfig.entityId,\n enabled: entityConfig.enabled !== false,\n priority: entityConfig.priority ?? 5,\n strategies: entityConfig.strategies,\n searchableFields: entityConfig.fieldPolicy?.searchable,\n hashOnlyFields: entityConfig.fieldPolicy?.hashOnly,\n excludedFields: entityConfig.fieldPolicy?.excluded,\n })\n }\n\n if (input.entityType && entities.length === 0) {\n return {\n found: false,\n entityType: input.entityType,\n error: 'Entity type not configured for search',\n }\n }\n\n return {\n totalEntities: entities.length,\n entities: entities.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)),\n }\n },\n}\n\n// =============================================================================\n// search.aggregate - Get counts grouped by field values\n// =============================================================================\n\nconst searchAggregateTool: AiToolDefinition = {\n name: 'search_aggregate',\n description:\n 'Get record counts grouped by a field value. Useful for analytics like \"how many deals by stage?\" or \"customers by status\". Samples up to 100 records \u2014 percentages may not reflect the full dataset for large entity sets.',\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type to aggregate (e.g., \"customers:customer_deal\")'),\n groupBy: z\n .string()\n .describe('The field to group by (e.g., \"status\", \"industry\", \"pipeline_stage\")'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of buckets to return (default: 20)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const entityConfig = authorizeEntityAccess(input.entityType, ctx)\n\n if (!isGroupByAllowed(input.groupBy, entityConfig)) {\n throw new SearchToolAuthorizationError(\n `[internal] Field \"${input.groupBy}\" is not an allowed grouping key for \"${input.entityType}\"`\n )\n }\n\n const orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return {\n entityType: input.entityType,\n groupBy: input.groupBy,\n total: 0,\n buckets: [],\n }\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n // Fetch records and aggregate in memory\n // Note: For large datasets, this should use database GROUP BY\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\n page: { page: 1, pageSize: 100 },\n })\n\n const counts = new Map<string | null, number>()\n for (const item of result.items as Record<string, unknown>[]) {\n const value = item[input.groupBy]\n const key = value === null || value === undefined ? null : String(value)\n counts.set(key, (counts.get(key) ?? 0) + 1)\n }\n\n const total = result.items.length\n const buckets = Array.from(counts.entries())\n .map(([value, count]) => ({\n value,\n count,\n percentage: Math.round((count / total) * 100 * 100) / 100,\n }))\n .sort((a, b) => b.count - a.count)\n .slice(0, input.limit)\n\n return {\n entityType: input.entityType,\n groupBy: input.groupBy,\n total,\n buckets,\n }\n },\n}\n\nconst searchReindexTool: AiToolDefinition = {\n name: 'search_reindex',\n description:\n 'Trigger a reindex operation for search data. This rebuilds the search index for the specified entity type or all entities.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe(\n 'Specific entity type to reindex (e.g., \"customers:customer_person_profile\"). If not provided, reindexes all entities.'\n ),\n strategy: z\n .enum(['fulltext', 'vector'])\n .optional()\n .default('fulltext')\n .describe('Which search strategy to reindex (default: fulltext)'),\n recreateIndex: z\n .boolean()\n .optional()\n .default(false)\n .describe('Whether to recreate the index from scratch (default: false)'),\n }),\n requiredFeatures: ['search.reindex'],\n // Reindex changes server-side index state \u2014 must surface as a write so\n // any agent that whitelists it routes through the approval card.\n isMutation: true,\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for reindex')\n }\n\n const searchIndexer = ctx.container.resolve<{\n reindexEntityToFulltext: (params: any) => Promise<any>\n reindexAllToFulltext: (params: any) => Promise<any>\n reindexEntityToVector: (params: any) => Promise<void>\n reindexAllToVector: (params: any) => Promise<void>\n }>('searchIndexer')\n\n const baseParams = {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n recreateIndex: input.recreateIndex,\n useQueue: true,\n }\n\n if (input.strategy === 'vector') {\n if (input.entityType) {\n await searchIndexer.reindexEntityToVector({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'started',\n strategy: 'vector',\n entityType: input.entityType,\n message: `Vector reindex started for ${input.entityType}`,\n }\n } else {\n await searchIndexer.reindexAllToVector(baseParams)\n return {\n status: 'started',\n strategy: 'vector',\n entityType: 'all',\n message: 'Vector reindex started for all entities',\n }\n }\n } else {\n if (input.entityType) {\n const result = await searchIndexer.reindexEntityToFulltext({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: input.entityType,\n ...result,\n }\n } else {\n const result = await searchIndexer.reindexAllToFulltext(baseParams)\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: 'all',\n ...result,\n }\n }\n }\n },\n}\n\n// =============================================================================\n// Export\n// =============================================================================\n\n/**\n * All AI tools exported by the search module.\n * Discovered by ai-assistant module's generator.\n */\nexport const aiTools = [\n searchQueryTool,\n searchStatusTool,\n searchGetTool,\n searchSchemaTool,\n searchAggregateTool,\n searchReindexTool,\n]\n\nexport default aiTools\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.10.1-develop.7766.1.666ed40a18",
3
+ "version": "0.10.1-develop.7767.1.440f7c2b4c",
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.10.1-develop.7766.1.666ed40a18",
131
- "@open-mercato/queue": "0.10.1-develop.7766.1.666ed40a18",
132
- "@open-mercato/shared": "0.10.1-develop.7766.1.666ed40a18"
130
+ "@open-mercato/core": "0.10.1-develop.7767.1.440f7c2b4c",
131
+ "@open-mercato/queue": "0.10.1-develop.7767.1.440f7c2b4c",
132
+ "@open-mercato/shared": "0.10.1-develop.7767.1.440f7c2b4c"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -124,6 +124,62 @@ describe('search_get tool', () => {
124
124
  expect(result.record.date_of_birth).toBeUndefined()
125
125
  })
126
126
 
127
+ describe.each(['cf_', 'cf:'])('custom fields with the %s prefix', (prefix) => {
128
+ it.each([false, true])('strips sensitive values when isSuperAdmin is %s', async (isSuperAdmin) => {
129
+ const rawRecord = {
130
+ id: 'rec-1',
131
+ first_name: 'Jane',
132
+ primary_email: 'jane@example.com',
133
+ ssn: '123-45-6789',
134
+ [`${prefix}primary_email`]: 'private@example.com',
135
+ [`${prefix}primary_phone`]: '+1555',
136
+ [`${prefix}ssn`]: '987-65-4321',
137
+ [`${prefix}date_of_birth`]: '1990-01-01',
138
+ [`${prefix}interests`]: ['reading', 'travel'],
139
+ [`${prefix}score`]: 0,
140
+ [`${prefix}subscribed`]: false,
141
+ [`${prefix}notes`]: null,
142
+ legacy_cf_ssn: 'visible legacy field',
143
+ }
144
+ const { ctx } = makeCtx(rawRecord, { isSuperAdmin })
145
+
146
+ const result = await getTool.handler(
147
+ { entityType: 'customers:customer_person_profile', recordId: 'rec-1' },
148
+ ctx,
149
+ ) as { record: Record<string, unknown>; customFields?: Record<string, unknown> }
150
+
151
+ expect(result.record).toEqual({
152
+ id: 'rec-1',
153
+ first_name: 'Jane',
154
+ legacy_cf_ssn: 'visible legacy field',
155
+ })
156
+ expect(result.customFields).toEqual({
157
+ interests: ['reading', 'travel'],
158
+ score: 0,
159
+ subscribed: false,
160
+ notes: null,
161
+ })
162
+ expect(rawRecord[`${prefix}ssn`]).toBe('987-65-4321')
163
+ })
164
+
165
+ it('omits customFields when every custom field is sensitive', async () => {
166
+ const { ctx } = makeCtx({
167
+ id: 'rec-1',
168
+ first_name: 'Jane',
169
+ [`${prefix}primary_email`]: 'private@example.com',
170
+ [`${prefix}ssn`]: '123-45-6789',
171
+ })
172
+
173
+ const result = await getTool.handler(
174
+ { entityType: 'customers:customer_person_profile', recordId: 'rec-1' },
175
+ ctx,
176
+ ) as { record: Record<string, unknown>; customFields?: Record<string, unknown> }
177
+
178
+ expect(result.record).toEqual({ id: 'rec-1', first_name: 'Jane' })
179
+ expect(result.customFields).toBeUndefined()
180
+ })
181
+ })
182
+
127
183
  it('honors wildcard grants and super admins', async () => {
128
184
  const wildcard = makeCtx({ id: 'rec-1', first_name: 'Jane' }, { userFeatures: ['*'] })
129
185
  const wildcardResult = await getTool.handler(
@@ -170,7 +170,8 @@ function stripSensitiveFields(
170
170
  if (sensitive.size === 0) return record
171
171
  const safe: Record<string, unknown> = {}
172
172
  for (const [key, value] of Object.entries(record)) {
173
- if (sensitive.has(key)) continue
173
+ const fieldName = key.replace(/^cf[:_]/, '')
174
+ if (sensitive.has(key) || sensitive.has(fieldName)) continue
174
175
  safe[key] = value
175
176
  }
176
177
  return safe