@open-mercato/search 0.6.8-develop.6918.1.7b3f32b0c8 → 0.6.8-develop.6925.1.cefa200fa4

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.
@@ -1,2 +1,2 @@
1
- [build:search] found 97 entry points
1
+ [build:search] found 98 entry points
2
2
  [build:search] built successfully
package/AGENTS.md CHANGED
@@ -10,6 +10,7 @@ When working on search functionality, use this guide. It covers indexing, queryi
10
10
  4. **MUST** include `checksumSource` in every `buildSource` return value so the indexer can detect changes and skip redundant re-embedding.
11
11
  5. **MUST** use the `entityId` format `module:entity_name` and ensure it matches the entity registry exactly.
12
12
  6. **MUST** use `fieldPolicy.hashOnly` for PII fields (email, phone, tax_id) that need exact-match filtering but not fuzzy search.
13
+ 7. **MUST** declare `aclFeatures` on every entity, naming the owning module's view feature(s) — the same gate that entity's own list/read route enforces. `search.global` only authorizes *using* search; `aclFeatures` is what authorizes *reading the records*. Global search and the AI tools fail closed, so an entity without it silently vanishes from results for every non-superadmin.
13
14
 
14
15
  ## Ask First
15
16
 
@@ -304,6 +305,9 @@ formatResult: async (ctx: SearchBuildContext): Promise<SearchResultPresenter | n
304
305
  { href: `/backend/module/${ctx.record.id}/edit`, label: 'Edit', kind: 'secondary' },
305
306
  ],
306
307
 
308
+ /** Per-entity view feature(s) required to read this entity through search */
309
+ aclFeatures: ['module.entity.view'],
310
+
307
311
  /** FOR FULLTEXT: Control field indexing */
308
312
  fieldPolicy: {
309
313
  searchable: ['name', 'description'],
@@ -521,6 +525,7 @@ curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
521
525
 
522
526
  | Endpoint | Method | Permission | When to use |
523
527
  |----------|--------|------------|-------------|
528
+ | `/api/search/search/global` | GET | `search.global` | When you need the Cmd+K palette's results (filtered by the caller's per-entity view features) |
524
529
  | `/api/search/settings/global-search` | GET | `search.view` | When you need to read which strategies are enabled for Cmd+K |
525
530
  | `/api/search/settings/global-search` | POST | `search.manage` | When you need to update enabled strategies for a tenant |
526
531
  | `/api/search/reindex` | POST | `search.manage` | When you need to trigger a fulltext reindex (after bulk data changes) |
@@ -712,6 +717,7 @@ buildSource: async (ctx) => {
712
717
 
713
718
  - [ ] Create `search.ts` in the module directory
714
719
  - [ ] Export `searchConfig` with correct `entityId` matching the entity registry
720
+ - [ ] Declare `aclFeatures` with the owning module's view feature(s) — omitting it hides the entity from global search for every non-superadmin
715
721
  - [ ] Define `fieldPolicy` for fulltext (mark sensitive fields as `excluded` or `hashOnly`)
716
722
  - [ ] Define `buildSource` for vector search (include `checksumSource`)
717
723
  - [ ] Define `formatResult` for tokens strategy
@@ -1,5 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
3
+ import {
4
+ filterSearchResultsByEntityAccess,
5
+ resolveReadableEntityTypes
6
+ } from "./lib/entity-access.js";
3
7
  class SearchToolAuthorizationError extends Error {
4
8
  constructor(message) {
5
9
  super(message);
@@ -73,13 +77,20 @@ Searches customers, products, orders, deals, and more in one call.`,
73
77
  throw new Error("Tenant context is required for search");
74
78
  }
75
79
  const searchService = ctx.container.resolve("searchService");
76
- const results = await searchService.search(input.query, {
80
+ const searchIndexer = ctx.container.resolve("searchIndexer");
81
+ const subject = { grantedFeatures: ctx.userFeatures, isSuperAdmin: ctx.isSuperAdmin };
82
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, input.entityTypes);
83
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
84
+ return { query: input.query, totalResults: 0, results: [] };
85
+ }
86
+ const rawResults = await searchService.search(input.query, {
77
87
  tenantId: ctx.tenantId,
78
88
  organizationId: ctx.organizationId,
79
- entityTypes: input.entityTypes,
89
+ entityTypes: readableEntityTypes,
80
90
  strategies: input.strategies,
81
91
  limit: input.limit
82
92
  });
93
+ const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject);
83
94
  return {
84
95
  query: input.query,
85
96
  totalResults: results.length,
@@ -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'\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. Kept local to avoid importing the full\n * `SearchIndexer` class into the tool module.\n */\ntype SearchIndexerLike = {\n getEntityConfig: (entityId: string) => SearchEntityConfig | undefined\n}\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 * 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 const results = await searchService.search(input.query, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n entityTypes: input.entityTypes,\n strategies: input.strategies as SearchStrategyId[],\n limit: input.limit,\n })\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 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: ctx.organizationId,\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 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: ctx.organizationId,\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'\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 * 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 rawResults = await searchService.search(input.query, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\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 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: ctx.organizationId,\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 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: ctx.organizationId,\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
  }
@@ -6,10 +6,14 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
6
6
  import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
7
7
  import { resolveEmbeddingConfig } from "../../../lib/embedding-config.js";
8
8
  import { resolveGlobalSearchStrategies } from "../../../lib/global-search-config.js";
9
- import { searchError } from "../../../../../lib/debug.js";
9
+ import {
10
+ filterSearchResultsByEntityAccess,
11
+ resolveReadableEntityTypes
12
+ } from "../../../lib/entity-access.js";
13
+ import { searchDebug, searchError } from "../../../../../lib/debug.js";
10
14
  import { globalSearchOpenApi } from "../../openapi.js";
11
15
  const metadata = {
12
- GET: { requireAuth: true, requireFeatures: ["search.view"] }
16
+ GET: { requireAuth: true, requireFeatures: ["search.global"] }
13
17
  };
14
18
  function parseLimit(value) {
15
19
  if (!value) return 50;
@@ -50,6 +54,18 @@ async function GET(req) {
50
54
  { status: 503 }
51
55
  );
52
56
  }
57
+ if (!container.hasRegistration("rbacService") || !container.hasRegistration("searchIndexer")) {
58
+ searchError("search.api.global", "entity-acl-unavailable", {
59
+ rbacService: container.hasRegistration("rbacService"),
60
+ searchIndexer: container.hasRegistration("searchIndexer")
61
+ });
62
+ return NextResponse.json(
63
+ { error: t("search.api.errors.serviceUnavailable", "Search service unavailable") },
64
+ { status: 503 }
65
+ );
66
+ }
67
+ const rbac = container.resolve("rbacService");
68
+ const searchIndexer = container.resolve("searchIndexer");
53
69
  const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } });
54
70
  if (strategies.includes("vector")) {
55
71
  try {
@@ -75,15 +91,41 @@ async function GET(req) {
75
91
  }
76
92
  const scopeFilter = resolveOrganizationScopeFilter(scope, auth);
77
93
  const organizationId = typeof scope.selectedId === "string" && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : void 0;
94
+ const acl = await rbac.loadAcl(auth.sub, {
95
+ tenantId: scope.tenantId ?? auth.tenantId ?? null,
96
+ organizationId: organizationId ?? null
97
+ });
98
+ const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin };
99
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes);
100
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
101
+ return NextResponse.json({
102
+ results: [],
103
+ strategiesUsed: [],
104
+ strategiesEnabled: strategies,
105
+ timing: Date.now() - startTime,
106
+ query,
107
+ limit
108
+ });
109
+ }
78
110
  const searchOptions = {
79
111
  tenantId: auth.tenantId,
80
112
  organizationId,
81
113
  organizationIds: scopeFilter.organizationIds,
82
114
  limit,
83
115
  strategies,
84
- entityTypes
116
+ entityTypes: readableEntityTypes
85
117
  };
86
- const results = await searchService.search(query, searchOptions);
118
+ const rawResults = await searchService.search(query, searchOptions);
119
+ const results = filterSearchResultsByEntityAccess(
120
+ rawResults,
121
+ searchIndexer,
122
+ { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin },
123
+ {
124
+ onDeny: (deniedEntityId, reason) => {
125
+ searchDebug("search.api.global", "entity-filtered", { entityId: deniedEntityId, reason });
126
+ }
127
+ }
128
+ );
87
129
  const timing = Date.now() - startTime;
88
130
  const strategiesUsed = [...new Set(results.map((r) => r.source))];
89
131
  return NextResponse.json({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/search/api/search/global/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { EmbeddingService } from '../../../../../vector'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'\nimport { searchError } from '../../../../../lib/debug'\nimport { globalSearchOpenApi } from '../../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\n/**\n * Global search endpoint for Cmd+K.\n * Always uses saved global search settings - does NOT accept strategies from URL.\n */\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Fetch saved global search strategies (per-tenant; falls back to the instance default)\n const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } })\n\n // Load embedding config for vector strategy (only if vector is enabled)\n if (strategies.includes('vector')) {\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n strategiesEnabled: strategies,\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n strategiesEnabled: strategies,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n searchError('search.api.global', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = globalSearchOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { EmbeddingService } from '../../../../../vector'\nimport { resolveEmbeddingConfig } from '../../../lib/embedding-config'\nimport { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'\nimport {\n filterSearchResultsByEntityAccess,\n resolveReadableEntityTypes,\n type SearchEntityConfigLookup,\n} from '../../../lib/entity-access'\nimport { searchDebug, searchError } from '../../../../../lib/debug'\nimport { globalSearchOpenApi } from '../../openapi'\n\n/**\n * `search.global` \u2014 the same feature the topbar gates the Cmd+K palette on\n * (`BackendHeaderChrome`). It used to be `search.view`, which is the\n * search-administration feature guarding the settings endpoints; the two gates\n * could disagree in either direction, so a role holding only one of them either\n * saw a search box that 403'd on every keystroke or could query the endpoint with\n * no UI. `search.view` stays on `api/settings/**`, where it belongs.\n */\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.global'] },\n}\n\ntype RbacLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\n/**\n * Global search endpoint for Cmd+K.\n * Always uses saved global search settings - does NOT accept strategies from URL.\n */\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Fail closed: without the RBAC service or the entity registry there is no way\n // to tell which entity types this caller may read, so refuse rather than search.\n if (!container.hasRegistration('rbacService') || !container.hasRegistration('searchIndexer')) {\n searchError('search.api.global', 'entity-acl-unavailable', {\n rbacService: container.hasRegistration('rbacService'),\n searchIndexer: container.hasRegistration('searchIndexer'),\n })\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n const rbac = container.resolve('rbacService') as RbacLike\n const searchIndexer = container.resolve('searchIndexer') as SearchEntityConfigLookup\n\n // Fetch saved global search strategies (per-tenant; falls back to the instance default)\n const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } })\n\n // Load embedding config for vector strategy (only if vector is enabled)\n if (strategies.includes('vector')) {\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n strategiesEnabled: strategies,\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n\n // `search.global` authorizes using the palette, not reading every indexed\n // record. Narrow the query to the entity types this caller may read so the\n // result budget is not spent on records that would only be filtered out.\n const acl = await rbac.loadAcl(auth.sub, {\n tenantId: scope.tenantId ?? auth.tenantId ?? null,\n organizationId: organizationId ?? null,\n })\n const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin }\n const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes)\n if (readableEntityTypes && readableEntityTypes.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n strategiesEnabled: strategies,\n timing: Date.now() - startTime,\n query,\n limit,\n })\n }\n\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes: readableEntityTypes,\n }\n\n const rawResults = await searchService.search(query, searchOptions)\n\n // Defense in depth: a strategy that ignores `entityTypes` must still not leak\n // a presenter title, subtitle or deep link past the per-entity gate.\n const results = filterSearchResultsByEntityAccess(\n rawResults,\n searchIndexer,\n { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin },\n {\n onDeny: (deniedEntityId, reason) => {\n searchDebug('search.api.global', 'entity-filtered', { entityId: deniedEntityId, reason })\n },\n },\n )\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n strategiesEnabled: strategies,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n searchError('search.api.global', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = globalSearchOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,44 @@
1
+ import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
2
+ function canReadSearchEntity(entityId, lookup, subject, options = {}) {
3
+ if (subject.isSuperAdmin) return true;
4
+ const config = lookup.getEntityConfig(entityId);
5
+ if (!config) {
6
+ options.onDeny?.(entityId, "unconfigured");
7
+ return false;
8
+ }
9
+ const required = config.aclFeatures;
10
+ if (!required || required.length === 0) {
11
+ options.onDeny?.(entityId, "no-acl-features");
12
+ return false;
13
+ }
14
+ const allowed = authorizeFeatures(required, {
15
+ grantedFeatures: subject.grantedFeatures,
16
+ unrestricted: false
17
+ });
18
+ if (!allowed) options.onDeny?.(entityId, "insufficient-features");
19
+ return allowed;
20
+ }
21
+ function resolveReadableEntityTypes(lookup, subject, requestedEntityTypes) {
22
+ if (subject.isSuperAdmin) return requestedEntityTypes;
23
+ const readable = lookup.getAllEntityConfigs().filter((config) => config.enabled !== false).map((config) => config.entityId).filter((entityId) => canReadSearchEntity(entityId, lookup, subject));
24
+ if (!requestedEntityTypes) return readable;
25
+ const requested = new Set(requestedEntityTypes);
26
+ return readable.filter((entityId) => requested.has(entityId));
27
+ }
28
+ function filterSearchResultsByEntityAccess(results, lookup, subject, options = {}) {
29
+ if (subject.isSuperAdmin) return [...results];
30
+ const decisions = /* @__PURE__ */ new Map();
31
+ return results.filter((result) => {
32
+ const cached = decisions.get(result.entityId);
33
+ if (cached !== void 0) return cached;
34
+ const allowed = canReadSearchEntity(result.entityId, lookup, subject, options);
35
+ decisions.set(result.entityId, allowed);
36
+ return allowed;
37
+ });
38
+ }
39
+ export {
40
+ canReadSearchEntity,
41
+ filterSearchResultsByEntityAccess,
42
+ resolveReadableEntityTypes
43
+ };
44
+ //# sourceMappingURL=entity-access.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/search/lib/entity-access.ts"],
4
+ "sourcesContent": ["import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\n/**\n * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL\n * resolution. Kept structural so callers and tests can pass a plain object\n * instead of constructing a full `SearchIndexer`.\n */\nexport type SearchEntityConfigLookup = {\n getEntityConfig: (entityId: string) => SearchEntityConfig | undefined\n getAllEntityConfigs: () => SearchEntityConfig[]\n}\n\nexport type SearchEntityAccessSubject = {\n grantedFeatures: readonly string[]\n isSuperAdmin?: boolean\n}\n\nexport type SearchEntityDenyReason =\n /** No module declares this entity in a `search.ts` config. */\n | 'unconfigured'\n /** The entity is configured for search but declares no `aclFeatures`. */\n | 'no-acl-features'\n /** The caller does not hold the entity's declared view feature(s). */\n | 'insufficient-features'\n\nexport type SearchEntityAccessOptions = {\n /**\n * Called once per denied entity type. Exists so a silent drop is diagnosable:\n * results disappearing because a module forgot to declare `aclFeatures` looks\n * identical, from the palette, to results that simply did not match.\n */\n onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void\n}\n\n/**\n * Decide whether a caller may see results for one entity type.\n *\n * The single `search.global` gate on the palette only says \"this user may use\n * global search\"; it says nothing about which records they may read. Each entity\n * declares the owning module's view feature(s) in `aclFeatures`, and those are\n * what actually authorize the read \u2014 the same rule the `search_get` /\n * `search_aggregate` AI tools already apply.\n *\n * Fails closed: an entity that is not registered for search, or that declares no\n * `aclFeatures`, is never exposed to a non-superadmin caller.\n */\nexport function canReadSearchEntity(\n entityId: string,\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): boolean {\n if (subject.isSuperAdmin) return true\n\n const config = lookup.getEntityConfig(entityId)\n if (!config) {\n options.onDeny?.(entityId, 'unconfigured')\n return false\n }\n\n const required = config.aclFeatures\n if (!required || required.length === 0) {\n options.onDeny?.(entityId, 'no-acl-features')\n return false\n }\n\n const allowed = authorizeFeatures(required, {\n grantedFeatures: subject.grantedFeatures,\n unrestricted: false,\n })\n if (!allowed) options.onDeny?.(entityId, 'insufficient-features')\n return allowed\n}\n\n/**\n * The entity types this caller may read, narrowed to `requestedEntityTypes` when\n * the caller asked for specific ones.\n *\n * Restricting the query up front is what keeps `limit` meaningful. Filtering only\n * after the search would spend the whole result budget on records the caller\n * cannot see: an employee granted just `customers.people.view` would get the top\n * 50 hits across every entity type, then watch most of them be dropped, and the\n * palette would look empty even with hundreds of matching people behind it.\n *\n * Returns `undefined` when no restriction applies (superadmin with no explicit\n * request), and an empty array when nothing is readable \u2014 callers should\n * short-circuit on that rather than pass it down as \"no filter\".\n */\nexport function resolveReadableEntityTypes(\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n requestedEntityTypes?: string[],\n): string[] | undefined {\n if (subject.isSuperAdmin) return requestedEntityTypes\n\n const readable = lookup\n .getAllEntityConfigs()\n .filter((config) => config.enabled !== false)\n .map((config) => config.entityId)\n .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))\n\n if (!requestedEntityTypes) return readable\n const requested = new Set(requestedEntityTypes)\n return readable.filter((entityId) => requested.has(entityId))\n}\n\n/**\n * Drop the results whose entity type the caller is not allowed to read.\n *\n * Filtering happens server-side so an under-privileged caller never receives the\n * presenter title, subtitle or deep link of a record they cannot open. Decisions\n * are memoized per entity type because a single response commonly mixes dozens of\n * results across a handful of types.\n */\nexport function filterSearchResultsByEntityAccess<T extends { entityId: string }>(\n results: readonly T[],\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): T[] {\n if (subject.isSuperAdmin) return [...results]\n\n const decisions = new Map<string, boolean>()\n return results.filter((result) => {\n const cached = decisions.get(result.entityId)\n if (cached !== undefined) return cached\n const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)\n decisions.set(result.entityId, allowed)\n return allowed\n })\n}\n"],
5
+ "mappings": "AACA,SAAS,yBAAyB;AA8C3B,SAAS,oBACd,UACA,QACA,SACA,UAAqC,CAAC,GAC7B;AACT,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,SAAS,OAAO,gBAAgB,QAAQ;AAC9C,MAAI,CAAC,QAAQ;AACX,YAAQ,SAAS,UAAU,cAAc;AACzC,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,OAAO;AACxB,MAAI,CAAC,YAAY,SAAS,WAAW,GAAG;AACtC,YAAQ,SAAS,UAAU,iBAAiB;AAC5C,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,kBAAkB,UAAU;AAAA,IAC1C,iBAAiB,QAAQ;AAAA,IACzB,cAAc;AAAA,EAChB,CAAC;AACD,MAAI,CAAC,QAAS,SAAQ,SAAS,UAAU,uBAAuB;AAChE,SAAO;AACT;AAgBO,SAAS,2BACd,QACA,SACA,sBACsB;AACtB,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,WAAW,OACd,oBAAoB,EACpB,OAAO,CAAC,WAAW,OAAO,YAAY,KAAK,EAC3C,IAAI,CAAC,WAAW,OAAO,QAAQ,EAC/B,OAAO,CAAC,aAAa,oBAAoB,UAAU,QAAQ,OAAO,CAAC;AAEtE,MAAI,CAAC,qBAAsB,QAAO;AAClC,QAAM,YAAY,IAAI,IAAI,oBAAoB;AAC9C,SAAO,SAAS,OAAO,CAAC,aAAa,UAAU,IAAI,QAAQ,CAAC;AAC9D;AAUO,SAAS,kCACd,SACA,QACA,SACA,UAAqC,CAAC,GACjC;AACL,MAAI,QAAQ,aAAc,QAAO,CAAC,GAAG,OAAO;AAE5C,QAAM,YAAY,oBAAI,IAAqB;AAC3C,SAAO,QAAQ,OAAO,CAAC,WAAW;AAChC,UAAM,SAAS,UAAU,IAAI,OAAO,QAAQ;AAC5C,QAAI,WAAW,OAAW,QAAO;AACjC,UAAM,UAAU,oBAAoB,OAAO,UAAU,QAAQ,SAAS,OAAO;AAC7E,cAAU,IAAI,OAAO,UAAU,OAAO;AACtC,WAAO;AAAA,EACT,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -1,7 +1,12 @@
1
1
  const setup = {
2
2
  defaultRoleFeatures: {
3
3
  admin: ["search.*", "vector.*"],
4
- employee: ["vector.*"]
4
+ // `search.global` only unlocks the Cmd+K palette — the mirror image of the
5
+ // `ai_assistant.view` grant that gives employees Cmd+L. It does not widen what
6
+ // they can read: the global-search endpoint drops every result whose entity
7
+ // type the caller has no owning-module view feature for. The administration
8
+ // features (`search.view`, `search.manage`, `search.reindex`) stay admin-only.
9
+ employee: ["search.global", "vector.*"]
5
10
  }
6
11
  };
7
12
  var setup_default = setup;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/search/setup.ts"],
4
- "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: ['search.*', 'vector.*'],\n employee: ['vector.*'],\n },\n}\n\nexport default setup\n"],
5
- "mappings": "AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,OAAO,CAAC,YAAY,UAAU;AAAA,IAC9B,UAAU,CAAC,UAAU;AAAA,EACvB;AACF;AAEA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: ['search.*', 'vector.*'],\n // `search.global` only unlocks the Cmd+K palette \u2014 the mirror image of the\n // `ai_assistant.view` grant that gives employees Cmd+L. It does not widen what\n // they can read: the global-search endpoint drops every result whose entity\n // type the caller has no owning-module view feature for. The administration\n // features (`search.view`, `search.manage`, `search.reindex`) stay admin-only.\n employee: ['search.global', 'vector.*'],\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,OAAO,CAAC,YAAY,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAM9B,UAAU,CAAC,iBAAiB,UAAU;AAAA,EACxC;AACF;AAEA,IAAO,gBAAQ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.8-develop.6918.1.7b3f32b0c8",
3
+ "version": "0.6.8-develop.6925.1.cefa200fa4",
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.6.8-develop.6918.1.7b3f32b0c8",
131
- "@open-mercato/queue": "0.6.8-develop.6918.1.7b3f32b0c8",
132
- "@open-mercato/shared": "0.6.8-develop.6918.1.7b3f32b0c8"
130
+ "@open-mercato/core": "0.6.8-develop.6925.1.cefa200fa4",
131
+ "@open-mercato/queue": "0.6.8-develop.6925.1.cefa200fa4",
132
+ "@open-mercato/shared": "0.6.8-develop.6925.1.cefa200fa4"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -23,6 +23,8 @@ function makeCtx(
23
23
  const mockQuery = jest.fn().mockResolvedValue(queryResult)
24
24
  const searchIndexer = {
25
25
  getEntityConfig: (entityId: string) => ENTITY_CONFIGS[entityId],
26
+ getAllEntityConfigs: () =>
27
+ Object.entries(ENTITY_CONFIGS).map(([entityId, config]) => ({ entityId, ...config })),
26
28
  }
27
29
  const ctx = {
28
30
  tenantId: 'tenant-1',
@@ -21,6 +21,7 @@ function makeCtx(
21
21
  const searchIndexer = {
22
22
  getEntityConfig: (entityId: string) =>
23
23
  entityId === 'customers:customer_person_profile' ? PERSON_CONFIG : undefined,
24
+ getAllEntityConfigs: () => [{ entityId: 'customers:customer_person_profile', ...PERSON_CONFIG }],
24
25
  }
25
26
  const ctx = {
26
27
  tenantId: 'tenant-1',