@open-mercato/ai-assistant 0.10.1-develop.7601.1.e057b693a8 → 0.10.1-develop.7616.1.51a1f65492
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 +42 -2
- package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +166 -0
- package/src/modules/ai_assistant/ai-tools/search-pack.ts +65 -2
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
resolveReadableEntityTypes
|
|
6
6
|
} from "@open-mercato/shared/lib/search/entityAccess";
|
|
7
7
|
import { defineAiTool } from "../lib/ai-tool-definition.js";
|
|
8
|
+
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
9
|
+
import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
|
|
8
10
|
class SearchToolAuthorizationError extends Error {
|
|
9
11
|
constructor(message) {
|
|
10
12
|
super(message);
|
|
@@ -44,6 +46,24 @@ function authorizeEntityAccess(entityType, lookup, subject) {
|
|
|
44
46
|
`[internal] Insufficient permissions for entity "${entityType}". Required: ${required.join(", ")}`
|
|
45
47
|
);
|
|
46
48
|
}
|
|
49
|
+
async function resolveActorOrganizationScope(ctx) {
|
|
50
|
+
if (!ctx.userId) return { organizationId: void 0, organizationIds: void 0, denyAll: true };
|
|
51
|
+
const auth = {
|
|
52
|
+
sub: ctx.userId,
|
|
53
|
+
tenantId: ctx.tenantId,
|
|
54
|
+
orgId: ctx.organizationId,
|
|
55
|
+
isSuperAdmin: ctx.isSuperAdmin
|
|
56
|
+
};
|
|
57
|
+
const scope = await resolveOrganizationScopeForRequest({
|
|
58
|
+
container: ctx.container,
|
|
59
|
+
auth,
|
|
60
|
+
selectedId: ctx.organizationId
|
|
61
|
+
});
|
|
62
|
+
const scopeFilter = resolveOrganizationScopeFilter(scope, auth);
|
|
63
|
+
const denyAll = Array.isArray(scope.filterIds) && scope.filterIds.length === 0;
|
|
64
|
+
const organizationId = typeof scope.selectedId === "string" && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : void 0;
|
|
65
|
+
return { organizationId, organizationIds: scopeFilter.organizationIds, denyAll };
|
|
66
|
+
}
|
|
47
67
|
const hybridSearchInput = z.object({
|
|
48
68
|
q: z.string().min(1).describe("Search query text."),
|
|
49
69
|
limit: z.number().int().min(1).max(100).optional().describe("Maximum results (default 20, max 100)."),
|
|
@@ -100,9 +120,20 @@ const hybridSearchTool = defineAiTool({
|
|
|
100
120
|
timing: { ms: Date.now() - started }
|
|
101
121
|
};
|
|
102
122
|
}
|
|
123
|
+
const orgScope = await resolveActorOrganizationScope(ctx);
|
|
124
|
+
if (orgScope.denyAll) {
|
|
125
|
+
return {
|
|
126
|
+
query: input.q,
|
|
127
|
+
totalResults: 0,
|
|
128
|
+
results: [],
|
|
129
|
+
strategiesUsed: [],
|
|
130
|
+
timing: { ms: Date.now() - started }
|
|
131
|
+
};
|
|
132
|
+
}
|
|
103
133
|
const rawResults = await service.search(input.q, {
|
|
104
134
|
tenantId: ctx.tenantId,
|
|
105
|
-
organizationId:
|
|
135
|
+
organizationId: orgScope.organizationId,
|
|
136
|
+
organizationIds: orgScope.organizationIds,
|
|
106
137
|
limit,
|
|
107
138
|
strategies: input.strategies,
|
|
108
139
|
entityTypes: readableEntityTypes
|
|
@@ -146,10 +177,19 @@ const getRecordContextTool = defineAiTool({
|
|
|
146
177
|
lookup = resolveSearchIndexer(ctx);
|
|
147
178
|
authorizeEntityAccess(input.entityId, lookup, subject);
|
|
148
179
|
}
|
|
180
|
+
const orgScope = await resolveActorOrganizationScope(ctx);
|
|
181
|
+
if (orgScope.denyAll) {
|
|
182
|
+
return {
|
|
183
|
+
found: false,
|
|
184
|
+
entityId: input.entityId,
|
|
185
|
+
recordId: input.recordId
|
|
186
|
+
};
|
|
187
|
+
}
|
|
149
188
|
const service = ctx.container.resolve("searchService");
|
|
150
189
|
const rawResults = await service.search(input.recordId, {
|
|
151
190
|
tenantId: ctx.tenantId,
|
|
152
|
-
organizationId:
|
|
191
|
+
organizationId: orgScope.organizationId,
|
|
192
|
+
organizationIds: orgScope.organizationIds,
|
|
153
193
|
limit: 5,
|
|
154
194
|
entityTypes: [input.entityId]
|
|
155
195
|
});
|
|
@@ -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 {\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;
|
|
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'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { AwilixContainer } from 'awilix'\n\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\n/**\n * Resolve the actor's allowed-organization scope the same way the HTTP search\n * route does, so this tool pack can never return results outside a\n * restricted user's organization allowlist \u2014 even when their current\n * organization is null. Mirrors packages/search/.../api/search/route.ts.\n *\n * `organizationId` is the re-validated selection (`scope.selectedId`), not the\n * raw `ctx.organizationId` \u2014 `SearchOptions.organizationId` takes precedence\n * over `organizationIds` in `normalizeOrganizationFilter`, so passing the\n * unvalidated context value through would let a stale/out-of-allowlist\n * selection bypass the allowlist below.\n */\nasync function resolveActorOrganizationScope(ctx: McpToolContext): Promise<{\n organizationId: string | undefined\n organizationIds: string[] | undefined\n denyAll: boolean\n}> {\n if (!ctx.userId) return { organizationId: undefined, organizationIds: undefined, denyAll: true }\n const auth: AuthContext = {\n sub: ctx.userId,\n tenantId: ctx.tenantId,\n orgId: ctx.organizationId,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n const scope = await resolveOrganizationScopeForRequest({\n container: ctx.container as unknown as AwilixContainer,\n auth,\n selectedId: ctx.organizationId,\n })\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const denyAll = Array.isArray(scope.filterIds) && scope.filterIds.length === 0\n const organizationId = typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0\n ? scope.selectedId.trim()\n : undefined\n return { organizationId, organizationIds: scopeFilter.organizationIds, denyAll }\n}\n\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 orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\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: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\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 orgScope = await resolveActorOrganizationScope(ctx)\n if (orgScope.denyAll) {\n return {\n found: false as const,\n entityId: input.entityId,\n recordId: input.recordId,\n }\n }\n\n const service = ctx.container.resolve<SearchServiceLike>('searchService')\n const rawResults = await service.search(input.recordId, {\n tenantId: ctx.tenantId,\n organizationId: orgScope.organizationId,\n organizationIds: orgScope.organizationIds,\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
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.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7616.1.51a1f65492",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -100,16 +100,16 @@
|
|
|
100
100
|
"zod-to-json-schema": "^3.25.2"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
104
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
103
|
+
"@open-mercato/shared": "0.10.1-develop.7616.1.51a1f65492",
|
|
104
|
+
"@open-mercato/ui": "0.10.1-develop.7616.1.51a1f65492",
|
|
105
105
|
"react": "^19.0.0",
|
|
106
106
|
"react-dom": "^19.0.0",
|
|
107
107
|
"zod": ">=3.23.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
|
-
"@open-mercato/cli": "0.10.1-develop.
|
|
111
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
110
|
+
"@open-mercato/cli": "0.10.1-develop.7616.1.51a1f65492",
|
|
111
|
+
"@open-mercato/shared": "0.10.1-develop.7616.1.51a1f65492",
|
|
112
|
+
"@open-mercato/ui": "0.10.1-develop.7616.1.51a1f65492",
|
|
113
113
|
"@types/react": "^19.2.18",
|
|
114
114
|
"@types/react-dom": "^19.2.5",
|
|
115
115
|
"react": "19.2.8",
|
|
@@ -42,6 +42,37 @@ function makeCtx(overrides: Partial<ToolContext> = {}): ToolContext {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Builds a `container.resolve` DI mock that resolves `em`/`rbacService` so
|
|
47
|
+
* `resolveActorOrganizationScope` (issue #7808) runs the real
|
|
48
|
+
* `resolveOrganizationScopeForRequest` path instead of its no-DI fallback —
|
|
49
|
+
* needed for tests that assert the actual allowed-organization allowlist is
|
|
50
|
+
* enforced. `aclOrganizations: null` means unrestricted (same as the
|
|
51
|
+
* pre-#7808 single-current-org behavior); an array is the restricted
|
|
52
|
+
* allowlist; `[]` means explicitly denied.
|
|
53
|
+
*/
|
|
54
|
+
function makeOrgScopeResolver(options: {
|
|
55
|
+
aclOrganizations: string[] | null
|
|
56
|
+
isSuperAdmin?: boolean
|
|
57
|
+
knownOrgIds?: string[]
|
|
58
|
+
}) {
|
|
59
|
+
const em = {
|
|
60
|
+
find: jest.fn(async (_entity: unknown, filter: { id?: { $in: string[] } }) => {
|
|
61
|
+
const ids = filter?.id?.$in ?? []
|
|
62
|
+
const known = new Set(options.knownOrgIds ?? ['org-1', 'org-2', 'org-3'])
|
|
63
|
+
return ids.filter((id) => known.has(id)).map((id) => ({ id, descendantIds: [] }))
|
|
64
|
+
}),
|
|
65
|
+
}
|
|
66
|
+
const rbacService = {
|
|
67
|
+
loadAcl: jest.fn(async () => ({
|
|
68
|
+
isSuperAdmin: options.isSuperAdmin ?? false,
|
|
69
|
+
features: [],
|
|
70
|
+
organizations: options.aclOrganizations,
|
|
71
|
+
})),
|
|
72
|
+
}
|
|
73
|
+
return { em, rbacService }
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
function makeSearchService(results: unknown[]): {
|
|
46
77
|
service: { search: (query: string, options: Record<string, unknown>) => Promise<unknown[]> }
|
|
47
78
|
calls: SearchCall[]
|
|
@@ -224,6 +255,8 @@ describe('search.hybrid_search', () => {
|
|
|
224
255
|
const result = (await tool.handler({ q: 'test', entityTypes: ['secret:thing'] }, ctx as any)) as Record<string, unknown>
|
|
225
256
|
expect(calls[0].options.entityTypes).toEqual(['secret:thing'])
|
|
226
257
|
expect(result.totalResults).toBe(1)
|
|
258
|
+
// Regression (#7808): superadmins must stay unfiltered — no organizationIds restriction.
|
|
259
|
+
expect(calls[0].options.organizationIds).toBeUndefined()
|
|
227
260
|
})
|
|
228
261
|
})
|
|
229
262
|
|
|
@@ -385,6 +418,139 @@ describe('search.get_record_context', () => {
|
|
|
385
418
|
})
|
|
386
419
|
})
|
|
387
420
|
|
|
421
|
+
describe('org-allowlist enforcement (issue #7808)', () => {
|
|
422
|
+
const hybridTool = findTool('search.hybrid_search')
|
|
423
|
+
const recordContextTool = findTool('search.get_record_context')
|
|
424
|
+
|
|
425
|
+
it('hybrid_search scopes a restricted user with no current org to their organization allowlist', async () => {
|
|
426
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
427
|
+
const { service, calls } = makeSearchService([
|
|
428
|
+
{ entityId: 'catalog:product', recordId: 'c1', score: 0.8, source: 'fulltext' },
|
|
429
|
+
])
|
|
430
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: ['org-2', 'org-3'] })
|
|
431
|
+
const ctx = makeCtx({ organizationId: null })
|
|
432
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
433
|
+
if (name === 'searchService') return service
|
|
434
|
+
if (name === 'searchIndexer') return lookup
|
|
435
|
+
if (name === 'em') return em
|
|
436
|
+
if (name === 'rbacService') return rbacService
|
|
437
|
+
throw new Error(`unexpected ${name}`)
|
|
438
|
+
})
|
|
439
|
+
const result = (await hybridTool.handler({ q: 'widget' }, ctx as any)) as Record<string, unknown>
|
|
440
|
+
expect(calls).toHaveLength(1)
|
|
441
|
+
expect(calls[0].options.organizationIds).toEqual(['org-2', 'org-3'])
|
|
442
|
+
expect(result.totalResults).toBe(1)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('hybrid_search denies a restricted user whose allowlist is explicitly empty, without calling search', async () => {
|
|
446
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
447
|
+
const { service, calls } = makeSearchService([
|
|
448
|
+
{ entityId: 'catalog:product', recordId: 'c1', score: 0.8, source: 'fulltext' },
|
|
449
|
+
])
|
|
450
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: [] })
|
|
451
|
+
const ctx = makeCtx({ organizationId: null })
|
|
452
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
453
|
+
if (name === 'searchService') return service
|
|
454
|
+
if (name === 'searchIndexer') return lookup
|
|
455
|
+
if (name === 'em') return em
|
|
456
|
+
if (name === 'rbacService') return rbacService
|
|
457
|
+
throw new Error(`unexpected ${name}`)
|
|
458
|
+
})
|
|
459
|
+
const result = (await hybridTool.handler({ q: 'widget' }, ctx as any)) as Record<string, unknown>
|
|
460
|
+
expect(calls).toHaveLength(0)
|
|
461
|
+
expect(result.totalResults).toBe(0)
|
|
462
|
+
expect(result.results).toEqual([])
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('hybrid_search leaves a superadmin unfiltered regardless of the ACL allowlist', async () => {
|
|
466
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
467
|
+
const { service, calls } = makeSearchService([
|
|
468
|
+
{ entityId: 'catalog:product', recordId: 'c1', score: 0.8, source: 'fulltext' },
|
|
469
|
+
])
|
|
470
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: [], isSuperAdmin: true })
|
|
471
|
+
const ctx = makeCtx({ organizationId: null, isSuperAdmin: true, userFeatures: [] })
|
|
472
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
473
|
+
if (name === 'searchService') return service
|
|
474
|
+
if (name === 'searchIndexer') return lookup
|
|
475
|
+
if (name === 'em') return em
|
|
476
|
+
if (name === 'rbacService') return rbacService
|
|
477
|
+
throw new Error(`unexpected ${name}`)
|
|
478
|
+
})
|
|
479
|
+
const result = (await hybridTool.handler({ q: 'widget' }, ctx as any)) as Record<string, unknown>
|
|
480
|
+
expect(calls).toHaveLength(1)
|
|
481
|
+
expect(calls[0].options.organizationIds).toBeUndefined()
|
|
482
|
+
expect(result.totalResults).toBe(1)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('get_record_context scopes a restricted user with no current org to their organization allowlist', async () => {
|
|
486
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
487
|
+
const { service, calls } = makeSearchService([
|
|
488
|
+
{ entityId: 'catalog:product', recordId: 'rec-42', score: 1, source: 'fulltext', presenter: { title: 'Widget' } },
|
|
489
|
+
])
|
|
490
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: ['org-2', 'org-3'] })
|
|
491
|
+
const ctx = makeCtx({ organizationId: null })
|
|
492
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
493
|
+
if (name === 'searchService') return service
|
|
494
|
+
if (name === 'searchIndexer') return lookup
|
|
495
|
+
if (name === 'em') return em
|
|
496
|
+
if (name === 'rbacService') return rbacService
|
|
497
|
+
throw new Error(`unexpected ${name}`)
|
|
498
|
+
})
|
|
499
|
+
const result = (await recordContextTool.handler(
|
|
500
|
+
{ entityId: 'catalog:product', recordId: 'rec-42' },
|
|
501
|
+
ctx as any,
|
|
502
|
+
)) as Record<string, unknown>
|
|
503
|
+
expect(calls).toHaveLength(1)
|
|
504
|
+
expect(calls[0].options.organizationIds).toEqual(['org-2', 'org-3'])
|
|
505
|
+
expect(result.found).toBe(true)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('get_record_context denies a restricted user whose allowlist is explicitly empty, without calling search', async () => {
|
|
509
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
510
|
+
const { service, calls } = makeSearchService([
|
|
511
|
+
{ entityId: 'catalog:product', recordId: 'rec-42', score: 1, source: 'fulltext' },
|
|
512
|
+
])
|
|
513
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: [] })
|
|
514
|
+
const ctx = makeCtx({ organizationId: null })
|
|
515
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
516
|
+
if (name === 'searchService') return service
|
|
517
|
+
if (name === 'searchIndexer') return lookup
|
|
518
|
+
if (name === 'em') return em
|
|
519
|
+
if (name === 'rbacService') return rbacService
|
|
520
|
+
throw new Error(`unexpected ${name}`)
|
|
521
|
+
})
|
|
522
|
+
const result = (await recordContextTool.handler(
|
|
523
|
+
{ entityId: 'catalog:product', recordId: 'rec-42' },
|
|
524
|
+
ctx as any,
|
|
525
|
+
)) as Record<string, unknown>
|
|
526
|
+
expect(calls).toHaveLength(0)
|
|
527
|
+
expect(result.found).toBe(false)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('get_record_context leaves a superadmin unfiltered regardless of the ACL allowlist', async () => {
|
|
531
|
+
const lookup = permissiveLookup(['catalog:product'])
|
|
532
|
+
const { service, calls } = makeSearchService([
|
|
533
|
+
{ entityId: 'catalog:product', recordId: 'rec-42', score: 1, source: 'fulltext', presenter: { title: 'Widget' } },
|
|
534
|
+
])
|
|
535
|
+
const { em, rbacService } = makeOrgScopeResolver({ aclOrganizations: [], isSuperAdmin: true })
|
|
536
|
+
const ctx = makeCtx({ organizationId: null, isSuperAdmin: true, userFeatures: [] })
|
|
537
|
+
;(ctx.container.resolve as jest.Mock).mockImplementation((name: string) => {
|
|
538
|
+
if (name === 'searchService') return service
|
|
539
|
+
if (name === 'searchIndexer') return lookup
|
|
540
|
+
if (name === 'em') return em
|
|
541
|
+
if (name === 'rbacService') return rbacService
|
|
542
|
+
throw new Error(`unexpected ${name}`)
|
|
543
|
+
})
|
|
544
|
+
const result = (await recordContextTool.handler(
|
|
545
|
+
{ entityId: 'catalog:product', recordId: 'rec-42' },
|
|
546
|
+
ctx as any,
|
|
547
|
+
)) as Record<string, unknown>
|
|
548
|
+
expect(calls).toHaveLength(1)
|
|
549
|
+
expect(calls[0].options.organizationIds).toBeUndefined()
|
|
550
|
+
expect(result.found).toBe(true)
|
|
551
|
+
})
|
|
552
|
+
})
|
|
553
|
+
|
|
388
554
|
describe('search-pack tool surface', () => {
|
|
389
555
|
it('exports exactly the expected tool names and shapes', () => {
|
|
390
556
|
const names = searchAiTools.map((tool) => tool.name)
|
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
} from '@open-mercato/shared/lib/search/entityAccess'
|
|
18
18
|
import { defineAiTool } from '../lib/ai-tool-definition'
|
|
19
19
|
import type { AiToolDefinition, McpToolContext } from '../lib/types'
|
|
20
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
21
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
22
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
23
|
+
import type { AwilixContainer } from 'awilix'
|
|
20
24
|
|
|
21
25
|
type SearchServiceLike = {
|
|
22
26
|
search: (query: string, options: SearchOptions) => Promise<SearchResult[]>
|
|
@@ -69,6 +73,43 @@ function authorizeEntityAccess(
|
|
|
69
73
|
)
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the actor's allowed-organization scope the same way the HTTP search
|
|
78
|
+
* route does, so this tool pack can never return results outside a
|
|
79
|
+
* restricted user's organization allowlist — even when their current
|
|
80
|
+
* organization is null. Mirrors packages/search/.../api/search/route.ts.
|
|
81
|
+
*
|
|
82
|
+
* `organizationId` is the re-validated selection (`scope.selectedId`), not the
|
|
83
|
+
* raw `ctx.organizationId` — `SearchOptions.organizationId` takes precedence
|
|
84
|
+
* over `organizationIds` in `normalizeOrganizationFilter`, so passing the
|
|
85
|
+
* unvalidated context value through would let a stale/out-of-allowlist
|
|
86
|
+
* selection bypass the allowlist below.
|
|
87
|
+
*/
|
|
88
|
+
async function resolveActorOrganizationScope(ctx: McpToolContext): Promise<{
|
|
89
|
+
organizationId: string | undefined
|
|
90
|
+
organizationIds: string[] | undefined
|
|
91
|
+
denyAll: boolean
|
|
92
|
+
}> {
|
|
93
|
+
if (!ctx.userId) return { organizationId: undefined, organizationIds: undefined, denyAll: true }
|
|
94
|
+
const auth: AuthContext = {
|
|
95
|
+
sub: ctx.userId,
|
|
96
|
+
tenantId: ctx.tenantId,
|
|
97
|
+
orgId: ctx.organizationId,
|
|
98
|
+
isSuperAdmin: ctx.isSuperAdmin,
|
|
99
|
+
}
|
|
100
|
+
const scope = await resolveOrganizationScopeForRequest({
|
|
101
|
+
container: ctx.container as unknown as AwilixContainer,
|
|
102
|
+
auth,
|
|
103
|
+
selectedId: ctx.organizationId,
|
|
104
|
+
})
|
|
105
|
+
const scopeFilter = resolveOrganizationScopeFilter(scope, auth)
|
|
106
|
+
const denyAll = Array.isArray(scope.filterIds) && scope.filterIds.length === 0
|
|
107
|
+
const organizationId = typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0
|
|
108
|
+
? scope.selectedId.trim()
|
|
109
|
+
: undefined
|
|
110
|
+
return { organizationId, organizationIds: scopeFilter.organizationIds, denyAll }
|
|
111
|
+
}
|
|
112
|
+
|
|
72
113
|
const hybridSearchInput = z.object({
|
|
73
114
|
q: z.string().min(1).describe('Search query text.'),
|
|
74
115
|
limit: z
|
|
@@ -142,9 +183,21 @@ const hybridSearchTool = defineAiTool({
|
|
|
142
183
|
}
|
|
143
184
|
}
|
|
144
185
|
|
|
186
|
+
const orgScope = await resolveActorOrganizationScope(ctx)
|
|
187
|
+
if (orgScope.denyAll) {
|
|
188
|
+
return {
|
|
189
|
+
query: input.q,
|
|
190
|
+
totalResults: 0,
|
|
191
|
+
results: [],
|
|
192
|
+
strategiesUsed: [],
|
|
193
|
+
timing: { ms: Date.now() - started },
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
145
197
|
const rawResults = await service.search(input.q, {
|
|
146
198
|
tenantId: ctx.tenantId,
|
|
147
|
-
organizationId:
|
|
199
|
+
organizationId: orgScope.organizationId,
|
|
200
|
+
organizationIds: orgScope.organizationIds,
|
|
148
201
|
limit,
|
|
149
202
|
strategies: input.strategies as SearchStrategyId[] | undefined,
|
|
150
203
|
entityTypes: readableEntityTypes,
|
|
@@ -193,10 +246,20 @@ const getRecordContextTool = defineAiTool({
|
|
|
193
246
|
authorizeEntityAccess(input.entityId, lookup, subject)
|
|
194
247
|
}
|
|
195
248
|
|
|
249
|
+
const orgScope = await resolveActorOrganizationScope(ctx)
|
|
250
|
+
if (orgScope.denyAll) {
|
|
251
|
+
return {
|
|
252
|
+
found: false as const,
|
|
253
|
+
entityId: input.entityId,
|
|
254
|
+
recordId: input.recordId,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
196
258
|
const service = ctx.container.resolve<SearchServiceLike>('searchService')
|
|
197
259
|
const rawResults = await service.search(input.recordId, {
|
|
198
260
|
tenantId: ctx.tenantId,
|
|
199
|
-
organizationId:
|
|
261
|
+
organizationId: orgScope.organizationId,
|
|
262
|
+
organizationIds: orgScope.organizationIds,
|
|
200
263
|
limit: 5,
|
|
201
264
|
entityTypes: [input.entityId],
|
|
202
265
|
})
|