@open-mercato/ai-assistant 0.6.8-develop.7049.1.b41427429a → 0.6.8-develop.7051.1.2e20648fd5

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,5 +1,49 @@
1
1
  import { z } from "zod";
2
+ import {
3
+ canReadSearchEntity,
4
+ filterSearchResultsByEntityAccess,
5
+ resolveReadableEntityTypes
6
+ } from "@open-mercato/shared/lib/search/entityAccess";
2
7
  import { defineAiTool } from "../lib/ai-tool-definition.js";
8
+ class SearchToolAuthorizationError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "SearchToolAuthorizationError";
12
+ }
13
+ }
14
+ function resolveSearchIndexer(ctx) {
15
+ try {
16
+ const indexer = ctx.container.resolve("searchIndexer");
17
+ if (indexer && typeof indexer.getEntityConfig === "function" && typeof indexer.getAllEntityConfigs === "function") {
18
+ return indexer;
19
+ }
20
+ } catch {
21
+ }
22
+ throw new SearchToolAuthorizationError("[internal] Search entity registry unavailable");
23
+ }
24
+ function authorizeEntityAccess(entityType, lookup, subject) {
25
+ if (subject.isSuperAdmin) return;
26
+ let denial;
27
+ const allowed = canReadSearchEntity(entityType, lookup, subject, {
28
+ onDeny: (_, reason) => {
29
+ denial = reason;
30
+ }
31
+ });
32
+ if (allowed) return;
33
+ if (denial === "unconfigured") {
34
+ throw new SearchToolAuthorizationError(`[internal] Entity type "${entityType}" is not configured for search`);
35
+ }
36
+ if (denial === "no-acl-features") {
37
+ throw new SearchToolAuthorizationError(
38
+ `[internal] Entity type "${entityType}" does not declare aclFeatures; access denied`
39
+ );
40
+ }
41
+ const config = lookup.getEntityConfig(entityType);
42
+ const required = config?.aclFeatures ?? [];
43
+ throw new SearchToolAuthorizationError(
44
+ `[internal] Insufficient permissions for entity "${entityType}". Required: ${required.join(", ")}`
45
+ );
46
+ }
3
47
  const hybridSearchInput = z.object({
4
48
  q: z.string().min(1).describe("Search query text."),
5
49
  limit: z.number().int().min(1).max(100).optional().describe("Maximum results (default 20, max 100)."),
@@ -21,13 +65,49 @@ const hybridSearchTool = defineAiTool({
21
65
  const service = ctx.container.resolve("searchService");
22
66
  const limit = input.limit ?? 20;
23
67
  const started = Date.now();
24
- const results = await service.search(input.q, {
68
+ const subject = {
69
+ grantedFeatures: ctx.userFeatures,
70
+ isSuperAdmin: ctx.isSuperAdmin
71
+ };
72
+ if (ctx.isSuperAdmin) {
73
+ const results2 = await service.search(input.q, {
74
+ tenantId: ctx.tenantId,
75
+ organizationId: ctx.organizationId,
76
+ limit,
77
+ strategies: input.strategies,
78
+ entityTypes: input.entityTypes
79
+ });
80
+ const timingMs2 = Date.now() - started;
81
+ const strategiesUsed2 = Array.from(
82
+ new Set(results2.map((result) => result.source).filter((id) => typeof id === "string"))
83
+ );
84
+ return {
85
+ query: input.q,
86
+ totalResults: results2.length,
87
+ results: results2,
88
+ strategiesUsed: strategiesUsed2,
89
+ timing: { ms: timingMs2 }
90
+ };
91
+ }
92
+ const lookup = resolveSearchIndexer(ctx);
93
+ const readableEntityTypes = resolveReadableEntityTypes(lookup, subject, input.entityTypes);
94
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
95
+ return {
96
+ query: input.q,
97
+ totalResults: 0,
98
+ results: [],
99
+ strategiesUsed: [],
100
+ timing: { ms: Date.now() - started }
101
+ };
102
+ }
103
+ const rawResults = await service.search(input.q, {
25
104
  tenantId: ctx.tenantId,
26
105
  organizationId: ctx.organizationId,
27
106
  limit,
28
107
  strategies: input.strategies,
29
- entityTypes: input.entityTypes
108
+ entityTypes: readableEntityTypes
30
109
  });
110
+ const results = filterSearchResultsByEntityAccess(rawResults, lookup, subject);
31
111
  const timingMs = Date.now() - started;
32
112
  const strategiesUsed = Array.from(
33
113
  new Set(results.map((result) => result.source).filter((id) => typeof id === "string"))
@@ -57,13 +137,23 @@ const getRecordContextTool = defineAiTool({
57
137
  throw new Error("Tenant context is required for search.get_record_context");
58
138
  }
59
139
  const input = getRecordContextInput.parse(rawInput);
140
+ const subject = {
141
+ grantedFeatures: ctx.userFeatures,
142
+ isSuperAdmin: ctx.isSuperAdmin
143
+ };
144
+ let lookup;
145
+ if (!ctx.isSuperAdmin) {
146
+ lookup = resolveSearchIndexer(ctx);
147
+ authorizeEntityAccess(input.entityId, lookup, subject);
148
+ }
60
149
  const service = ctx.container.resolve("searchService");
61
- const results = await service.search(input.recordId, {
150
+ const rawResults = await service.search(input.recordId, {
62
151
  tenantId: ctx.tenantId,
63
152
  organizationId: ctx.organizationId,
64
153
  limit: 5,
65
154
  entityTypes: [input.entityId]
66
155
  });
156
+ const results = lookup ? filterSearchResultsByEntityAccess(rawResults, lookup, subject) : rawResults;
67
157
  const match = results.find((result) => result.recordId === input.recordId);
68
158
  if (!match) {
69
159
  return {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/ai-tools/search-pack.ts"],
4
- "sourcesContent": ["/**\n * General-purpose `search.*` tool pack (Phase 1 WS-C, Step 3.8).\n *\n * These tools are discovered by the generator alongside any other module\n * `ai-tools.ts`; they expose the existing `@open-mercato/search` runtime to\n * agents that whitelist them via `allowedTools`.\n */\nimport { z } from 'zod'\nimport type { SearchOptions, SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { defineAiTool } from '../lib/ai-tool-definition'\nimport type { AiToolDefinition } from '../lib/types'\n\ntype SearchServiceLike = {\n search: (query: string, options: SearchOptions) => Promise<SearchResult[]>\n}\n\nconst hybridSearchInput = z.object({\n q: z.string().min(1).describe('Search query text.'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .describe('Maximum results (default 20, max 100).'),\n strategies: z\n .array(z.enum(['fulltext', 'vector', 'tokens']))\n .optional()\n .describe('Subset of strategies to run; defaults to the module defaults.'),\n entityTypes: z\n .array(z.string())\n .optional()\n .describe('Filter to specific entity ids (e.g. \"catalog:product\").'),\n})\n\nconst hybridSearchTool = defineAiTool({\n name: 'search.hybrid_search',\n displayName: 'Hybrid search',\n description:\n 'Run a global fulltext + vector + token search across enabled entities for the current tenant/organization.',\n inputSchema: hybridSearchInput,\n requiredFeatures: ['search.view'],\n tags: ['read', 'search'],\n handler: async (rawInput, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search.hybrid_search')\n }\n const input = hybridSearchInput.parse(rawInput)\n const service = ctx.container.resolve<SearchServiceLike>('searchService')\n const limit = input.limit ?? 20\n const started = Date.now()\n const results = await service.search(input.q, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n limit,\n strategies: input.strategies as SearchStrategyId[] | undefined,\n entityTypes: input.entityTypes,\n })\n const timingMs = Date.now() - started\n const strategiesUsed = Array.from(\n new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),\n )\n return {\n query: input.q,\n totalResults: results.length,\n results,\n strategiesUsed,\n timing: { ms: timingMs },\n }\n },\n})\n\nconst getRecordContextInput = z.object({\n entityId: z.string().min(1).describe('Entity identifier (e.g. \"customers:customer_person_profile\").'),\n recordId: z.string().min(1).describe('Record primary key (UUID).'),\n})\n\nconst getRecordContextTool = defineAiTool({\n name: 'search.get_record_context',\n displayName: 'Get record context',\n description:\n 'Resolve presenter, links, and URL for a specific record by re-querying the search index. Returns { found: false } when no hit matches the recordId.',\n inputSchema: getRecordContextInput,\n requiredFeatures: ['search.view'],\n tags: ['read', 'search'],\n handler: async (rawInput, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search.get_record_context')\n }\n const input = getRecordContextInput.parse(rawInput)\n const service = ctx.container.resolve<SearchServiceLike>('searchService')\n const results = await service.search(input.recordId, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n limit: 5,\n entityTypes: [input.entityId],\n })\n const match = results.find((result) => result.recordId === input.recordId)\n if (!match) {\n return {\n found: false as const,\n entityId: input.entityId,\n recordId: input.recordId,\n }\n }\n return {\n found: true as const,\n entityId: match.entityId,\n recordId: match.recordId,\n presenter: match.presenter,\n url: match.url,\n links: match.links,\n metadata: match.metadata,\n source: match.source,\n score: match.score,\n }\n },\n})\n\nexport const searchAiTools: AiToolDefinition<any, any>[] = [hybridSearchTool, getRecordContextTool]\n\nexport default searchAiTools\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["/**\n * General-purpose `search.*` tool pack (Phase 1 WS-C, Step 3.8).\n *\n * These tools are discovered by the generator alongside any other module\n * `ai-tools.ts`; they expose the existing `@open-mercato/search` runtime to\n * agents that whitelist them via `allowedTools`.\n */\nimport { z } from 'zod'\nimport type { SearchOptions, SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport {\n canReadSearchEntity,\n filterSearchResultsByEntityAccess,\n resolveReadableEntityTypes,\n type SearchEntityAccessSubject,\n type SearchEntityConfigLookup,\n type SearchEntityDenyReason,\n} from '@open-mercato/shared/lib/search/entityAccess'\nimport { defineAiTool } from '../lib/ai-tool-definition'\nimport type { AiToolDefinition, McpToolContext } from '../lib/types'\n\ntype SearchServiceLike = {\n search: (query: string, options: SearchOptions) => Promise<SearchResult[]>\n}\n\nclass SearchToolAuthorizationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'SearchToolAuthorizationError'\n }\n}\n\nfunction resolveSearchIndexer(ctx: McpToolContext): SearchEntityConfigLookup {\n try {\n const indexer = ctx.container.resolve('searchIndexer') as SearchEntityConfigLookup | undefined\n if (indexer && typeof indexer.getEntityConfig === 'function' && typeof indexer.getAllEntityConfigs === 'function') {\n return indexer\n }\n } catch {\n // fall through to throw\n }\n throw new SearchToolAuthorizationError('[internal] Search entity registry unavailable')\n}\n\nfunction authorizeEntityAccess(\n entityType: string,\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n): void {\n if (subject.isSuperAdmin) return\n let denial: SearchEntityDenyReason | undefined\n const allowed = canReadSearchEntity(entityType, lookup, subject, {\n onDeny: (_, reason) => {\n denial = reason\n },\n })\n if (allowed) return\n if (denial === 'unconfigured') {\n throw new SearchToolAuthorizationError(`[internal] Entity type \"${entityType}\" is not configured for search`)\n }\n if (denial === 'no-acl-features') {\n throw new SearchToolAuthorizationError(\n `[internal] Entity type \"${entityType}\" does not declare aclFeatures; access denied`,\n )\n }\n const config = lookup.getEntityConfig(entityType)\n const required = config?.aclFeatures ?? []\n throw new SearchToolAuthorizationError(\n `[internal] Insufficient permissions for entity \"${entityType}\". Required: ${required.join(', ')}`,\n )\n}\n\nconst hybridSearchInput = z.object({\n q: z.string().min(1).describe('Search query text.'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .describe('Maximum results (default 20, max 100).'),\n strategies: z\n .array(z.enum(['fulltext', 'vector', 'tokens']))\n .optional()\n .describe('Subset of strategies to run; defaults to the module defaults.'),\n entityTypes: z\n .array(z.string())\n .optional()\n .describe('Filter to specific entity ids (e.g. \"catalog:product\").'),\n})\n\nconst hybridSearchTool = defineAiTool({\n name: 'search.hybrid_search',\n displayName: 'Hybrid search',\n description:\n 'Run a global fulltext + vector + token search across enabled entities for the current tenant/organization.',\n inputSchema: hybridSearchInput,\n requiredFeatures: ['search.view'],\n tags: ['read', 'search'],\n handler: async (rawInput, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search.hybrid_search')\n }\n const input = hybridSearchInput.parse(rawInput)\n const service = ctx.container.resolve<SearchServiceLike>('searchService')\n const limit = input.limit ?? 20\n const started = Date.now()\n const subject: SearchEntityAccessSubject = {\n grantedFeatures: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n\n if (ctx.isSuperAdmin) {\n const results = await service.search(input.q, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n limit,\n strategies: input.strategies as SearchStrategyId[] | undefined,\n entityTypes: input.entityTypes,\n })\n const timingMs = Date.now() - started\n const strategiesUsed = Array.from(\n new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),\n )\n return {\n query: input.q,\n totalResults: results.length,\n results,\n strategiesUsed,\n timing: { ms: timingMs },\n }\n }\n\n const lookup = resolveSearchIndexer(ctx)\n const readableEntityTypes = resolveReadableEntityTypes(lookup, subject, input.entityTypes)\n if (readableEntityTypes && readableEntityTypes.length === 0) {\n return {\n query: input.q,\n totalResults: 0,\n results: [],\n strategiesUsed: [],\n timing: { ms: Date.now() - started },\n }\n }\n\n const rawResults = await service.search(input.q, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n limit,\n strategies: input.strategies as SearchStrategyId[] | undefined,\n entityTypes: readableEntityTypes,\n })\n const results = filterSearchResultsByEntityAccess(rawResults, lookup, subject)\n const timingMs = Date.now() - started\n const strategiesUsed = Array.from(\n new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),\n )\n return {\n query: input.q,\n totalResults: results.length,\n results,\n strategiesUsed,\n timing: { ms: timingMs },\n }\n },\n})\n\nconst getRecordContextInput = z.object({\n entityId: z.string().min(1).describe('Entity identifier (e.g. \"customers:customer_person_profile\").'),\n recordId: z.string().min(1).describe('Record primary key (UUID).'),\n})\n\nconst getRecordContextTool = defineAiTool({\n name: 'search.get_record_context',\n displayName: 'Get record context',\n description:\n 'Resolve presenter, links, and URL for a specific record by re-querying the search index. Returns { found: false } when no hit matches the recordId.',\n inputSchema: getRecordContextInput,\n requiredFeatures: ['search.view'],\n tags: ['read', 'search'],\n handler: async (rawInput, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search.get_record_context')\n }\n const input = getRecordContextInput.parse(rawInput)\n const subject: SearchEntityAccessSubject = {\n grantedFeatures: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n\n let lookup: SearchEntityConfigLookup | undefined\n if (!ctx.isSuperAdmin) {\n lookup = resolveSearchIndexer(ctx)\n authorizeEntityAccess(input.entityId, lookup, subject)\n }\n\n const service = ctx.container.resolve<SearchServiceLike>('searchService')\n const rawResults = await service.search(input.recordId, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n limit: 5,\n entityTypes: [input.entityId],\n })\n const results = lookup ? filterSearchResultsByEntityAccess(rawResults, lookup, subject) : rawResults\n const match = results.find((result) => result.recordId === input.recordId)\n if (!match) {\n return {\n found: false as const,\n entityId: input.entityId,\n recordId: input.recordId,\n }\n }\n return {\n found: true as const,\n entityId: match.entityId,\n recordId: match.recordId,\n presenter: match.presenter,\n url: match.url,\n links: match.links,\n metadata: match.metadata,\n source: match.source,\n score: match.score,\n }\n },\n})\n\nexport const searchAiTools: AiToolDefinition<any, any>[] = [hybridSearchTool, getRecordContextTool]\n\nexport default searchAiTools\n"],
5
+ "mappings": "AAOA,SAAS,SAAS;AAElB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,oBAAoB;AAO7B,MAAM,qCAAqC,MAAM;AAAA,EAC/C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEA,SAAS,qBAAqB,KAA+C;AAC3E,MAAI;AACF,UAAM,UAAU,IAAI,UAAU,QAAQ,eAAe;AACrD,QAAI,WAAW,OAAO,QAAQ,oBAAoB,cAAc,OAAO,QAAQ,wBAAwB,YAAY;AACjH,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,QAAM,IAAI,6BAA6B,+CAA+C;AACxF;AAEA,SAAS,sBACP,YACA,QACA,SACM;AACN,MAAI,QAAQ,aAAc;AAC1B,MAAI;AACJ,QAAM,UAAU,oBAAoB,YAAY,QAAQ,SAAS;AAAA,IAC/D,QAAQ,CAAC,GAAG,WAAW;AACrB,eAAS;AAAA,IACX;AAAA,EACF,CAAC;AACD,MAAI,QAAS;AACb,MAAI,WAAW,gBAAgB;AAC7B,UAAM,IAAI,6BAA6B,2BAA2B,UAAU,gCAAgC;AAAA,EAC9G;AACA,MAAI,WAAW,mBAAmB;AAChC,UAAM,IAAI;AAAA,MACR,2BAA2B,UAAU;AAAA,IACvC;AAAA,EACF;AACA,QAAM,SAAS,OAAO,gBAAgB,UAAU;AAChD,QAAM,WAAW,QAAQ,eAAe,CAAC;AACzC,QAAM,IAAI;AAAA,IACR,mDAAmD,UAAU,gBAAgB,SAAS,KAAK,IAAI,CAAC;AAAA,EAClG;AACF;AAEA,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS,oBAAoB;AAAA,EAClD,OAAO,EACJ,OAAO,EACP,IAAI,EACJ,IAAI,CAAC,EACL,IAAI,GAAG,EACP,SAAS,EACT,SAAS,wCAAwC;AAAA,EACpD,YAAY,EACT,MAAM,EAAE,KAAK,CAAC,YAAY,UAAU,QAAQ,CAAC,CAAC,EAC9C,SAAS,EACT,SAAS,+DAA+D;AAAA,EAC3E,aAAa,EACV,MAAM,EAAE,OAAO,CAAC,EAChB,SAAS,EACT,SAAS,yDAAyD;AACvE,CAAC;AAED,MAAM,mBAAmB,aAAa;AAAA,EACpC,MAAM;AAAA,EACN,aAAa;AAAA,EACb,aACE;AAAA,EACF,aAAa;AAAA,EACb,kBAAkB,CAAC,aAAa;AAAA,EAChC,MAAM,CAAC,QAAQ,QAAQ;AAAA,EACvB,SAAS,OAAO,UAAU,QAAQ;AAChC,QAAI,CAAC,IAAI,UAAU;AACjB,YAAM,IAAI,MAAM,qDAAqD;AAAA,IACvE;AACA,UAAM,QAAQ,kBAAkB,MAAM,QAAQ;AAC9C,UAAM,UAAU,IAAI,UAAU,QAA2B,eAAe;AACxE,UAAM,QAAQ,MAAM,SAAS;AAC7B,UAAM,UAAU,KAAK,IAAI;AACzB,UAAM,UAAqC;AAAA,MACzC,iBAAiB,IAAI;AAAA,MACrB,cAAc,IAAI;AAAA,IACpB;AAEA,QAAI,IAAI,cAAc;AACpB,YAAMA,WAAU,MAAM,QAAQ,OAAO,MAAM,GAAG;AAAA,QAC5C,UAAU,IAAI;AAAA,QACd,gBAAgB,IAAI;AAAA,QACpB;AAAA,QACA,YAAY,MAAM;AAAA,QAClB,aAAa,MAAM;AAAA,MACrB,CAAC;AACD,YAAMC,YAAW,KAAK,IAAI,IAAI;AAC9B,YAAMC,kBAAiB,MAAM;AAAA,QAC3B,IAAI,IAAIF,SAAQ,IAAI,CAAC,WAAW,OAAO,MAAM,EAAE,OAAO,CAAC,OAA+B,OAAO,OAAO,QAAQ,CAAC;AAAA,MAC/G;AACA,aAAO;AAAA,QACL,OAAO,MAAM;AAAA,QACb,cAAcA,SAAQ;AAAA,QACtB,SAAAA;AAAA,QACA,gBAAAE;AAAA,QACA,QAAQ,EAAE,IAAID,UAAS;AAAA,MACzB;AAAA,IACF;AAEA,UAAM,SAAS,qBAAqB,GAAG;AACvC,UAAM,sBAAsB,2BAA2B,QAAQ,SAAS,MAAM,WAAW;AACzF,QAAI,uBAAuB,oBAAoB,WAAW,GAAG;AAC3D,aAAO;AAAA,QACL,OAAO,MAAM;AAAA,QACb,cAAc;AAAA,QACd,SAAS,CAAC;AAAA,QACV,gBAAgB,CAAC;AAAA,QACjB,QAAQ,EAAE,IAAI,KAAK,IAAI,IAAI,QAAQ;AAAA,MACrC;AAAA,IACF;AAEA,UAAM,aAAa,MAAM,QAAQ,OAAO,MAAM,GAAG;AAAA,MAC/C,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,MACpB;AAAA,MACA,YAAY,MAAM;AAAA,MAClB,aAAa;AAAA,IACf,CAAC;AACD,UAAM,UAAU,kCAAkC,YAAY,QAAQ,OAAO;AAC7E,UAAM,WAAW,KAAK,IAAI,IAAI;AAC9B,UAAM,iBAAiB,MAAM;AAAA,MAC3B,IAAI,IAAI,QAAQ,IAAI,CAAC,WAAW,OAAO,MAAM,EAAE,OAAO,CAAC,OAA+B,OAAO,OAAO,QAAQ,CAAC;AAAA,IAC/G;AACA,WAAO;AAAA,MACL,OAAO,MAAM;AAAA,MACb,cAAc,QAAQ;AAAA,MACtB;AAAA,MACA;AAAA,MACA,QAAQ,EAAE,IAAI,SAAS;AAAA,IACzB;AAAA,EACF;AACF,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS,+DAA+D;AAAA,EACpG,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS,4BAA4B;AACnE,CAAC;AAED,MAAM,uBAAuB,aAAa;AAAA,EACxC,MAAM;AAAA,EACN,aAAa;AAAA,EACb,aACE;AAAA,EACF,aAAa;AAAA,EACb,kBAAkB,CAAC,aAAa;AAAA,EAChC,MAAM,CAAC,QAAQ,QAAQ;AAAA,EACvB,SAAS,OAAO,UAAU,QAAQ;AAChC,QAAI,CAAC,IAAI,UAAU;AACjB,YAAM,IAAI,MAAM,0DAA0D;AAAA,IAC5E;AACA,UAAM,QAAQ,sBAAsB,MAAM,QAAQ;AAClD,UAAM,UAAqC;AAAA,MACzC,iBAAiB,IAAI;AAAA,MACrB,cAAc,IAAI;AAAA,IACpB;AAEA,QAAI;AACJ,QAAI,CAAC,IAAI,cAAc;AACrB,eAAS,qBAAqB,GAAG;AACjC,4BAAsB,MAAM,UAAU,QAAQ,OAAO;AAAA,IACvD;AAEA,UAAM,UAAU,IAAI,UAAU,QAA2B,eAAe;AACxE,UAAM,aAAa,MAAM,QAAQ,OAAO,MAAM,UAAU;AAAA,MACtD,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI;AAAA,MACpB,OAAO;AAAA,MACP,aAAa,CAAC,MAAM,QAAQ;AAAA,IAC9B,CAAC;AACD,UAAM,UAAU,SAAS,kCAAkC,YAAY,QAAQ,OAAO,IAAI;AAC1F,UAAM,QAAQ,QAAQ,KAAK,CAAC,WAAW,OAAO,aAAa,MAAM,QAAQ;AACzE,QAAI,CAAC,OAAO;AACV,aAAO;AAAA,QACL,OAAO;AAAA,QACP,UAAU,MAAM;AAAA,QAChB,UAAU,MAAM;AAAA,MAClB;AAAA,IACF;AACA,WAAO;AAAA,MACL,OAAO;AAAA,MACP,UAAU,MAAM;AAAA,MAChB,UAAU,MAAM;AAAA,MAChB,WAAW,MAAM;AAAA,MACjB,KAAK,MAAM;AAAA,MACX,OAAO,MAAM;AAAA,MACb,UAAU,MAAM;AAAA,MAChB,QAAQ,MAAM;AAAA,MACd,OAAO,MAAM;AAAA,IACf;AAAA,EACF;AACF,CAAC;AAEM,MAAM,gBAA8C,CAAC,kBAAkB,oBAAoB;AAElG,IAAO,sBAAQ;",
6
+ "names": ["results", "timingMs", "strategiesUsed"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.6.8-develop.7049.1.b41427429a",
3
+ "version": "0.6.8-develop.7051.1.2e20648fd5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -99,16 +99,16 @@
99
99
  "zod-to-json-schema": "^3.25.2"
100
100
  },
101
101
  "peerDependencies": {
102
- "@open-mercato/shared": "0.6.8-develop.7049.1.b41427429a",
103
- "@open-mercato/ui": "0.6.8-develop.7049.1.b41427429a",
102
+ "@open-mercato/shared": "0.6.8-develop.7051.1.2e20648fd5",
103
+ "@open-mercato/ui": "0.6.8-develop.7051.1.2e20648fd5",
104
104
  "react": "^19.0.0",
105
105
  "react-dom": "^19.0.0",
106
106
  "zod": ">=3.23.0"
107
107
  },
108
108
  "devDependencies": {
109
- "@open-mercato/cli": "0.6.8-develop.7049.1.b41427429a",
110
- "@open-mercato/shared": "0.6.8-develop.7049.1.b41427429a",
111
- "@open-mercato/ui": "0.6.8-develop.7049.1.b41427429a",
109
+ "@open-mercato/cli": "0.6.8-develop.7051.1.2e20648fd5",
110
+ "@open-mercato/shared": "0.6.8-develop.7051.1.2e20648fd5",
111
+ "@open-mercato/ui": "0.6.8-develop.7051.1.2e20648fd5",
112
112
  "@types/react": "^19.2.17",
113
113
  "@types/react-dom": "^19.2.3",
114
114
  "react": "19.2.8",
@@ -156,10 +156,15 @@ describe('WS-C integration — tool-pack coverage', () => {
156
156
 
157
157
  it('propagates tenantId + organizationId to the search service call', async () => {
158
158
  const searchMock = jest.fn().mockResolvedValue([])
159
+ const searchIndexerMock = {
160
+ getEntityConfig: (entityId: string) => ({ entityId, aclFeatures: ['ai_assistant.view'], enabled: true } as any),
161
+ getAllEntityConfigs: () => [{ entityId: 'test:entity', aclFeatures: ['ai_assistant.view'], enabled: true } as any],
162
+ }
159
163
  const ctx = makeCtx({
160
164
  container: {
161
165
  resolve: (name: string) => {
162
166
  if (name === 'searchService') return { search: searchMock }
167
+ if (name === 'searchIndexer') return searchIndexerMock
163
168
  throw new Error(`Unknown registration: ${name}`)
164
169
  },
165
170
  },
@@ -2,7 +2,8 @@
2
2
  * Step 3.8 — `search.*` tool pack unit tests.
3
3
  *
4
4
  * Covers `search.hybrid_search` happy path and `search.get_record_context`
5
- * happy / miss / tenant isolation.
5
+ * happy / miss / tenant isolation, plus per-entity ACL enforcement for
6
+ * issue #5211 (legacy pack must not leak records the caller cannot view).
6
7
  */
7
8
  import searchAiTools from '../search-pack'
8
9
 
@@ -57,6 +58,19 @@ function makeSearchService(results: unknown[]): {
57
58
  }
58
59
  }
59
60
 
61
+ function makeLookup(configs: Array<{ entityId: string; aclFeatures?: string[]; enabled?: boolean }>) {
62
+ const map = new Map(configs.map((c) => [c.entityId, c]))
63
+ return {
64
+ getEntityConfig: (entityId: string) => map.get(entityId) as any,
65
+ getAllEntityConfigs: () => configs as any,
66
+ }
67
+ }
68
+
69
+ function permissiveLookup(entityIds: string[] = ['catalog:product']) {
70
+ const configs = entityIds.map((id) => ({ entityId: id, aclFeatures: ['search.view'], enabled: true }))
71
+ return makeLookup(configs)
72
+ }
73
+
60
74
  describe('search.hybrid_search', () => {
61
75
  const tool = findTool('search.hybrid_search')
62
76
 
@@ -71,8 +85,10 @@ describe('search.hybrid_search', () => {
71
85
  },
72
86
  ])
73
87
  const ctx = makeCtx()
88
+ const lookup = permissiveLookup(['catalog:product'])
74
89
  ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
75
90
  if (name === 'searchService') return service
91
+ if (name === 'searchIndexer') return lookup
76
92
  throw new Error(`unexpected resolve ${name}`)
77
93
  })
78
94
  const result = (await tool.handler(
@@ -95,7 +111,12 @@ describe('search.hybrid_search', () => {
95
111
  it('defaults limit to 20 when omitted', async () => {
96
112
  const { service, calls } = makeSearchService([])
97
113
  const ctx = makeCtx()
98
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
114
+ const lookup = permissiveLookup(['catalog:product'])
115
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
116
+ if (name === 'searchService') return service
117
+ if (name === 'searchIndexer') return lookup
118
+ return undefined
119
+ })
99
120
  await tool.handler({ q: 'hello' }, ctx as any)
100
121
  expect(calls[0].options.limit).toBe(20)
101
122
  })
@@ -105,6 +126,105 @@ describe('search.hybrid_search', () => {
105
126
  ;(ctx.container.resolve as jest.Mock).mockReturnValue({ search: jest.fn() })
106
127
  await expect(tool.handler({ q: 'x' }, ctx as any)).rejects.toThrow(/Tenant context/)
107
128
  })
129
+
130
+ it('withholds results for entity types the caller cannot view', async () => {
131
+ const lookup = makeLookup([
132
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
133
+ { entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
134
+ ])
135
+ const { service, calls } = makeSearchService([
136
+ { entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext', presenter: { title: 'Person' } },
137
+ { entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext', presenter: { title: 'Product' } },
138
+ ])
139
+ const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
140
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
141
+ if (name === 'searchService') return service
142
+ if (name === 'searchIndexer') return lookup
143
+ throw new Error(`unexpected resolve ${name}`)
144
+ })
145
+ const result = (await tool.handler({ q: 'test' }, ctx as any)) as Record<string, unknown>
146
+ // Should have narrowed entityTypes to only readable type
147
+ expect((calls[0].options as any).entityTypes).toEqual(['customers:customer_person_profile'])
148
+ // Defense in depth: results filtered to only readable
149
+ expect(result.totalResults).toBe(1)
150
+ expect((result.results as any[])[0].entityId).toBe('customers:customer_person_profile')
151
+ })
152
+
153
+ it('short-circuits without calling search when no entity type is readable for an explicit request', async () => {
154
+ const lookup = makeLookup([
155
+ { entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
156
+ ])
157
+ const { service, calls } = makeSearchService([
158
+ { entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext' },
159
+ ])
160
+ const ctx = makeCtx({ userFeatures: ['search.view'] })
161
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
162
+ if (name === 'searchService') return service
163
+ if (name === 'searchIndexer') return lookup
164
+ throw new Error(`unexpected ${name}`)
165
+ })
166
+ const result = (await tool.handler({ q: 'test', entityTypes: ['catalog:catalog_product'] }, ctx as any)) as Record<string, unknown>
167
+ expect(calls).toHaveLength(0)
168
+ expect(result.totalResults).toBe(0)
169
+ expect(result.results).toEqual([])
170
+ })
171
+
172
+ it('intersects an explicitly requested mixed set with the readable set', async () => {
173
+ const lookup = makeLookup([
174
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
175
+ { entityId: 'catalog:catalog_product', aclFeatures: ['catalog.products.view'], enabled: true },
176
+ { entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
177
+ ])
178
+ const { service, calls } = makeSearchService([
179
+ { entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext' },
180
+ { entityId: 'catalog:catalog_product', recordId: 'c1', score: 0.8, source: 'fulltext' },
181
+ ])
182
+ const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
183
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
184
+ if (name === 'searchService') return service
185
+ if (name === 'searchIndexer') return lookup
186
+ throw new Error(`unexpected ${name}`)
187
+ })
188
+ await tool.handler({ q: 'test', entityTypes: ['customers:customer_person_profile', 'catalog:catalog_product', 'secret:thing'] }, ctx as any)
189
+ expect((calls[0].options as any).entityTypes).toEqual(['customers:customer_person_profile'])
190
+ })
191
+
192
+ it('drops results a strategy returned for an unreadable entity type (defense in depth)', async () => {
193
+ const lookup = makeLookup([
194
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
195
+ { entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
196
+ ])
197
+ const { service } = makeSearchService([
198
+ { entityId: 'customers:customer_person_profile', recordId: 'p1', score: 0.9, source: 'fulltext' },
199
+ { entityId: 'secret:thing', recordId: 's1', score: 0.8, source: 'fulltext' },
200
+ ])
201
+ const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
202
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
203
+ if (name === 'searchService') return service
204
+ if (name === 'searchIndexer') return lookup
205
+ throw new Error(`unexpected ${name}`)
206
+ })
207
+ const result = (await tool.handler({ q: 'test' }, ctx as any)) as Record<string, unknown>
208
+ expect((result.results as any[]).every((r: any) => r.entityId === 'customers:customer_person_profile')).toBe(true)
209
+ })
210
+
211
+ it('allows a superadmin to see all requested entity types', async () => {
212
+ const lookup = makeLookup([
213
+ { entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
214
+ ])
215
+ const { service, calls } = makeSearchService([
216
+ { entityId: 'secret:thing', recordId: 's1', score: 0.8, source: 'fulltext' },
217
+ ])
218
+ const ctx = makeCtx({ userFeatures: [], isSuperAdmin: true })
219
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
220
+ if (name === 'searchService') return service
221
+ if (name === 'searchIndexer') return lookup
222
+ throw new Error(`unexpected ${name}`)
223
+ })
224
+ const result = (await tool.handler({ q: 'test', entityTypes: ['secret:thing'] }, ctx as any)) as Record<string, unknown>
225
+ expect(calls[0].options.entityTypes).toEqual(['secret:thing'])
226
+ expect(result.totalResults).toBe(1)
227
+ })
108
228
  })
109
229
 
110
230
  describe('search.get_record_context', () => {
@@ -125,7 +245,12 @@ describe('search.get_record_context', () => {
125
245
  match,
126
246
  ])
127
247
  const ctx = makeCtx()
128
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
248
+ const lookup = permissiveLookup(['catalog:product'])
249
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
250
+ if (name === 'searchService') return service
251
+ if (name === 'searchIndexer') return lookup
252
+ throw new Error(`unexpected ${name}`)
253
+ })
129
254
  const result = (await tool.handler(
130
255
  { entityId: 'catalog:product', recordId: 'rec-42' },
131
256
  ctx as any,
@@ -149,7 +274,12 @@ describe('search.get_record_context', () => {
149
274
  { entityId: 'catalog:product', recordId: 'other', score: 0.2, source: 'fulltext' },
150
275
  ])
151
276
  const ctx = makeCtx()
152
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
277
+ const lookup = permissiveLookup(['catalog:product'])
278
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
279
+ if (name === 'searchService') return service
280
+ if (name === 'searchIndexer') return lookup
281
+ throw new Error(`unexpected ${name}`)
282
+ })
153
283
  const result = (await tool.handler(
154
284
  { entityId: 'catalog:product', recordId: 'missing' },
155
285
  ctx as any,
@@ -161,7 +291,12 @@ describe('search.get_record_context', () => {
161
291
  it('passes the caller tenant/org and never leaks another tenant', async () => {
162
292
  const { service, calls } = makeSearchService([])
163
293
  const ctx = makeCtx({ tenantId: 'tenant-A', organizationId: 'org-A' })
164
- ;(ctx.container.resolve as jest.Mock).mockReturnValue(service)
294
+ const lookup = permissiveLookup(['x:y'])
295
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
296
+ if (name === 'searchService') return service
297
+ if (name === 'searchIndexer') return lookup
298
+ throw new Error(`unexpected ${name}`)
299
+ })
165
300
  await tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any)
166
301
  expect(calls[0].options).toMatchObject({
167
302
  tenantId: 'tenant-A',
@@ -177,6 +312,77 @@ describe('search.get_record_context', () => {
177
312
  tool.handler({ entityId: 'x:y', recordId: 'z' }, ctx as any),
178
313
  ).rejects.toThrow(/Tenant context/)
179
314
  })
315
+
316
+ it('rejects an explicitly requested unauthorized entity type', async () => {
317
+ const lookup = makeLookup([
318
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
319
+ ])
320
+ const { service } = makeSearchService([])
321
+ const ctx = makeCtx({ userFeatures: ['search.view'] })
322
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
323
+ if (name === 'searchService') return service
324
+ if (name === 'searchIndexer') return lookup
325
+ throw new Error(`unexpected ${name}`)
326
+ })
327
+ await expect(
328
+ tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any),
329
+ ).rejects.toThrow(/Insufficient permissions/)
330
+ })
331
+
332
+ it('allows an authorized caller to retrieve record context', async () => {
333
+ const lookup = makeLookup([
334
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
335
+ ])
336
+ const { service } = makeSearchService([
337
+ { entityId: 'customers:customer_person_profile', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Person' }, url: '/backend/customers/people/rec-1', links: [] },
338
+ ])
339
+ const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
340
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
341
+ if (name === 'searchService') return service
342
+ if (name === 'searchIndexer') return lookup
343
+ throw new Error(`unexpected ${name}`)
344
+ })
345
+ const result = (await tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
346
+ expect(result.found).toBe(true)
347
+ expect((result as any).presenter.title).toBe('Person')
348
+ })
349
+
350
+ it('filters stray results a strategy returned for an unreadable entity (defense in depth)', async () => {
351
+ const lookup = makeLookup([
352
+ { entityId: 'customers:customer_person_profile', aclFeatures: ['customers.people.view'], enabled: true },
353
+ { entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
354
+ ])
355
+ // User can read person, but strategy returns secret thing for same recordId
356
+ const { service } = makeSearchService([
357
+ { entityId: 'secret:thing', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Secret' } },
358
+ ])
359
+ const ctx = makeCtx({ userFeatures: ['search.view', 'customers.people.view'] })
360
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
361
+ if (name === 'searchService') return service
362
+ if (name === 'searchIndexer') return lookup
363
+ throw new Error(`unexpected ${name}`)
364
+ })
365
+ // Requesting person, but backend returned secret – should be filtered to not found
366
+ const result = (await tool.handler({ entityId: 'customers:customer_person_profile', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
367
+ expect(result.found).toBe(false)
368
+ })
369
+
370
+ it('allows a superadmin to retrieve any record context', async () => {
371
+ const lookup = makeLookup([
372
+ { entityId: 'secret:thing', aclFeatures: ['secret.view'], enabled: true },
373
+ ])
374
+ const { service } = makeSearchService([
375
+ { entityId: 'secret:thing', recordId: 'rec-1', score: 1, source: 'fulltext', presenter: { title: 'Secret' }, url: '/x', links: [] },
376
+ ])
377
+ const ctx = makeCtx({ userFeatures: [], isSuperAdmin: true })
378
+ ;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
379
+ if (name === 'searchService') return service
380
+ if (name === 'searchIndexer') return lookup
381
+ throw new Error(`unexpected ${name}`)
382
+ })
383
+ const result = (await tool.handler({ entityId: 'secret:thing', recordId: 'rec-1' }, ctx as any)) as Record<string, unknown>
384
+ expect(result.found).toBe(true)
385
+ })
180
386
  })
181
387
 
182
388
  describe('search-pack tool surface', () => {
@@ -7,13 +7,68 @@
7
7
  */
8
8
  import { z } from 'zod'
9
9
  import type { SearchOptions, SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'
10
+ import {
11
+ canReadSearchEntity,
12
+ filterSearchResultsByEntityAccess,
13
+ resolveReadableEntityTypes,
14
+ type SearchEntityAccessSubject,
15
+ type SearchEntityConfigLookup,
16
+ type SearchEntityDenyReason,
17
+ } from '@open-mercato/shared/lib/search/entityAccess'
10
18
  import { defineAiTool } from '../lib/ai-tool-definition'
11
- import type { AiToolDefinition } from '../lib/types'
19
+ import type { AiToolDefinition, McpToolContext } from '../lib/types'
12
20
 
13
21
  type SearchServiceLike = {
14
22
  search: (query: string, options: SearchOptions) => Promise<SearchResult[]>
15
23
  }
16
24
 
25
+ class SearchToolAuthorizationError extends Error {
26
+ constructor(message: string) {
27
+ super(message)
28
+ this.name = 'SearchToolAuthorizationError'
29
+ }
30
+ }
31
+
32
+ function resolveSearchIndexer(ctx: McpToolContext): SearchEntityConfigLookup {
33
+ try {
34
+ const indexer = ctx.container.resolve('searchIndexer') as SearchEntityConfigLookup | undefined
35
+ if (indexer && typeof indexer.getEntityConfig === 'function' && typeof indexer.getAllEntityConfigs === 'function') {
36
+ return indexer
37
+ }
38
+ } catch {
39
+ // fall through to throw
40
+ }
41
+ throw new SearchToolAuthorizationError('[internal] Search entity registry unavailable')
42
+ }
43
+
44
+ function authorizeEntityAccess(
45
+ entityType: string,
46
+ lookup: SearchEntityConfigLookup,
47
+ subject: SearchEntityAccessSubject,
48
+ ): void {
49
+ if (subject.isSuperAdmin) return
50
+ let denial: SearchEntityDenyReason | undefined
51
+ const allowed = canReadSearchEntity(entityType, lookup, subject, {
52
+ onDeny: (_, reason) => {
53
+ denial = reason
54
+ },
55
+ })
56
+ if (allowed) return
57
+ if (denial === 'unconfigured') {
58
+ throw new SearchToolAuthorizationError(`[internal] Entity type "${entityType}" is not configured for search`)
59
+ }
60
+ if (denial === 'no-acl-features') {
61
+ throw new SearchToolAuthorizationError(
62
+ `[internal] Entity type "${entityType}" does not declare aclFeatures; access denied`,
63
+ )
64
+ }
65
+ const config = lookup.getEntityConfig(entityType)
66
+ const required = config?.aclFeatures ?? []
67
+ throw new SearchToolAuthorizationError(
68
+ `[internal] Insufficient permissions for entity "${entityType}". Required: ${required.join(', ')}`,
69
+ )
70
+ }
71
+
17
72
  const hybridSearchInput = z.object({
18
73
  q: z.string().min(1).describe('Search query text.'),
19
74
  limit: z
@@ -49,13 +104,52 @@ const hybridSearchTool = defineAiTool({
49
104
  const service = ctx.container.resolve<SearchServiceLike>('searchService')
50
105
  const limit = input.limit ?? 20
51
106
  const started = Date.now()
52
- const results = await service.search(input.q, {
107
+ const subject: SearchEntityAccessSubject = {
108
+ grantedFeatures: ctx.userFeatures,
109
+ isSuperAdmin: ctx.isSuperAdmin,
110
+ }
111
+
112
+ if (ctx.isSuperAdmin) {
113
+ const results = await service.search(input.q, {
114
+ tenantId: ctx.tenantId,
115
+ organizationId: ctx.organizationId,
116
+ limit,
117
+ strategies: input.strategies as SearchStrategyId[] | undefined,
118
+ entityTypes: input.entityTypes,
119
+ })
120
+ const timingMs = Date.now() - started
121
+ const strategiesUsed = Array.from(
122
+ new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),
123
+ )
124
+ return {
125
+ query: input.q,
126
+ totalResults: results.length,
127
+ results,
128
+ strategiesUsed,
129
+ timing: { ms: timingMs },
130
+ }
131
+ }
132
+
133
+ const lookup = resolveSearchIndexer(ctx)
134
+ const readableEntityTypes = resolveReadableEntityTypes(lookup, subject, input.entityTypes)
135
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
136
+ return {
137
+ query: input.q,
138
+ totalResults: 0,
139
+ results: [],
140
+ strategiesUsed: [],
141
+ timing: { ms: Date.now() - started },
142
+ }
143
+ }
144
+
145
+ const rawResults = await service.search(input.q, {
53
146
  tenantId: ctx.tenantId,
54
147
  organizationId: ctx.organizationId,
55
148
  limit,
56
149
  strategies: input.strategies as SearchStrategyId[] | undefined,
57
- entityTypes: input.entityTypes,
150
+ entityTypes: readableEntityTypes,
58
151
  })
152
+ const results = filterSearchResultsByEntityAccess(rawResults, lookup, subject)
59
153
  const timingMs = Date.now() - started
60
154
  const strategiesUsed = Array.from(
61
155
  new Set(results.map((result) => result.source).filter((id): id is SearchStrategyId => typeof id === 'string')),
@@ -88,13 +182,25 @@ const getRecordContextTool = defineAiTool({
88
182
  throw new Error('Tenant context is required for search.get_record_context')
89
183
  }
90
184
  const input = getRecordContextInput.parse(rawInput)
185
+ const subject: SearchEntityAccessSubject = {
186
+ grantedFeatures: ctx.userFeatures,
187
+ isSuperAdmin: ctx.isSuperAdmin,
188
+ }
189
+
190
+ let lookup: SearchEntityConfigLookup | undefined
191
+ if (!ctx.isSuperAdmin) {
192
+ lookup = resolveSearchIndexer(ctx)
193
+ authorizeEntityAccess(input.entityId, lookup, subject)
194
+ }
195
+
91
196
  const service = ctx.container.resolve<SearchServiceLike>('searchService')
92
- const results = await service.search(input.recordId, {
197
+ const rawResults = await service.search(input.recordId, {
93
198
  tenantId: ctx.tenantId,
94
199
  organizationId: ctx.organizationId,
95
200
  limit: 5,
96
201
  entityTypes: [input.entityId],
97
202
  })
203
+ const results = lookup ? filterSearchResultsByEntityAccess(rawResults, lookup, subject) : rawResults
98
204
  const match = results.find((result) => result.recordId === input.recordId)
99
205
  if (!match) {
100
206
  return {