@open-mercato/ai-assistant 0.4.2-canary-c02407ff85

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.
Files changed (193) hide show
  1. package/AGENTS.md +1090 -0
  2. package/README.md +607 -0
  3. package/build.mjs +92 -0
  4. package/dist/di.js +8 -0
  5. package/dist/di.js.map +7 -0
  6. package/dist/frontend/components/CommandPalette/CommandFooter.js +80 -0
  7. package/dist/frontend/components/CommandPalette/CommandFooter.js.map +7 -0
  8. package/dist/frontend/components/CommandPalette/CommandHeader.js +53 -0
  9. package/dist/frontend/components/CommandPalette/CommandHeader.js.map +7 -0
  10. package/dist/frontend/components/CommandPalette/CommandInput.js +29 -0
  11. package/dist/frontend/components/CommandPalette/CommandInput.js.map +7 -0
  12. package/dist/frontend/components/CommandPalette/CommandItem.js +92 -0
  13. package/dist/frontend/components/CommandPalette/CommandItem.js.map +7 -0
  14. package/dist/frontend/components/CommandPalette/CommandPalette.js +244 -0
  15. package/dist/frontend/components/CommandPalette/CommandPalette.js.map +7 -0
  16. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js +42 -0
  17. package/dist/frontend/components/CommandPalette/CommandPaletteProvider.js.map +7 -0
  18. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js +18 -0
  19. package/dist/frontend/components/CommandPalette/CommandPaletteWrapper.js.map +7 -0
  20. package/dist/frontend/components/CommandPalette/DebugPanel.js +215 -0
  21. package/dist/frontend/components/CommandPalette/DebugPanel.js.map +7 -0
  22. package/dist/frontend/components/CommandPalette/MessageBubble.js +64 -0
  23. package/dist/frontend/components/CommandPalette/MessageBubble.js.map +7 -0
  24. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js +91 -0
  25. package/dist/frontend/components/CommandPalette/ToolCallConfirmation.js.map +7 -0
  26. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js +47 -0
  27. package/dist/frontend/components/CommandPalette/ToolCallDisplay.js.map +7 -0
  28. package/dist/frontend/components/CommandPalette/ToolChatPage.js +74 -0
  29. package/dist/frontend/components/CommandPalette/ToolChatPage.js.map +7 -0
  30. package/dist/frontend/components/CommandPalette/index.js +28 -0
  31. package/dist/frontend/components/CommandPalette/index.js.map +7 -0
  32. package/dist/frontend/constants.js +41 -0
  33. package/dist/frontend/constants.js.map +7 -0
  34. package/dist/frontend/hooks/index.js +13 -0
  35. package/dist/frontend/hooks/index.js.map +7 -0
  36. package/dist/frontend/hooks/useCommandPalette.js +1094 -0
  37. package/dist/frontend/hooks/useCommandPalette.js.map +7 -0
  38. package/dist/frontend/hooks/useMcpTools.js +66 -0
  39. package/dist/frontend/hooks/useMcpTools.js.map +7 -0
  40. package/dist/frontend/hooks/usePageContext.js +48 -0
  41. package/dist/frontend/hooks/usePageContext.js.map +7 -0
  42. package/dist/frontend/hooks/useRecentActions.js +56 -0
  43. package/dist/frontend/hooks/useRecentActions.js.map +7 -0
  44. package/dist/frontend/hooks/useRecentTools.js +55 -0
  45. package/dist/frontend/hooks/useRecentTools.js.map +7 -0
  46. package/dist/frontend/index.js +35 -0
  47. package/dist/frontend/index.js.map +7 -0
  48. package/dist/frontend/types.js +1 -0
  49. package/dist/frontend/types.js.map +7 -0
  50. package/dist/frontend/utils/index.js +7 -0
  51. package/dist/frontend/utils/index.js.map +7 -0
  52. package/dist/frontend/utils/toolMatcher.js +95 -0
  53. package/dist/frontend/utils/toolMatcher.js.map +7 -0
  54. package/dist/index.js +57 -0
  55. package/dist/index.js.map +7 -0
  56. package/dist/modules/ai_assistant/acl.js +14 -0
  57. package/dist/modules/ai_assistant/acl.js.map +7 -0
  58. package/dist/modules/ai_assistant/api/chat/route.js +152 -0
  59. package/dist/modules/ai_assistant/api/chat/route.js.map +7 -0
  60. package/dist/modules/ai_assistant/api/health/route.js +27 -0
  61. package/dist/modules/ai_assistant/api/health/route.js.map +7 -0
  62. package/dist/modules/ai_assistant/api/route/route.js +123 -0
  63. package/dist/modules/ai_assistant/api/route/route.js.map +7 -0
  64. package/dist/modules/ai_assistant/api/settings/route.js +60 -0
  65. package/dist/modules/ai_assistant/api/settings/route.js.map +7 -0
  66. package/dist/modules/ai_assistant/api/tools/execute/route.js +58 -0
  67. package/dist/modules/ai_assistant/api/tools/execute/route.js.map +7 -0
  68. package/dist/modules/ai_assistant/api/tools/route.js +48 -0
  69. package/dist/modules/ai_assistant/api/tools/route.js.map +7 -0
  70. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +10 -0
  71. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +7 -0
  72. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +28 -0
  73. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +7 -0
  74. package/dist/modules/ai_assistant/cli.js +192 -0
  75. package/dist/modules/ai_assistant/cli.js.map +7 -0
  76. package/dist/modules/ai_assistant/di.js +11 -0
  77. package/dist/modules/ai_assistant/di.js.map +7 -0
  78. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js +257 -0
  79. package/dist/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.js.map +7 -0
  80. package/dist/modules/ai_assistant/index.js +13 -0
  81. package/dist/modules/ai_assistant/index.js.map +7 -0
  82. package/dist/modules/ai_assistant/lib/ai-sdk.js +13 -0
  83. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +7 -0
  84. package/dist/modules/ai_assistant/lib/api-discovery-tools.js +249 -0
  85. package/dist/modules/ai_assistant/lib/api-discovery-tools.js.map +7 -0
  86. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js +177 -0
  87. package/dist/modules/ai_assistant/lib/api-endpoint-index-config.js.map +7 -0
  88. package/dist/modules/ai_assistant/lib/api-endpoint-index.js +210 -0
  89. package/dist/modules/ai_assistant/lib/api-endpoint-index.js.map +7 -0
  90. package/dist/modules/ai_assistant/lib/auth.js +87 -0
  91. package/dist/modules/ai_assistant/lib/auth.js.map +7 -0
  92. package/dist/modules/ai_assistant/lib/chat-config.js +117 -0
  93. package/dist/modules/ai_assistant/lib/chat-config.js.map +7 -0
  94. package/dist/modules/ai_assistant/lib/client-factory.js +60 -0
  95. package/dist/modules/ai_assistant/lib/client-factory.js.map +7 -0
  96. package/dist/modules/ai_assistant/lib/http-server.js +367 -0
  97. package/dist/modules/ai_assistant/lib/http-server.js.map +7 -0
  98. package/dist/modules/ai_assistant/lib/in-process-client.js +126 -0
  99. package/dist/modules/ai_assistant/lib/in-process-client.js.map +7 -0
  100. package/dist/modules/ai_assistant/lib/mcp-client.js +146 -0
  101. package/dist/modules/ai_assistant/lib/mcp-client.js.map +7 -0
  102. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +283 -0
  103. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +7 -0
  104. package/dist/modules/ai_assistant/lib/mcp-server-config.js +160 -0
  105. package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +7 -0
  106. package/dist/modules/ai_assistant/lib/mcp-server.js +156 -0
  107. package/dist/modules/ai_assistant/lib/mcp-server.js.map +7 -0
  108. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js +44 -0
  109. package/dist/modules/ai_assistant/lib/mcp-tool-adapter.js.map +7 -0
  110. package/dist/modules/ai_assistant/lib/opencode-client.js +247 -0
  111. package/dist/modules/ai_assistant/lib/opencode-client.js.map +7 -0
  112. package/dist/modules/ai_assistant/lib/opencode-handlers.js +398 -0
  113. package/dist/modules/ai_assistant/lib/opencode-handlers.js.map +7 -0
  114. package/dist/modules/ai_assistant/lib/schema-utils.js +94 -0
  115. package/dist/modules/ai_assistant/lib/schema-utils.js.map +7 -0
  116. package/dist/modules/ai_assistant/lib/tool-executor.js +55 -0
  117. package/dist/modules/ai_assistant/lib/tool-executor.js.map +7 -0
  118. package/dist/modules/ai_assistant/lib/tool-index-config.js +125 -0
  119. package/dist/modules/ai_assistant/lib/tool-index-config.js.map +7 -0
  120. package/dist/modules/ai_assistant/lib/tool-loader.js +88 -0
  121. package/dist/modules/ai_assistant/lib/tool-loader.js.map +7 -0
  122. package/dist/modules/ai_assistant/lib/tool-registry.js +65 -0
  123. package/dist/modules/ai_assistant/lib/tool-registry.js.map +7 -0
  124. package/dist/modules/ai_assistant/lib/tool-search.js +192 -0
  125. package/dist/modules/ai_assistant/lib/tool-search.js.map +7 -0
  126. package/dist/modules/ai_assistant/lib/types.js +1 -0
  127. package/dist/modules/ai_assistant/lib/types.js.map +7 -0
  128. package/package.json +108 -0
  129. package/src/di.ts +11 -0
  130. package/src/frontend/components/CommandPalette/CommandFooter.tsx +113 -0
  131. package/src/frontend/components/CommandPalette/CommandHeader.tsx +76 -0
  132. package/src/frontend/components/CommandPalette/CommandInput.tsx +50 -0
  133. package/src/frontend/components/CommandPalette/CommandItem.tsx +111 -0
  134. package/src/frontend/components/CommandPalette/CommandPalette.tsx +276 -0
  135. package/src/frontend/components/CommandPalette/CommandPaletteProvider.tsx +60 -0
  136. package/src/frontend/components/CommandPalette/CommandPaletteWrapper.tsx +21 -0
  137. package/src/frontend/components/CommandPalette/DebugPanel.tsx +257 -0
  138. package/src/frontend/components/CommandPalette/MessageBubble.tsx +73 -0
  139. package/src/frontend/components/CommandPalette/ToolCallConfirmation.tsx +130 -0
  140. package/src/frontend/components/CommandPalette/ToolCallDisplay.tsx +57 -0
  141. package/src/frontend/components/CommandPalette/ToolChatPage.tsx +125 -0
  142. package/src/frontend/components/CommandPalette/index.ts +14 -0
  143. package/src/frontend/constants.ts +35 -0
  144. package/src/frontend/hooks/index.ts +5 -0
  145. package/src/frontend/hooks/useCommandPalette.ts +1389 -0
  146. package/src/frontend/hooks/useMcpTools.ts +73 -0
  147. package/src/frontend/hooks/usePageContext.ts +61 -0
  148. package/src/frontend/hooks/useRecentActions.ts +64 -0
  149. package/src/frontend/hooks/useRecentTools.ts +69 -0
  150. package/src/frontend/index.ts +39 -0
  151. package/src/frontend/types.ts +260 -0
  152. package/src/frontend/utils/index.ts +1 -0
  153. package/src/frontend/utils/toolMatcher.ts +127 -0
  154. package/src/index.ts +92 -0
  155. package/src/modules/ai_assistant/acl.ts +10 -0
  156. package/src/modules/ai_assistant/api/chat/route.ts +213 -0
  157. package/src/modules/ai_assistant/api/health/route.ts +30 -0
  158. package/src/modules/ai_assistant/api/route/route.ts +149 -0
  159. package/src/modules/ai_assistant/api/settings/route.ts +73 -0
  160. package/src/modules/ai_assistant/api/tools/execute/route.ts +71 -0
  161. package/src/modules/ai_assistant/api/tools/route.ts +57 -0
  162. package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +26 -0
  163. package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +12 -0
  164. package/src/modules/ai_assistant/cli.ts +233 -0
  165. package/src/modules/ai_assistant/di.ts +9 -0
  166. package/src/modules/ai_assistant/frontend/components/AiAssistantSettingsPageClient.tsx +418 -0
  167. package/src/modules/ai_assistant/index.ts +11 -0
  168. package/src/modules/ai_assistant/lib/ai-sdk.ts +5 -0
  169. package/src/modules/ai_assistant/lib/api-discovery-tools.ts +334 -0
  170. package/src/modules/ai_assistant/lib/api-endpoint-index-config.ts +243 -0
  171. package/src/modules/ai_assistant/lib/api-endpoint-index.ts +381 -0
  172. package/src/modules/ai_assistant/lib/auth.ts +185 -0
  173. package/src/modules/ai_assistant/lib/chat-config.ts +152 -0
  174. package/src/modules/ai_assistant/lib/client-factory.ts +130 -0
  175. package/src/modules/ai_assistant/lib/http-server.ts +498 -0
  176. package/src/modules/ai_assistant/lib/in-process-client.ts +205 -0
  177. package/src/modules/ai_assistant/lib/mcp-client.ts +221 -0
  178. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +373 -0
  179. package/src/modules/ai_assistant/lib/mcp-server-config.ts +287 -0
  180. package/src/modules/ai_assistant/lib/mcp-server.ts +214 -0
  181. package/src/modules/ai_assistant/lib/mcp-tool-adapter.ts +76 -0
  182. package/src/modules/ai_assistant/lib/opencode-client.ts +426 -0
  183. package/src/modules/ai_assistant/lib/opencode-handlers.ts +676 -0
  184. package/src/modules/ai_assistant/lib/schema-utils.ts +142 -0
  185. package/src/modules/ai_assistant/lib/tool-executor.ts +71 -0
  186. package/src/modules/ai_assistant/lib/tool-index-config.ts +178 -0
  187. package/src/modules/ai_assistant/lib/tool-loader.ts +149 -0
  188. package/src/modules/ai_assistant/lib/tool-registry.ts +114 -0
  189. package/src/modules/ai_assistant/lib/tool-search.ts +308 -0
  190. package/src/modules/ai_assistant/lib/types.ts +147 -0
  191. package/test-schema.ts +37 -0
  192. package/tsconfig.json +10 -0
  193. package/watch.mjs +6 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * API Endpoint Index
3
+ *
4
+ * Parses OpenAPI spec and indexes endpoints for discovery via hybrid search.
5
+ */
6
+
7
+ import type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'
8
+ import type { SearchService } from '@open-mercato/search/service'
9
+ import type { IndexableRecord } from '@open-mercato/search/types'
10
+ import {
11
+ API_ENDPOINT_ENTITY_ID,
12
+ GLOBAL_TENANT_ID,
13
+ API_ENDPOINT_SEARCH_CONFIG,
14
+ endpointToIndexableRecord,
15
+ computeEndpointsChecksum,
16
+ } from './api-endpoint-index-config'
17
+
18
+ /**
19
+ * Indexed API endpoint structure
20
+ */
21
+ export interface ApiEndpoint {
22
+ id: string
23
+ operationId: string
24
+ method: string
25
+ path: string
26
+ summary: string
27
+ description: string
28
+ tags: string[]
29
+ requiredFeatures: string[]
30
+ parameters: ApiParameter[]
31
+ requestBodySchema: Record<string, unknown> | null
32
+ deprecated: boolean
33
+ }
34
+
35
+ export interface ApiParameter {
36
+ name: string
37
+ in: 'path' | 'query' | 'header'
38
+ required: boolean
39
+ type: string
40
+ description: string
41
+ }
42
+
43
+ /**
44
+ * Entity type for API endpoints in search index
45
+ * @deprecated Use API_ENDPOINT_ENTITY_ID from api-endpoint-index-config.ts
46
+ */
47
+ export const API_ENDPOINT_ENTITY = API_ENDPOINT_ENTITY_ID
48
+
49
+ /**
50
+ * In-memory cache of parsed endpoints (avoid re-parsing on each request)
51
+ */
52
+ let endpointsCache: ApiEndpoint[] | null = null
53
+ let endpointsByOperationId: Map<string, ApiEndpoint> | null = null
54
+
55
+ /**
56
+ * Get all parsed API endpoints (cached)
57
+ */
58
+ export async function getApiEndpoints(): Promise<ApiEndpoint[]> {
59
+ if (endpointsCache) {
60
+ return endpointsCache
61
+ }
62
+
63
+ endpointsCache = await parseApiEndpoints()
64
+ endpointsByOperationId = new Map(endpointsCache.map((e) => [e.operationId, e]))
65
+
66
+ return endpointsCache
67
+ }
68
+
69
+ /**
70
+ * Get endpoint by operationId
71
+ */
72
+ export async function getEndpointByOperationId(operationId: string): Promise<ApiEndpoint | null> {
73
+ await getApiEndpoints() // Ensure cache is populated
74
+ return endpointsByOperationId?.get(operationId) ?? null
75
+ }
76
+
77
+ /**
78
+ * Parse OpenAPI spec into indexable endpoints
79
+ * Fetches the OpenAPI spec from the running app's /api/docs/openapi endpoint
80
+ */
81
+ async function parseApiEndpoints(): Promise<ApiEndpoint[]> {
82
+ const baseUrl =
83
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
84
+ process.env.NEXT_PUBLIC_APP_URL ||
85
+ process.env.APP_URL ||
86
+ 'http://localhost:3000'
87
+
88
+ const openApiUrl = `${baseUrl}/api/docs/openapi`
89
+
90
+ try {
91
+ console.error(`[API Index] Fetching OpenAPI spec from ${openApiUrl}...`)
92
+ const response = await fetch(openApiUrl)
93
+
94
+ if (!response.ok) {
95
+ console.error(`[API Index] Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`)
96
+ return []
97
+ }
98
+
99
+ const doc = (await response.json()) as OpenApiDocument
100
+ console.error(`[API Index] Successfully fetched OpenAPI spec`)
101
+ return extractEndpoints(doc)
102
+ } catch (error) {
103
+ console.error('[API Index] Could not fetch OpenAPI spec:', error instanceof Error ? error.message : error)
104
+ console.error('[API Index] Make sure the app is running at', baseUrl)
105
+ return []
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Extract endpoints from OpenAPI document
111
+ */
112
+ function extractEndpoints(doc: OpenApiDocument): ApiEndpoint[] {
113
+ const endpoints: ApiEndpoint[] = []
114
+ const validMethods = ['get', 'post', 'put', 'patch', 'delete']
115
+
116
+ if (!doc.paths) {
117
+ return endpoints
118
+ }
119
+
120
+ for (const [path, pathItem] of Object.entries(doc.paths)) {
121
+ if (!pathItem || typeof pathItem !== 'object') continue
122
+
123
+ for (const [method, operation] of Object.entries(pathItem)) {
124
+ if (!validMethods.includes(method.toLowerCase())) continue
125
+ if (!operation || typeof operation !== 'object') continue
126
+
127
+ const op = operation as any
128
+
129
+ // Generate operationId if not present
130
+ const operationId = op.operationId || generateOperationId(path, method)
131
+
132
+ const endpoint: ApiEndpoint = {
133
+ id: operationId,
134
+ operationId,
135
+ method: method.toUpperCase(),
136
+ path,
137
+ summary: op.summary || '',
138
+ description: op.description || op.summary || `${method.toUpperCase()} ${path}`,
139
+ tags: op.tags || [],
140
+ requiredFeatures: op['x-require-features'] || [],
141
+ deprecated: op.deprecated || false,
142
+ parameters: extractParameters(op.parameters || []),
143
+ requestBodySchema: extractRequestBodySchema(op.requestBody, doc.components?.schemas),
144
+ }
145
+
146
+ endpoints.push(endpoint)
147
+ }
148
+ }
149
+
150
+ console.error(`[API Index] Parsed ${endpoints.length} endpoints from OpenAPI spec`)
151
+ return endpoints
152
+ }
153
+
154
+ /**
155
+ * Generate operationId from path and method
156
+ */
157
+ function generateOperationId(path: string, method: string): string {
158
+ const pathParts = path
159
+ .replace(/^\//, '')
160
+ .replace(/\{([^}]+)\}/g, 'by_$1')
161
+ .split('/')
162
+ .filter(Boolean)
163
+ .join('_')
164
+
165
+ return `${method.toLowerCase()}_${pathParts}`
166
+ }
167
+
168
+ /**
169
+ * Extract parameter info
170
+ */
171
+ function extractParameters(params: any[]): ApiParameter[] {
172
+ return params
173
+ .filter((p) => p.in === 'path' || p.in === 'query')
174
+ .map((p) => ({
175
+ name: p.name,
176
+ in: p.in,
177
+ required: p.required ?? false,
178
+ type: p.schema?.type || 'string',
179
+ description: p.description || '',
180
+ }))
181
+ }
182
+
183
+ /**
184
+ * Extract request body schema (simplified)
185
+ */
186
+ function extractRequestBodySchema(
187
+ requestBody: any,
188
+ schemas?: Record<string, any>
189
+ ): Record<string, unknown> | null {
190
+ if (!requestBody?.content?.['application/json']?.schema) {
191
+ return null
192
+ }
193
+
194
+ const schema = requestBody.content['application/json'].schema
195
+
196
+ // Resolve $ref if present
197
+ if (schema.$ref && schemas) {
198
+ const refPath = schema.$ref.replace('#/components/schemas/', '')
199
+ return schemas[refPath] || schema
200
+ }
201
+
202
+ return schema
203
+ }
204
+
205
+ /**
206
+ * Checksum from last indexing operation
207
+ */
208
+ let lastIndexChecksum: string | null = null
209
+
210
+ /**
211
+ * Index endpoints for search discovery using hybrid search strategies.
212
+ * Uses checksum-based change detection to avoid unnecessary re-indexing.
213
+ *
214
+ * @param searchService - The search service to use for indexing
215
+ * @param force - Force re-indexing even if checksum hasn't changed
216
+ * @returns Number of endpoints indexed
217
+ */
218
+ export async function indexApiEndpoints(
219
+ searchService: SearchService,
220
+ force = false
221
+ ): Promise<number> {
222
+ const endpoints = await getApiEndpoints()
223
+
224
+ if (endpoints.length === 0) {
225
+ console.error('[API Index] No endpoints to index')
226
+ return 0
227
+ }
228
+
229
+ // Compute checksum to detect changes
230
+ const checksum = computeEndpointsChecksum(
231
+ endpoints.map((e) => ({ operationId: e.operationId, method: e.method, path: e.path }))
232
+ )
233
+
234
+ // Skip if checksum matches and not forced
235
+ if (!force && lastIndexChecksum === checksum) {
236
+ console.error(`[API Index] Skipping indexing - ${endpoints.length} endpoints unchanged`)
237
+ return 0
238
+ }
239
+
240
+ // Convert to indexable records using the proper format
241
+ const records: IndexableRecord[] = endpoints.map((endpoint) =>
242
+ endpointToIndexableRecord(endpoint)
243
+ )
244
+
245
+ try {
246
+ console.error(`[API Index] Starting bulk index of ${records.length} endpoints...`)
247
+ // Bulk index using all available strategies (fulltext + vector)
248
+ // Use Promise.race with timeout to prevent hanging
249
+ const timeoutMs = 60000 // 60 second timeout
250
+ const indexPromise = searchService.bulkIndex(records)
251
+ const timeoutPromise = new Promise<never>((_, reject) =>
252
+ setTimeout(() => reject(new Error(`Bulk index timed out after ${timeoutMs}ms`)), timeoutMs)
253
+ )
254
+
255
+ await Promise.race([indexPromise, timeoutPromise])
256
+ lastIndexChecksum = checksum
257
+ console.error(`[API Index] Indexed ${records.length} API endpoints for hybrid search`)
258
+ return records.length
259
+ } catch (error) {
260
+ console.error('[API Index] Failed to index endpoints:', error)
261
+ // Still return the count - some strategies may have succeeded
262
+ lastIndexChecksum = checksum
263
+ return records.length
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Build searchable content from endpoint
269
+ */
270
+ function buildSearchableContent(endpoint: ApiEndpoint): string {
271
+ const parts = [
272
+ endpoint.operationId,
273
+ endpoint.method,
274
+ endpoint.path,
275
+ endpoint.summary,
276
+ endpoint.description,
277
+ ...endpoint.tags,
278
+ ...endpoint.parameters.map((p) => `${p.name} ${p.description}`),
279
+ ]
280
+
281
+ return parts.filter(Boolean).join(' ')
282
+ }
283
+
284
+ /**
285
+ * Search endpoints using hybrid search (fulltext + vector).
286
+ * Falls back to in-memory search if search service is not available.
287
+ */
288
+ export async function searchEndpoints(
289
+ searchService: SearchService | null,
290
+ query: string,
291
+ options: { limit?: number; method?: string } = {}
292
+ ): Promise<ApiEndpoint[]> {
293
+ const { limit = API_ENDPOINT_SEARCH_CONFIG.defaultLimit, method } = options
294
+
295
+ // Ensure endpoints are loaded
296
+ await getApiEndpoints()
297
+
298
+ // Try hybrid search first if search service is available
299
+ if (searchService) {
300
+ try {
301
+ // Use hybrid search (fulltext + vector)
302
+ const results = await searchService.search(query, {
303
+ tenantId: GLOBAL_TENANT_ID,
304
+ organizationId: null,
305
+ entityTypes: [API_ENDPOINT_ENTITY_ID],
306
+ limit: limit * 2, // Get extra to account for filtering
307
+ })
308
+
309
+ // Map search results back to ApiEndpoint objects
310
+ const endpoints: ApiEndpoint[] = []
311
+ for (const result of results) {
312
+ if (endpoints.length >= limit) break
313
+
314
+ const endpoint = endpointsByOperationId?.get(result.recordId)
315
+ if (endpoint) {
316
+ // Apply method filter if not handled by search
317
+ if (method && endpoint.method !== method.toUpperCase()) continue
318
+ endpoints.push(endpoint)
319
+ }
320
+ }
321
+
322
+ if (endpoints.length > 0) {
323
+ return endpoints
324
+ }
325
+
326
+ // Fall through to fallback if no results from hybrid search
327
+ console.error('[API Index] No hybrid search results, falling back to in-memory search')
328
+ } catch (error) {
329
+ console.error('[API Index] Hybrid search failed, falling back to in-memory:', error)
330
+ }
331
+ }
332
+
333
+ // Fallback: Simple in-memory text matching
334
+ return searchEndpointsFallback(query, { limit, method })
335
+ }
336
+
337
+ /**
338
+ * Fallback in-memory search when hybrid search is not available.
339
+ */
340
+ function searchEndpointsFallback(
341
+ query: string,
342
+ options: { limit?: number; method?: string } = {}
343
+ ): ApiEndpoint[] {
344
+ const { limit = API_ENDPOINT_SEARCH_CONFIG.defaultLimit, method } = options
345
+
346
+ if (!endpointsCache) {
347
+ return []
348
+ }
349
+
350
+ const queryLower = query.toLowerCase()
351
+ const queryTerms = queryLower.split(/\s+/).filter(Boolean)
352
+
353
+ let matches = endpointsCache.filter((endpoint) => {
354
+ const content = buildSearchableContent(endpoint).toLowerCase()
355
+ return queryTerms.some((term) => content.includes(term))
356
+ })
357
+
358
+ // Filter by method if specified
359
+ if (method) {
360
+ matches = matches.filter((e) => e.method === method.toUpperCase())
361
+ }
362
+
363
+ // Sort by relevance (number of matching terms)
364
+ matches.sort((a, b) => {
365
+ const aContent = buildSearchableContent(a).toLowerCase()
366
+ const bContent = buildSearchableContent(b).toLowerCase()
367
+ const aScore = queryTerms.filter((t) => aContent.includes(t)).length
368
+ const bScore = queryTerms.filter((t) => bContent.includes(t)).length
369
+ return bScore - aScore
370
+ })
371
+
372
+ return matches.slice(0, limit)
373
+ }
374
+
375
+ /**
376
+ * Clear endpoint cache (for testing)
377
+ */
378
+ export function clearEndpointCache(): void {
379
+ endpointsCache = null
380
+ endpointsByOperationId = null
381
+ }
@@ -0,0 +1,185 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+
4
+ /**
5
+ * Successful authentication result.
6
+ */
7
+ export type McpAuthSuccess = {
8
+ success: true
9
+ keyId: string
10
+ keyName: string
11
+ tenantId: string | null
12
+ organizationId: string | null
13
+ userId: string
14
+ features: string[]
15
+ isSuperAdmin: boolean
16
+ }
17
+
18
+ /**
19
+ * Failed authentication result.
20
+ */
21
+ export type McpAuthFailure = {
22
+ success: false
23
+ error: string
24
+ }
25
+
26
+ /**
27
+ * Result from MCP authentication.
28
+ */
29
+ export type McpAuthResult = McpAuthSuccess | McpAuthFailure
30
+
31
+ /**
32
+ * Authenticate an MCP request using an API key.
33
+ *
34
+ * This function validates the API key secret and loads the associated
35
+ * ACL (features, organizations, super admin status) from the key's roles.
36
+ *
37
+ * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')
38
+ * @param container - Awilix DI container with 'em' and 'rbacService'
39
+ * @returns Authentication result with user context or error
40
+ */
41
+ export async function authenticateMcpRequest(
42
+ apiKeySecret: string,
43
+ container: AwilixContainer
44
+ ): Promise<McpAuthResult> {
45
+ if (!apiKeySecret || typeof apiKeySecret !== 'string') {
46
+ return { success: false, error: 'API key is required' }
47
+ }
48
+
49
+ const trimmedSecret = apiKeySecret.trim()
50
+ if (!trimmedSecret) {
51
+ return { success: false, error: 'API key is required' }
52
+ }
53
+
54
+ if (!trimmedSecret.startsWith('omk_')) {
55
+ return { success: false, error: 'Invalid API key format' }
56
+ }
57
+
58
+ try {
59
+ const em = container.resolve('em') as EntityManager
60
+
61
+ const { findApiKeyBySecret } = await import(
62
+ '@open-mercato/core/modules/api_keys/services/apiKeyService'
63
+ )
64
+
65
+ const apiKey = await findApiKeyBySecret(em, trimmedSecret)
66
+
67
+ if (!apiKey) {
68
+ return { success: false, error: 'Invalid or expired API key' }
69
+ }
70
+
71
+ const userId = `api_key:${apiKey.id}`
72
+
73
+ const rbacService = container.resolve('rbacService') as {
74
+ loadAcl: (
75
+ userId: string,
76
+ scope: { tenantId: string | null; organizationId: string | null }
77
+ ) => Promise<{
78
+ isSuperAdmin: boolean
79
+ features: string[]
80
+ organizations: string[] | null
81
+ }>
82
+ }
83
+
84
+ const acl = await rbacService.loadAcl(userId, {
85
+ tenantId: apiKey.tenantId ?? null,
86
+ organizationId: apiKey.organizationId ?? null,
87
+ })
88
+
89
+ try {
90
+ apiKey.lastUsedAt = new Date()
91
+ await em.persistAndFlush(apiKey)
92
+ } catch {
93
+ // Best-effort update; ignore write failures
94
+ }
95
+
96
+ return {
97
+ success: true,
98
+ keyId: apiKey.id,
99
+ keyName: apiKey.name,
100
+ tenantId: apiKey.tenantId ?? null,
101
+ organizationId: apiKey.organizationId ?? null,
102
+ userId,
103
+ features: acl.features,
104
+ isSuperAdmin: acl.isSuperAdmin,
105
+ }
106
+ } catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error)
108
+ console.error('[MCP Auth] Authentication failed:', message)
109
+ return { success: false, error: 'Authentication failed' }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if user has the required features for a resource.
115
+ *
116
+ * Supports:
117
+ * - Super admin bypass (always returns true)
118
+ * - Direct feature match (e.g., 'customers.view')
119
+ * - Global wildcard ('*' grants all features)
120
+ * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view')
121
+ *
122
+ * @param requiredFeatures - List of features required for access
123
+ * @param userFeatures - List of features the user has
124
+ * @param isSuperAdmin - Whether the user is a super admin
125
+ * @returns True if user has access
126
+ */
127
+ export function hasRequiredFeatures(
128
+ requiredFeatures: string[] | undefined,
129
+ userFeatures: string[],
130
+ isSuperAdmin: boolean
131
+ ): boolean {
132
+ if (isSuperAdmin) return true
133
+ if (!requiredFeatures?.length) return true
134
+
135
+ return requiredFeatures.every((required) => {
136
+ if (userFeatures.includes(required)) return true
137
+ if (userFeatures.includes('*')) return true
138
+
139
+ // Check wildcard patterns (e.g., 'customers.*' grants 'customers.people.view')
140
+ return userFeatures.some((feature) => {
141
+ if (feature.endsWith('.*')) {
142
+ const prefix = feature.slice(0, -2)
143
+ return required.startsWith(prefix + '.')
144
+ }
145
+ return false
146
+ })
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Extract API key from HTTP request headers.
152
+ *
153
+ * Supports two header formats:
154
+ * - x-api-key: <secret>
155
+ * - Authorization: ApiKey <secret>
156
+ *
157
+ * @param headers - Request headers (Map, Headers, or plain object)
158
+ * @returns The API key secret or null if not found
159
+ */
160
+ export function extractApiKeyFromHeaders(
161
+ headers: Headers | Map<string, string> | Record<string, string | undefined>
162
+ ): string | null {
163
+ const getHeader = (name: string): string | null => {
164
+ if (headers instanceof Headers) {
165
+ return headers.get(name)
166
+ }
167
+ if (headers instanceof Map) {
168
+ return headers.get(name) ?? null
169
+ }
170
+ const value = headers[name] ?? headers[name.toLowerCase()]
171
+ return typeof value === 'string' ? value : null
172
+ }
173
+
174
+ const xApiKey = getHeader('x-api-key')?.trim()
175
+ if (xApiKey) {
176
+ return xApiKey
177
+ }
178
+
179
+ const authHeader = getHeader('authorization')?.trim()
180
+ if (authHeader && authHeader.toLowerCase().startsWith('apikey ')) {
181
+ return authHeader.slice(7).trim()
182
+ }
183
+
184
+ return null
185
+ }
@@ -0,0 +1,152 @@
1
+ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
2
+
3
+ // Types
4
+ export type ChatProviderId = 'openai' | 'anthropic' | 'google'
5
+
6
+ export type ChatModelInfo = {
7
+ id: string
8
+ name: string
9
+ contextWindow: number
10
+ }
11
+
12
+ export type ChatProviderInfo = {
13
+ name: string
14
+ envKeyRequired: string
15
+ defaultModel: string
16
+ models: ChatModelInfo[]
17
+ }
18
+
19
+ export type ChatProviderConfig = {
20
+ providerId: ChatProviderId
21
+ model: string
22
+ updatedAt: string
23
+ }
24
+
25
+ // Constants
26
+ export const CHAT_CONFIG_KEY = 'chat_provider'
27
+
28
+ export const CHAT_PROVIDERS: Record<ChatProviderId, ChatProviderInfo> = {
29
+ openai: {
30
+ name: 'OpenAI',
31
+ envKeyRequired: 'OPENAI_API_KEY',
32
+ defaultModel: 'gpt-4o',
33
+ models: [
34
+ { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 },
35
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 },
36
+ { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000 },
37
+ { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385 },
38
+ ],
39
+ },
40
+ anthropic: {
41
+ name: 'Anthropic',
42
+ envKeyRequired: 'ANTHROPIC_API_KEY',
43
+ defaultModel: 'claude-sonnet-4-5-20250929',
44
+ models: [
45
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', contextWindow: 200000 },
46
+ { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', contextWindow: 200000 },
47
+ { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000 },
48
+ { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000 },
49
+ ],
50
+ },
51
+ google: {
52
+ name: 'Google',
53
+ envKeyRequired: 'GOOGLE_GENERATIVE_AI_API_KEY',
54
+ defaultModel: 'gemini-1.5-pro',
55
+ models: [
56
+ { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 2097152 },
57
+ { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1048576 },
58
+ { id: 'gemini-pro', name: 'Gemini Pro', contextWindow: 32000 },
59
+ ],
60
+ },
61
+ }
62
+
63
+ export const DEFAULT_CHAT_CONFIG: Omit<ChatProviderConfig, 'updatedAt'> = {
64
+ providerId: 'openai',
65
+ model: 'gpt-4o',
66
+ }
67
+
68
+ // Provider configuration checks
69
+ export function isProviderConfigured(providerId: ChatProviderId): boolean {
70
+ switch (providerId) {
71
+ case 'openai':
72
+ return Boolean(process.env.OPENAI_API_KEY?.trim())
73
+ case 'anthropic':
74
+ return Boolean(process.env.ANTHROPIC_API_KEY?.trim())
75
+ case 'google':
76
+ return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim())
77
+ default:
78
+ return false
79
+ }
80
+ }
81
+
82
+ export function getConfiguredProviders(): ChatProviderId[] {
83
+ const providers: ChatProviderId[] = []
84
+ const allProviders: ChatProviderId[] = ['openai', 'anthropic', 'google']
85
+ for (const providerId of allProviders) {
86
+ if (isProviderConfigured(providerId)) {
87
+ providers.push(providerId)
88
+ }
89
+ }
90
+ return providers
91
+ }
92
+
93
+ // Config resolution
94
+ type Resolver = {
95
+ resolve: <T = unknown>(name: string) => T
96
+ }
97
+
98
+ export async function resolveChatConfig(
99
+ resolver: Resolver,
100
+ options?: { defaultValue?: ChatProviderConfig | null }
101
+ ): Promise<ChatProviderConfig | null> {
102
+ const fallback = options?.defaultValue ?? null
103
+ let service: ModuleConfigService
104
+ try {
105
+ service = resolver.resolve<ModuleConfigService>('moduleConfigService')
106
+ } catch {
107
+ return fallback
108
+ }
109
+ try {
110
+ const value = await service.getValue<ChatProviderConfig>('ai_assistant', CHAT_CONFIG_KEY, { defaultValue: fallback })
111
+ return value
112
+ } catch {
113
+ return fallback
114
+ }
115
+ }
116
+
117
+ export async function saveChatConfig(
118
+ resolver: Resolver,
119
+ config: Omit<ChatProviderConfig, 'updatedAt'>
120
+ ): Promise<ChatProviderConfig> {
121
+ let service: ModuleConfigService
122
+ try {
123
+ service = resolver.resolve<ModuleConfigService>('moduleConfigService')
124
+ } catch {
125
+ throw new Error('Configuration service unavailable')
126
+ }
127
+ const fullConfig: ChatProviderConfig = {
128
+ ...config,
129
+ updatedAt: new Date().toISOString(),
130
+ }
131
+ await service.setValue('ai_assistant', CHAT_CONFIG_KEY, fullConfig)
132
+ return fullConfig
133
+ }
134
+
135
+ export function createDefaultConfig(): ChatProviderConfig {
136
+ return { ...DEFAULT_CHAT_CONFIG, updatedAt: new Date().toISOString() }
137
+ }
138
+
139
+ // Get model info by ID
140
+ export function getModelInfo(providerId: ChatProviderId, modelId: string): ChatModelInfo | null {
141
+ const provider = CHAT_PROVIDERS[providerId]
142
+ if (!provider) return null
143
+ return provider.models.find((m) => m.id === modelId) ?? null
144
+ }
145
+
146
+ // Format context window for display
147
+ export function formatContextWindow(contextWindow: number): string {
148
+ if (contextWindow >= 1000000) {
149
+ return `${(contextWindow / 1000000).toFixed(1)}M`
150
+ }
151
+ return `${(contextWindow / 1000).toFixed(0)}K`
152
+ }