@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.
@@ -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: ctx.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: ctx.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": "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",
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.7601.1.e057b693a8",
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.7601.1.e057b693a8",
104
- "@open-mercato/ui": "0.10.1-develop.7601.1.e057b693a8",
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.7601.1.e057b693a8",
111
- "@open-mercato/shared": "0.10.1-develop.7601.1.e057b693a8",
112
- "@open-mercato/ui": "0.10.1-develop.7601.1.e057b693a8",
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: ctx.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: ctx.organizationId,
261
+ organizationId: orgScope.organizationId,
262
+ organizationIds: orgScope.organizationIds,
200
263
  limit: 5,
201
264
  entityTypes: [input.entityId],
202
265
  })