@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.
- package/dist/modules/ai_assistant/ai-tools/search-pack.js +93 -3
- package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +3 -3
- package/package.json +6 -6
- package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +5 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +211 -5
- package/src/modules/ai_assistant/ai-tools/search-pack.ts +110 -4
|
@@ -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
|
|
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:
|
|
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
|
|
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:
|
|
5
|
-
"mappings": "AAOA,SAAS,SAAS;AAElB,SAAS,oBAAoB;AAO7B,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,UAAU,MAAM,QAAQ,OAAO,MAAM,GAAG;AAAA,
|
|
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": "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",
|
|
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.
|
|
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.
|
|
103
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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.
|
|
110
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
111
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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 {
|