@open-mercato/ai-assistant 0.6.6-develop.6551.1.1511bfa890 → 0.6.6

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +7 -20
  3. package/dist/modules/ai_assistant/api/chat/route.js +14 -1
  4. package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
  5. package/dist/modules/ai_assistant/api/mcp-key/route.js +14 -1
  6. package/dist/modules/ai_assistant/api/mcp-key/route.js.map +2 -2
  7. package/dist/modules/ai_assistant/api/session-key/route.js +14 -1
  8. package/dist/modules/ai_assistant/api/session-key/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/settings/route.js +1 -16
  10. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/cli.js +0 -35
  12. package/dist/modules/ai_assistant/cli.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/codemode-tools.js +8 -3
  14. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  15. package/package.json +7 -8
  16. package/src/modules/ai_assistant/api/chat/route.ts +25 -1
  17. package/src/modules/ai_assistant/api/mcp-key/route.ts +25 -1
  18. package/src/modules/ai_assistant/api/session-key/route.ts +25 -1
  19. package/src/modules/ai_assistant/api/settings/route.ts +2 -22
  20. package/src/modules/ai_assistant/cli.ts +0 -42
  21. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +0 -146
  22. package/src/modules/ai_assistant/lib/codemode-tools.ts +11 -6
  23. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js +0 -89
  24. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js.map +0 -7
  25. package/dist/modules/ai_assistant/lib/scope-injection.js +0 -23
  26. package/dist/modules/ai_assistant/lib/scope-injection.js.map +0 -7
  27. package/dist/modules/ai_assistant/lib/user-role-ids.js +0 -18
  28. package/dist/modules/ai_assistant/lib/user-role-ids.js.map +0 -7
  29. package/src/modules/ai_assistant/lib/__tests__/mcp-ensure-api-key.test.ts +0 -182
  30. package/src/modules/ai_assistant/lib/__tests__/scope-injection.test.ts +0 -66
  31. package/src/modules/ai_assistant/lib/mcp-ensure-api-key.ts +0 -132
  32. package/src/modules/ai_assistant/lib/scope-injection.ts +0 -47
  33. package/src/modules/ai_assistant/lib/user-role-ids.ts +0 -30
@@ -1,2 +1,2 @@
1
- [build:ai-assistant] found 220 entry points
1
+ [build:ai-assistant] found 217 entry points
2
2
  [build:ai-assistant] built successfully
package/AGENTS.md CHANGED
@@ -494,7 +494,7 @@ packages/ai-assistant/
494
494
  │ ├── modules/ai_assistant/
495
495
  │ │ ├── index.ts # Module exports
496
496
  │ │ ├── acl.ts # Permission definitions
497
- │ │ ├── cli.ts # CLI commands (mcp:serve, mcp:serve-http, mcp:ensure-api-key)
497
+ │ │ ├── cli.ts # CLI commands (mcp:serve, mcp:serve-http)
498
498
  │ │ ├── di.ts # Module DI container
499
499
  │ │ │
500
500
  │ │ ├── lib/
@@ -811,9 +811,7 @@ Configure via `.mcp.json`:
811
811
  Use for web-based AI chat. Requires two-tier auth: server API key + user session tokens.
812
812
 
813
813
  ```bash
814
- # Host mode: requires a valid omk_ key in MCP_SERVER_API_KEY (.env).
815
- # The containerized fullapp stacks skip this entirely — their mcp service
816
- # self-provisions a key via `mercato ai_assistant mcp:ensure-api-key`.
814
+ # Requires MCP_SERVER_API_KEY in .env
817
815
  yarn mcp:serve
818
816
  ```
819
817
 
@@ -1160,17 +1158,16 @@ console.log('[AI Chat] DIAGNOSTIC - Request received:', {
1160
1158
  Use this tier to validate that requests come from an authorized AI agent (e.g., OpenCode).
1161
1159
 
1162
1160
  ```
1163
- Request → Check x-api-key header → Validate against the api_keys table (findApiKeyBySecret)
1161
+ Request → Check x-api-key header → Compare with MCP_SERVER_API_KEY env var
1164
1162
  ```
1165
1163
 
1166
1164
  | Aspect | MUST rules |
1167
1165
  |--------|------------|
1168
1166
  | **Header** | MUST use `x-api-key` — no other header name |
1169
- | **Value** | MUST be a valid, non-expired `omk_` API-key secret from the `api_keys` table |
1170
- | **Delivery to OpenCode** | `MCP_SERVER_API_KEY` env var when set (wins); otherwise the file at `MCP_SERVER_API_KEY_FILE` — the containerized stacks' `mcp` service provisions it idempotently via `mercato ai_assistant mcp:ensure-api-key` into the shared `mcp_shared` volume |
1171
- | **Configured In** | Generated into `opencode.jsonc` by `docker/opencode/entrypoint.sh` |
1172
- | **Validation** | Prefix-indexed lookup + bcrypt verification (`findApiKeyBySecret`) |
1173
- | **Result** | Grants access to call MCP endpoints; header-only calls resolve ACL context via the key's `createdBy` user |
1167
+ | **Value** | MUST match `MCP_SERVER_API_KEY` environment variable exactly |
1168
+ | **Configured In** | MUST set in `opencode.json` or `opencode.jsonc` |
1169
+ | **Validation** | MUST use constant-time string comparison |
1170
+ | **Result** | Grants access to call MCP endpoints (but no user permissions) |
1174
1171
 
1175
1172
  **Code reference**: `packages/ai-assistant/src/modules/ai_assistant/lib/http-server.ts:370-391`
1176
1173
 
@@ -1469,16 +1466,6 @@ Agents that need multi-step tool loops configure the `loop` block on `AiAgentDef
1469
1466
 
1470
1467
  ## Changelog
1471
1468
 
1472
- ### 2026-07-07 - Containerized MCP server + file-based key delivery
1473
-
1474
- **What changed**:
1475
- - New CLI command `mercato ai_assistant mcp:ensure-api-key --file <path> [--name] [--email] [--rotate]` (`cli.ts` + `lib/mcp-ensure-api-key.ts`): idempotently ensures a valid `__mcp_server__` API key exists (anchored on the plaintext secret file — the DB stores only bcrypt hashes) and rotates it when the file secret no longer resolves. The key is created with `createdBy` = the superadmin user (resolved via the encrypted-email-aware `$or` emailHash lookup) so header-only MCP calls get an ACL context; stale-key cleanup is scoped to the owner's tenant. The secret is never printed or logged.
1476
- - The fullapp compose stacks gain a dedicated `mcp` service (port 3001) that reuses the app image, waits for the app over HTTP, provisions the key into the shared `mcp_shared` volume (world-readable inside the volume — the OpenCode container reads it as a non-root user; the volume boundary is the security boundary), then runs `mcp:serve-http`.
1477
- - `docker/opencode/entrypoint.sh` gains a file fallback: when `MCP_SERVER_API_KEY` is empty and `MCP_SERVER_API_KEY_FILE` is set, it waits for MCP `/health` before reading the file; on timeout or read failure it starts headerless (old degraded behavior) with loud warnings instead of crash-looping.
1478
- - `GET /api/ai_assistant/settings` `mcpKeyConfigured` now also validates a file-delivered key against the `api_keys` table (existence alone would go stale across DB resets).
1479
-
1480
- **Backward compatibility**: additive. With `MCP_SERVER_API_KEY_FILE` unset the OpenCode entrypoint behaves byte-for-byte as before; host-MCP mode (`yarn mcp:serve` + `MCP_SERVER_API_KEY`) is unchanged. Spec: `.ai/specs/2026-07-07-windows-one-command-agentic-dev-environment.md`.
1481
-
1482
1469
  ### 2026-06-24 - MCP dev server loads ai-tools for @app local modules (#3524)
1483
1470
 
1484
1471
  **What changed** (`lib/generated-registry-loader.ts`):
@@ -12,8 +12,9 @@ import {
12
12
  bindOpencodeSessionToApiKey,
13
13
  findApiKeyByOpencodeSessionId
14
14
  } from "@open-mercato/core/modules/api_keys/services/apiKeyService";
15
+ import { UserRole } from "@open-mercato/core/modules/auth/data/entities";
16
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
15
17
  import { checkAiChatRateLimit } from "../../lib/rate-limit.js";
16
- import { getUserRoleIds } from "../../lib/user-role-ids.js";
17
18
  const logger = createLogger("ai_assistant");
18
19
  const CHAT_SYSTEM_INSTRUCTIONS = `
19
20
  You are a helpful business assistant for Open Mercato.
@@ -125,6 +126,18 @@ const openApi = {
125
126
  const metadata = {
126
127
  POST: { requireAuth: true, requireFeatures: ["ai_assistant.view"] }
127
128
  };
129
+ async function getUserRoleIds(em, userId, tenantId) {
130
+ if (!tenantId) return [];
131
+ const links = await findWithDecryption(
132
+ em,
133
+ UserRole,
134
+ { user: userId, role: { tenantId } },
135
+ { populate: ["role"] },
136
+ { tenantId, organizationId: null }
137
+ );
138
+ const linkList = Array.isArray(links) ? links : [];
139
+ return linkList.map((l) => l.role?.id).filter((id) => typeof id === "string" && id.length > 0);
140
+ }
128
141
  async function POST(req) {
129
142
  const auth = await getAuthFromRequest(req);
130
143
  if (!auth) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/ai_assistant/api/chat/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeAuthContext,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n bindOpencodeSessionToApiKey,\n findApiKeyByOpencodeSessionId,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { checkAiChatRateLimit } from '../../lib/rate-limit'\nimport { getUserRoleIds } from '../../lib/user-role-ids'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.\n2. PUT path = collection path. id goes in the BODY, not the URL. Example: PUT /api/customers/companies with { id: '...', name: 'New' }. There are NO /{id} path segments.\n3. Confirm before ANY write. Before POST/PUT/DELETE: present your plan in business language, then STOP and wait for user to say \"yes\". Do NOT execute the write in the same turn.\n4. Maximum 4 tool calls per message. Hard limit is 10.\n\nYou have 2 tools \u2014 both accept a \"code\" parameter with an async JavaScript arrow function.\n\nTOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)\n - spec.findEndpoints(keyword) \u2192 [{ path, methods }]\n - spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }\n - spec.describeEntity(keyword) \u2192 { className, fields, relationships }\n\nTOOL: execute \u2014 make API calls (reads and writes)\n - api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }\n\nCOMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):\n /api/customers/companies \u2014 companies (GET list, POST create, PUT update)\n /api/customers/people \u2014 contacts/people\n /api/customers/deals \u2014 deals/opportunities\n /api/customers/activities \u2014 activities/tasks\n /api/sales/orders \u2014 sales orders\n /api/sales/quotes \u2014 quotes\n /api/sales/invoices \u2014 invoices\n /api/catalog/products \u2014 products\n /api/catalog/categories \u2014 categories\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nRECIPES \u2014 follow EXACTLY for each task type\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nFIND/LIST records (1 call):\n For COMMON PATHS: skip describeEndpoint, go straight to execute.\n 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })\n The \"search\" query param only matches indexed text fields \u2014 it will NOT match concepts like \"Polish\" or \"large\".\n For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.\n For unknown paths: 1 search + 1 execute.\n\nUPDATE a record (3-4 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity\n 2. execute: GET the record \u2192 find it, get its ID\n 3. execute: PUT to the COLLECTION path with id IN THE BODY:\n api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })\n NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.\n\nCREATE a record (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example\n 2. Ask user for confirmation with the field values\n 3. execute: api.request({ method: 'POST', ...body })\n If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.\n Use the \"example\" from describeEndpoint as your template \u2014 fill in real values.\n Example \u2014 create a quote with line items:\n api.request({ method: 'POST', path: '/api/sales/quotes', body: {\n currencyCode: 'EUR', customerEntityId: '<company-uuid>',\n lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]\n }})\n Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.\n\nCREATE MULTIPLE records (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example\n 2. execute: loop in one call:\n async () => {\n const results = [];\n for (const item of items) {\n results.push(await api.request({ method: 'POST', path: '...', body: item }));\n }\n return results;\n }\n\nDISCOVER (what endpoints/entities exist) (1 call):\n 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nHARD RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.\n- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.\n- NEVER call describeEntity if describeEndpoint already returned relatedEntity.\n- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.\n- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.\n- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to \"enrich\" it.\n- Do NOT write JavaScript filters/regex to match records. Fetch data with a simple api.request() call and use YOUR knowledge to interpret the results.\n- The \"search\" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.\n- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.\n- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.\n- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.\n- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.\n- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.\n\nRESPONSE RULES:\n- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.\n- Never show technical terms, IDs, JSON, or internal reasoning.\n- Present results in clean business language with **bold names** and bullet points.\n- Only ask for confirmation before create/update/delete operations.\n`.trim()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI chat',\n methods: {\n POST: { summary: 'Send message to AI agent via SSE stream' },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (search, execute, context_whoami).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n logger.warn('Failed to write SSE event', { eventType: event.type })\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Resolve a request-scoped EntityManager once \u2014 needed both by the\n // answerQuestion short-circuit (for ownership re-check) and by the\n // streaming branch (for session-token mint + post-`done` binding).\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const opencodeAuth: OpenCodeAuthContext = {\n userId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n // Resolve the question's actual sessionID from OpenCode \u2014 never\n // trust the caller-supplied `answerQuestion.sessionId` blindly.\n const pending = await client.getPendingQuestions()\n const matching = pending.find((q) => q.id === answerQuestion.questionId)\n if (!matching) {\n // Unknown / stale question id \u2014 same opaque response as a\n // foreign-owner mismatch to avoid leaking which case occurred.\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n if (matching.sessionID !== answerQuestion.sessionId) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n // Look up the api_key row bound to this OpenCode session and\n // assert ownership matches the authenticated caller.\n const ownerRow = await findApiKeyByOpencodeSessionId(em, matching.sessionID)\n if (\n !ownerRow ||\n ownerRow.sessionUserId !== opencodeAuth.userId ||\n (ownerRow.tenantId ?? null) !== opencodeAuth.tenantId ||\n (ownerRow.organizationId ?? null) !== opencodeAuth.organizationId\n ) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n // Fail-closed: any failure during the answerQuestion path \u2014 ownership\n // mismatch, OpenCode unreachable, getPendingQuestions throwing,\n // findApiKeyByOpencodeSessionId throwing \u2014 returns the same opaque\n // 403 envelope used for ownership rejection. This matches finding #1's\n // security intent that the answerQuestion short-circuit MUST NOT leak\n // whether the failure was authorization vs backend connectivity vs\n // implementation error. The real reason is captured in server logs.\n logger.error('AI Chat \u2014 Answer error', { err: error })\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n // Bound per-user LLM spend, DB writes (incl. the ephemeral session-key insert\n // below), and SSE buffering. Fail-open so a missing limiter never blocks a turn.\n // Reuses the request container resolved above for the answerQuestion ownership re-check.\n const rateLimited = await checkAiChatRateLimit({\n req,\n container,\n userId: auth.sub,\n tenantId: auth.tenantId,\n })\n if (rateLimited) return rateLimited\n\n const chatStartTime = Date.now()\n logger.info('Chat request', {\n userId: auth.sub,\n sessionId: sessionId ? sessionId.slice(0, 16) : 'new',\n messageChars: lastUserMessage.length,\n })\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n logger.info('Created session token')\n } catch (error) {\n logger.error('AI Chat \u2014 Failed to create session key', { err: error })\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n let toolCallCount = 0\n let lastTokens: { input?: number; output?: number } | undefined\n let resultSessionId: string | undefined\n\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n logger.info('AI Chat \u2014 Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n auth: opencodeAuth,\n em,\n },\n async (event) => {\n // Track usage from stream events\n if (event.type === 'tool-call') toolCallCount++\n if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens\n if (event.type === 'done' && 'sessionId' in event) {\n resultSessionId = event.sessionId\n // First-time binding: when the caller did NOT supply a\n // sessionId (i.e., this is a freshly minted chat) and we\n // successfully created a session token earlier, bind the\n // newly-resolved OpenCode session id to that api_key row so\n // subsequent resumes can be authorized.\n if (!sessionId && sessionToken && event.sessionId) {\n try {\n await bindOpencodeSessionToApiKey(em, sessionToken, event.sessionId)\n } catch (bindErr) {\n // The response stream is already in-flight \u2014 surface the\n // failure to logs without disturbing the SSE pipeline.\n logger.error('AI Chat \u2014 Failed to bind OpenCode session to api_key', { err: bindErr })\n }\n }\n }\n\n await writeSSE(event)\n }\n )\n } catch (error) {\n logger.error('AI Chat \u2014 OpenCode error', { err: error })\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n const durationMs = Date.now() - chatStartTime\n logger.info('Chat complete', {\n userId: auth.sub,\n sessionId: (resultSessionId || sessionId || 'unknown').slice(0, 16),\n durationMs,\n toolCalls: toolCallCount,\n inputTokens: lastTokens?.input || 0,\n outputTokens: lastTokens?.output || 0,\n })\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n logger.error('AI Chat \u2014 Error', { err: error })\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport {\n handleOpenCodeMessageStreaming,\n type OpenCodeAuthContext,\n type OpenCodeStreamEvent,\n} from '../../lib/opencode-handlers'\nimport { createOpenCodeClient } from '../../lib/opencode-client'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n generateSessionToken,\n createSessionApiKey,\n bindOpencodeSessionToApiKey,\n findApiKeyByOpencodeSessionId,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { checkAiChatRateLimit } from '../../lib/rate-limit'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * System instructions injected at the start of new chat sessions.\n * These ensure the AI follows the correct workflow for data operations.\n */\nconst CHAT_SYSTEM_INSTRUCTIONS = `\nYou are a helpful business assistant for Open Mercato.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nABSOLUTE RULES \u2014 FOLLOW THESE OR BE CUT OFF\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n1. READ = GET only. If the user says find/list/show/search/get \u2192 use only GET. NEVER call PUT/POST/DELETE for a read query.\n2. PUT path = collection path. id goes in the BODY, not the URL. Example: PUT /api/customers/companies with { id: '...', name: 'New' }. There are NO /{id} path segments.\n3. Confirm before ANY write. Before POST/PUT/DELETE: present your plan in business language, then STOP and wait for user to say \"yes\". Do NOT execute the write in the same turn.\n4. Maximum 4 tool calls per message. Hard limit is 10.\n\nYou have 2 tools \u2014 both accept a \"code\" parameter with an async JavaScript arrow function.\n\nTOOL: search \u2014 discover endpoints and schemas (READ-ONLY, fast)\n - spec.findEndpoints(keyword) \u2192 [{ path, methods }]\n - spec.describeEndpoint(path, method) \u2192 COMPACT: { requiredFields, optionalFields, nestedCollections, example, relatedEndpoints, relatedEntity }\n - spec.describeEntity(keyword) \u2192 { className, fields, relationships }\n\nTOOL: execute \u2014 make API calls (reads and writes)\n - api.request({ method, path, query?, body? }) \u2192 { success, statusCode, data }\n\nCOMMON API PATHS (use directly \u2014 do NOT call findEndpoints for these):\n /api/customers/companies \u2014 companies (GET list, POST create, PUT update)\n /api/customers/people \u2014 contacts/people\n /api/customers/deals \u2014 deals/opportunities\n /api/customers/activities \u2014 activities/tasks\n /api/sales/orders \u2014 sales orders\n /api/sales/quotes \u2014 quotes\n /api/sales/invoices \u2014 invoices\n /api/catalog/products \u2014 products\n /api/catalog/categories \u2014 categories\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nRECIPES \u2014 follow EXACTLY for each task type\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nFIND/LIST records (1 call):\n For COMMON PATHS: skip describeEndpoint, go straight to execute.\n 1. execute: api.request({ method: 'GET', path: '/api/<module>/<resource>' })\n The \"search\" query param only matches indexed text fields \u2014 it will NOT match concepts like \"Polish\" or \"large\".\n For conceptual/subjective queries, fetch ALL records and use YOUR reasoning to identify matches from the returned data.\n For unknown paths: 1 search + 1 execute.\n\nUPDATE a record (3-4 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'PUT') \u2192 learn requestBody fields AND relatedEntity\n 2. execute: GET the record \u2192 find it, get its ID\n 3. execute: PUT to the COLLECTION path with id IN THE BODY:\n api.request({ method: 'PUT', path: '/api/<module>/<resource>', body: { id: '<uuid>', ...changes } })\n NOTE: All CRUD endpoints use the COLLECTION path. The id goes in the request BODY, not the URL. There are NO /{id} path segments.\n\nCREATE a record (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 gives requiredFields, optionalFields, nestedCollections, and a working example\n 2. Ask user for confirmation with the field values\n 3. execute: api.request({ method: 'POST', ...body })\n If the endpoint has nestedCollections (like lines), include them INLINE in the body \u2014 do NOT create them separately.\n Use the \"example\" from describeEndpoint as your template \u2014 fill in real values.\n Example \u2014 create a quote with line items:\n api.request({ method: 'POST', path: '/api/sales/quotes', body: {\n currencyCode: 'EUR', customerEntityId: '<company-uuid>',\n lines: [{ currencyCode: 'EUR', quantity: 1, productId: '<product-uuid>', name: 'Product Name', kind: 'product' }]\n }})\n Do NOT create lines separately. Do NOT include id, quoteId, or total fields \u2014 the server generates them.\n\nCREATE MULTIPLE records (2-3 calls):\n 1. search: spec.describeEndpoint('/api/<module>/<resource>', 'POST') \u2192 learn fields + example\n 2. execute: loop in one call:\n async () => {\n const results = [];\n for (const item of items) {\n results.push(await api.request({ method: 'POST', path: '...', body: item }));\n }\n return results;\n }\n\nDISCOVER (what endpoints/entities exist) (1 call):\n 1. search: spec.findEndpoints('<keyword>') or spec.describeEntity('<keyword>')\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nHARD RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- MAXIMUM 4 tool calls per user message. You WILL be cut off after 10.\n- NEVER call findEndpoints or describeEndpoint for COMMON PATHS listed above \u2014 use them directly with execute.\n- NEVER call describeEntity if describeEndpoint already returned relatedEntity.\n- NEVER repeat a search from earlier in the conversation \u2014 reuse previous results.\n- NEVER make N+1 API calls (1 call per record). Fetch a list and reason about the results yourself.\n- When you already have the data you need from a previous call, use it \u2014 do NOT fetch more data to \"enrich\" it.\n- Do NOT write JavaScript filters/regex to match records. Fetch data with a simple api.request() call and use YOUR knowledge to interpret the results.\n- The \"search\" query param is fulltext only \u2014 it won't match nationalities, categories, or subjective criteria. For those, fetch all and reason.\n- describeEndpoint returns a COMPACT summary with requiredFields, optionalFields, and an example. Use the example as your template \u2014 fill in real values and send it.\n- For fields you don't know, OMIT them \u2014 the API uses defaults for optional fields.\n- NEVER try to set computed/total fields (amounts, totals, counts) \u2014 the server calculates them.\n- For updates: describeEndpoint gives you the field names. Go straight to GET + PUT. PUT path is the COLLECTION path, id in BODY.\n- For creates with children (e.g. quote + lines): include children INLINE in the body using the nestedCollections field name.\n\nRESPONSE RULES:\n- Be proactive \u2014 fetch data and present results, don't ask what the user wants to see.\n- Never show technical terms, IDs, JSON, or internal reasoning.\n- Present results in clean business language with **bold names** and bullet points.\n- Only ask for confirmation before create/update/delete operations.\n`.trim()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'AI chat',\n methods: {\n POST: { summary: 'Send message to AI agent via SSE stream' },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * Chat endpoint that routes messages to OpenCode agent.\n * OpenCode connects to MCP server for tool access (search, execute, context_whoami).\n *\n * Emits verbose SSE events for debugging:\n * - thinking: Agent started processing\n * - metadata: Model, tokens, timing info\n * - tool-call: Tool invocation with args\n * - tool-result: Tool response\n * - text: Response text\n * - question: Confirmation question from agent\n * - done: Complete with session ID\n * - error: Error occurred\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const body = await req.json()\n const { messages, sessionId, answerQuestion } = body as {\n messages?: Array<{ role: string; content: string }>\n sessionId?: string\n // For answering a question\n answerQuestion?: {\n questionId: string\n answer: number\n sessionId: string\n }\n }\n\n // Create SSE stream for frontend compatibility\n const encoder = new TextEncoder()\n const stream = new TransformStream()\n const writer = stream.writable.getWriter()\n let writerClosed = false\n\n const writeSSE = async (event: OpenCodeStreamEvent | { type: string; [key: string]: unknown }) => {\n if (writerClosed) return // Guard against writes after close\n try {\n const jsonStr = JSON.stringify(event)\n await writer.write(encoder.encode(`data: ${jsonStr}\\n\\n`))\n } catch (err) {\n // Writer may have been closed by client disconnect\n logger.warn('Failed to write SSE event', { eventType: event.type })\n }\n }\n\n const closeWriter = async () => {\n if (writerClosed) return\n writerClosed = true\n try {\n await writer.close()\n } catch {\n // Already closed\n }\n }\n\n // Resolve a request-scoped EntityManager once \u2014 needed both by the\n // answerQuestion short-circuit (for ownership re-check) and by the\n // streaming branch (for session-token mint + post-`done` binding).\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const opencodeAuth: OpenCodeAuthContext = {\n userId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n }\n\n // Handle question answer - simple JSON response, not SSE\n // The original SSE stream continues and will receive the follow-up response\n if (answerQuestion) {\n try {\n const client = createOpenCodeClient()\n // Resolve the question's actual sessionID from OpenCode \u2014 never\n // trust the caller-supplied `answerQuestion.sessionId` blindly.\n const pending = await client.getPendingQuestions()\n const matching = pending.find((q) => q.id === answerQuestion.questionId)\n if (!matching) {\n // Unknown / stale question id \u2014 same opaque response as a\n // foreign-owner mismatch to avoid leaking which case occurred.\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n if (matching.sessionID !== answerQuestion.sessionId) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n // Look up the api_key row bound to this OpenCode session and\n // assert ownership matches the authenticated caller.\n const ownerRow = await findApiKeyByOpencodeSessionId(em, matching.sessionID)\n if (\n !ownerRow ||\n ownerRow.sessionUserId !== opencodeAuth.userId ||\n (ownerRow.tenantId ?? null) !== opencodeAuth.tenantId ||\n (ownerRow.organizationId ?? null) !== opencodeAuth.organizationId\n ) {\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n\n await client.answerQuestion(answerQuestion.questionId, answerQuestion.answer)\n return NextResponse.json({ success: true })\n } catch (error) {\n // Fail-closed: any failure during the answerQuestion path \u2014 ownership\n // mismatch, OpenCode unreachable, getPendingQuestions throwing,\n // findApiKeyByOpencodeSessionId throwing \u2014 returns the same opaque\n // 403 envelope used for ownership rejection. This matches finding #1's\n // security intent that the answerQuestion short-circuit MUST NOT leak\n // whether the failure was authorization vs backend connectivity vs\n // implementation error. The real reason is captured in server logs.\n logger.error('AI Chat \u2014 Answer error', { err: error })\n return NextResponse.json({ error: 'Session not available' }, { status: 403 })\n }\n }\n\n // Handle regular message\n if (!messages || !Array.isArray(messages)) {\n return NextResponse.json({ error: 'messages array is required' }, { status: 400 })\n }\n\n // Get the latest user message\n const lastUserMessage = messages.filter((m) => m.role === 'user').pop()?.content\n if (!lastUserMessage) {\n return NextResponse.json({ error: 'No user message found' }, { status: 400 })\n }\n\n // Bound per-user LLM spend, DB writes (incl. the ephemeral session-key insert\n // below), and SSE buffering. Fail-open so a missing limiter never blocks a turn.\n // Reuses the request container resolved above for the answerQuestion ownership re-check.\n const rateLimited = await checkAiChatRateLimit({\n req,\n container,\n userId: auth.sub,\n tenantId: auth.tenantId,\n })\n if (rateLimited) return rateLimited\n\n const chatStartTime = Date.now()\n logger.info('Chat request', {\n userId: auth.sub,\n sessionId: sessionId ? sessionId.slice(0, 16) : 'new',\n messageChars: lastUserMessage.length,\n })\n\n // For new sessions, create an ephemeral API key that inherits user permissions\n // The API key secret is encrypted and stored; MCP server recovers it via session token\n let sessionToken: string | null = null\n if (!sessionId) {\n try {\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n sessionToken = generateSessionToken()\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: 120,\n })\n logger.info('Created session token')\n } catch (error) {\n logger.error('AI Chat \u2014 Failed to create session key', { err: error })\n // Continue without session key - tools will use static API key auth\n }\n }\n\n // Build the message to send to OpenCode\n // For NEW sessions: inject system instructions + session token\n // For existing sessions: only inject session token if available\n let messageToSend = ''\n\n // For NEW sessions only, prepend system instructions\n if (!sessionId) {\n messageToSend = `${CHAT_SYSTEM_INSTRUCTIONS}\\n\\n`\n }\n\n // If we have a session token, prepend explicit instructions for the AI to include it in tool calls\n if (sessionToken) {\n messageToSend += `[Session Authorization: ${sessionToken}. Include \"_sessionToken\": \"${sessionToken}\" in EVERY tool call.]\\n\\n`\n }\n\n messageToSend += lastUserMessage\n\n // Process in background - starts AFTER Response is returned so there's a reader for the stream\n ;(async () => {\n let toolCallCount = 0\n let lastTokens: { input?: number; output?: number } | undefined\n let resultSessionId: string | undefined\n\n try {\n // Emit session-authorized event first (if we have a token)\n if (sessionToken) {\n logger.info('AI Chat \u2014 Emitting session-authorized event')\n await writeSSE({\n type: 'session-authorized',\n sessionToken: sessionToken.slice(0, 12) + '...',\n })\n }\n\n // Emit thinking event for UX feedback\n await writeSSE({ type: 'thinking' })\n\n // Use streaming handler that supports questions\n await handleOpenCodeMessageStreaming(\n {\n message: messageToSend,\n sessionId,\n auth: opencodeAuth,\n em,\n },\n async (event) => {\n // Track usage from stream events\n if (event.type === 'tool-call') toolCallCount++\n if (event.type === 'metadata' && 'tokens' in event) lastTokens = event.tokens\n if (event.type === 'done' && 'sessionId' in event) {\n resultSessionId = event.sessionId\n // First-time binding: when the caller did NOT supply a\n // sessionId (i.e., this is a freshly minted chat) and we\n // successfully created a session token earlier, bind the\n // newly-resolved OpenCode session id to that api_key row so\n // subsequent resumes can be authorized.\n if (!sessionId && sessionToken && event.sessionId) {\n try {\n await bindOpencodeSessionToApiKey(em, sessionToken, event.sessionId)\n } catch (bindErr) {\n // The response stream is already in-flight \u2014 surface the\n // failure to logs without disturbing the SSE pipeline.\n logger.error('AI Chat \u2014 Failed to bind OpenCode session to api_key', { err: bindErr })\n }\n }\n }\n\n await writeSSE(event)\n }\n )\n } catch (error) {\n logger.error('AI Chat \u2014 OpenCode error', { err: error })\n await writeSSE({\n type: 'error',\n error: error instanceof Error ? error.message : 'OpenCode request failed',\n })\n } finally {\n const durationMs = Date.now() - chatStartTime\n logger.info('Chat complete', {\n userId: auth.sub,\n sessionId: (resultSessionId || sessionId || 'unknown').slice(0, 16),\n durationMs,\n toolCalls: toolCallCount,\n inputTokens: lastTokens?.input || 0,\n outputTokens: lastTokens?.output || 0,\n })\n await closeWriter()\n }\n })()\n\n return new Response(stream.readable, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n })\n } catch (error) {\n logger.error('AI Chat \u2014 Error', { err: error })\n return NextResponse.json({ error: 'Chat request failed' }, { status: 500 })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,11 +4,24 @@ import { z } from "zod";
4
4
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
5
5
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
6
6
  import { createApiKey } from "@open-mercato/core/modules/api_keys/services/apiKeyService";
7
- import { getUserRoleIds } from "../../lib/user-role-ids.js";
7
+ import { UserRole } from "@open-mercato/core/modules/auth/data/entities";
8
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
8
9
  const logger = createLogger("ai_assistant");
9
10
  const metadata = {
10
11
  POST: { requireAuth: true, requireFeatures: ["api_keys.create"] }
11
12
  };
13
+ async function getUserRoleIds(em, userId, tenantId) {
14
+ if (!tenantId) return [];
15
+ const links = await findWithDecryption(
16
+ em,
17
+ UserRole,
18
+ { user: userId, role: { tenantId } },
19
+ { populate: ["role"] },
20
+ { tenantId, organizationId: null }
21
+ );
22
+ const linkList = Array.isArray(links) ? links : [];
23
+ return linkList.map((link) => link.role?.id).filter((id) => typeof id === "string" && id.length > 0);
24
+ }
12
25
  const bodySchema = z.object({
13
26
  name: z.string().min(1).max(120).optional(),
14
27
  description: z.string().max(1e3).optional().nullable()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/ai_assistant/api/mcp-key/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getUserRoleIds } from '../../lib/user-role-ids'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },\n}\n\nconst bodySchema = z.object({\n name: z.string().min(1).max(120).optional(),\n description: z.string().max(1000).optional().nullable(),\n})\n\n/**\n * POST /api/ai_assistant/mcp-key\n *\n * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a\n * raw `POST /api/api_keys/keys` call \u2014 which assigns no roles unless the client\n * passes them \u2014 this endpoint resolves the **caller's own roles** server-side\n * and attaches them to the key, so the key inherits the current user's ACL.\n * The user cannot escalate: they only ever grant their own roles to their own\n * key.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const raw = await req.json().catch(() => ({}))\n const body = bodySchema.parse(raw ?? {})\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n const { record, secret } = await createApiKey(em, {\n name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,\n description: body.description ?? 'Generated from AI Assistant settings for MCP client',\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n roles: roleIds,\n createdBy: auth.sub,\n })\n\n return NextResponse.json({\n id: record.id,\n name: record.name,\n keyPrefix: record.keyPrefix,\n secret,\n tenantId: record.tenantId ?? null,\n organizationId: record.organizationId ?? null,\n roles: roleIds,\n })\n } catch (error) {\n logger.error('MCP Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create MCP API key' },\n { status: 500 },\n )\n }\n}\n\nconst responseSchema = z.object({\n id: z.string(),\n name: z.string(),\n keyPrefix: z.string(),\n secret: z.string().describe('Full API key value (omk_...). Shown once.'),\n tenantId: z.string().uuid().nullable(),\n organizationId: z.string().uuid().nullable(),\n roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create MCP API key',\n description:\n 'Creates a persistent MCP API key that inherits the calling user\\'s roles, for use in an MCP client `.mcp.json`.',\n methods: {\n POST: {\n summary: 'Generate MCP API key',\n description:\n 'Generates a persistent `omk_` API key scoped to the calling user\\'s tenant/organization and carrying the caller\\'s own roles, so the key has the same ACL as the user.',\n responses: [\n { status: 200, description: 'API key created', schema: responseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create MCP API key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,SAAS,sBAAsB;AAE/B,MAAM,SAAS,aAAa,cAAc;AAEnC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,iBAAiB,EAAE;AAClE;AAEA,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EAC1C,aAAa,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,SAAS;AACxD,CAAC;AAYD,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,KAAK;AACd,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,MAAM,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC7C,UAAM,OAAO,WAAW,MAAM,OAAO,CAAC,CAAC;AAEvC,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAEhD,UAAM,UAAU,MAAM,eAAe,IAAI,KAAK,KAAK,KAAK,QAAQ;AAEhE,UAAM,EAAE,QAAQ,OAAO,IAAI,MAAM,aAAa,IAAI;AAAA,MAChD,MAAM,KAAK,QAAQ,iBAAgB,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,MACxE,aAAa,KAAK,eAAe;AAAA,MACjC,UAAU,KAAK,YAAY;AAAA,MAC3B,gBAAgB,KAAK,SAAS;AAAA,MAC9B,OAAO;AAAA,MACP,WAAW,KAAK;AAAA,IAClB,CAAC;AAED,WAAO,aAAa,KAAK;AAAA,MACvB,IAAI,OAAO;AAAA,MACX,MAAM,OAAO;AAAA,MACb,WAAW,OAAO;AAAA,MAClB;AAAA,MACA,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,MACzC,OAAO;AAAA,IACT,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,wBAAmB,EAAE,KAAK,MAAM,CAAC;AAC9C,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,+BAA+B;AAAA,MACxC,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,IAAI,EAAE,OAAO;AAAA,EACb,MAAM,EAAE,OAAO;AAAA,EACf,WAAW,EAAE,OAAO;AAAA,EACpB,QAAQ,EAAE,OAAO,EAAE,SAAS,2CAA2C;AAAA,EACvE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC3C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,2CAA2C;AACjF,CAAC;AAED,MAAM,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAE3C,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aACE;AAAA,EACF,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aACE;AAAA,MACF,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,eAAe;AAAA,MACxE;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,YAAY;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,YAAY;AAAA,MAClF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },\n}\n\n/**\n * Resolve the calling user's role ids for the active tenant. Mirrors the\n * `session-key` route so the generated key carries exactly the caller's roles.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null,\n): Promise<string[]> {\n if (!tenantId) return []\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((link) => (link.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\nconst bodySchema = z.object({\n name: z.string().min(1).max(120).optional(),\n description: z.string().max(1000).optional().nullable(),\n})\n\n/**\n * POST /api/ai_assistant/mcp-key\n *\n * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a\n * raw `POST /api/api_keys/keys` call \u2014 which assigns no roles unless the client\n * passes them \u2014 this endpoint resolves the **caller's own roles** server-side\n * and attaches them to the key, so the key inherits the current user's ACL.\n * The user cannot escalate: they only ever grant their own roles to their own\n * key.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const raw = await req.json().catch(() => ({}))\n const body = bodySchema.parse(raw ?? {})\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n const { record, secret } = await createApiKey(em, {\n name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,\n description: body.description ?? 'Generated from AI Assistant settings for MCP client',\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n roles: roleIds,\n createdBy: auth.sub,\n })\n\n return NextResponse.json({\n id: record.id,\n name: record.name,\n keyPrefix: record.keyPrefix,\n secret,\n tenantId: record.tenantId ?? null,\n organizationId: record.organizationId ?? null,\n roles: roleIds,\n })\n } catch (error) {\n logger.error('MCP Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create MCP API key' },\n { status: 500 },\n )\n }\n}\n\nconst responseSchema = z.object({\n id: z.string(),\n name: z.string(),\n keyPrefix: z.string(),\n secret: z.string().describe('Full API key value (omk_...). Shown once.'),\n tenantId: z.string().uuid().nullable(),\n organizationId: z.string().uuid().nullable(),\n roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create MCP API key',\n description:\n 'Creates a persistent MCP API key that inherits the calling user\\'s roles, for use in an MCP client `.mcp.json`.',\n methods: {\n POST: {\n summary: 'Generate MCP API key',\n description:\n 'Generates a persistent `omk_` API key scoped to the calling user\\'s tenant/organization and carrying the caller\\'s own roles, so the key has the same ACL as the user.',\n responses: [\n { status: 200, description: 'API key created', schema: responseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create MCP API key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,12 +7,25 @@ import {
7
7
  generateSessionToken,
8
8
  createSessionApiKey
9
9
  } from "@open-mercato/core/modules/api_keys/services/apiKeyService";
10
- import { getUserRoleIds } from "../../lib/user-role-ids.js";
10
+ import { UserRole } from "@open-mercato/core/modules/auth/data/entities";
11
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
11
12
  const logger = createLogger("ai_assistant");
12
13
  const metadata = {
13
14
  POST: { requireAuth: true, requireFeatures: ["ai_assistant.view"] }
14
15
  };
15
16
  const SESSION_TTL_MINUTES = 120;
17
+ async function getUserRoleIds(em, userId, tenantId) {
18
+ if (!tenantId) return [];
19
+ const links = await findWithDecryption(
20
+ em,
21
+ UserRole,
22
+ { user: userId, role: { tenantId } },
23
+ { populate: ["role"] },
24
+ { tenantId, organizationId: null }
25
+ );
26
+ const linkList = Array.isArray(links) ? links : [];
27
+ return linkList.map((l) => l.role?.id).filter((id) => typeof id === "string" && id.length > 0);
28
+ }
16
29
  async function POST(req) {
17
30
  const auth = await getAuthFromRequest(req);
18
31
  if (!auth?.sub) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/ai_assistant/api/session-key/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n generateSessionToken,\n createSessionApiKey,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getUserRoleIds } from '../../lib/user-role-ids'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nconst SESSION_TTL_MINUTES = 120 // 2 hours\n\n/**\n * POST /api/ai_assistant/session-key\n *\n * Creates an ephemeral session API key for programmatic LLM access.\n * The key inherits the user's permissions and expires after 2 hours.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n const sessionToken = generateSessionToken()\n const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)\n\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: SESSION_TTL_MINUTES,\n })\n\n return NextResponse.json({\n sessionToken,\n expiresAt: expiresAt.toISOString(),\n })\n } catch (error) {\n logger.error('Session Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create session key' },\n { status: 500 }\n )\n }\n}\n\n// OpenAPI documentation\nconst responseSchema = z.object({\n sessionToken: z.string().describe('Ephemeral session token for MCP tool calls'),\n expiresAt: z.string().describe('ISO 8601 timestamp when the token expires'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create session API key',\n description:\n 'Creates an ephemeral session API key for programmatic LLM access to MCP tools. The key inherits the authenticated user\\'s permissions and expires after 2 hours.',\n methods: {\n POST: {\n summary: 'Generate session key',\n description:\n 'Generates a new session token that can be included in MCP tool calls via the _sessionToken parameter. The token inherits the calling user\\'s roles and organization context.',\n responses: [\n {\n status: 200,\n description: 'Session key created successfully',\n schema: responseSchema,\n },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create session key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,SAAS,sBAAsB;AAE/B,MAAM,SAAS,aAAa,cAAc;AAEnC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,MAAM,sBAAsB;AAQ5B,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,KAAK;AACd,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAGhD,UAAM,cAAc,MAAM,eAAe,IAAI,KAAK,KAAK,KAAK,QAAQ;AAGpE,UAAM,eAAe,qBAAqB;AAC1C,UAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,sBAAsB,KAAK,GAAI;AAEvE,UAAM,oBAAoB,IAAI;AAAA,MAC5B;AAAA,MACA,QAAQ,KAAK;AAAA,MACb,WAAW;AAAA,MACX,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,MACrB,YAAY;AAAA,IACd,CAAC;AAED,WAAO,aAAa,KAAK;AAAA,MACvB;AAAA,MACA,WAAW,UAAU,YAAY;AAAA,IACnC,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,4BAAuB,EAAE,KAAK,MAAM,CAAC;AAClD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,+BAA+B;AAAA,MACxC,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAGA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,cAAc,EAAE,OAAO,EAAE,SAAS,4CAA4C;AAAA,EAC9E,WAAW,EAAE,OAAO,EAAE,SAAS,2CAA2C;AAC5E,CAAC;AAED,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aACE;AAAA,EACF,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aACE;AAAA,MACF,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,YAAY;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,YAAY;AAAA,MAClF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n generateSessionToken,\n createSessionApiKey,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['ai_assistant.view'] },\n}\n\nconst SESSION_TTL_MINUTES = 120 // 2 hours\n\n/**\n * Get user's role IDs from the database.\n */\nasync function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null\n): Promise<string[]> {\n if (!tenantId) return []\n\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId as any, role: { tenantId } } as any,\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((l) => (l.role as any)?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n\n/**\n * POST /api/ai_assistant/session-key\n *\n * Creates an ephemeral session API key for programmatic LLM access.\n * The key inherits the user's permissions and expires after 2 hours.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n // Get user's role IDs from database\n const userRoleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n // Generate session token and create ephemeral key\n const sessionToken = generateSessionToken()\n const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)\n\n await createSessionApiKey(em, {\n sessionToken,\n userId: auth.sub,\n userRoles: userRoleIds,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n ttlMinutes: SESSION_TTL_MINUTES,\n })\n\n return NextResponse.json({\n sessionToken,\n expiresAt: expiresAt.toISOString(),\n })\n } catch (error) {\n logger.error('Session Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create session key' },\n { status: 500 }\n )\n }\n}\n\n// OpenAPI documentation\nconst responseSchema = z.object({\n sessionToken: z.string().describe('Ephemeral session token for MCP tool calls'),\n expiresAt: z.string().describe('ISO 8601 timestamp when the token expires'),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create session API key',\n description:\n 'Creates an ephemeral session API key for programmatic LLM access to MCP tools. The key inherits the authenticated user\\'s permissions and expires after 2 hours.',\n methods: {\n POST: {\n summary: 'Generate session key',\n description:\n 'Generates a new session token that can be included in MCP tool calls via the _sessionToken parameter. The token inherits the calling user\\'s roles and organization context.',\n responses: [\n {\n status: 200,\n description: 'Session key created successfully',\n schema: responseSchema,\n },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create session key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AAGnC,MAAM,SAAS,aAAa,cAAc;AAEnC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,MAAM,sBAAsB;AAK5B,eAAe,eACb,IACA,QACA,UACmB;AACnB,MAAI,CAAC,SAAU,QAAO,CAAC;AAEvB,QAAM,QAAQ,MAAM;AAAA,IAClB;AAAA,IACA;AAAA,IACA,EAAE,MAAM,QAAe,MAAM,EAAE,SAAS,EAAE;AAAA,IAC1C,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,IACrB,EAAE,UAAU,gBAAgB,KAAK;AAAA,EACnC;AACA,QAAM,WAAW,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC;AACjD,SAAO,SACJ,IAAI,CAAC,MAAO,EAAE,MAAc,EAAE,EAC9B,OAAO,CAAC,OAAqB,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AACzE;AAQA,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,KAAK;AACd,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAGhD,UAAM,cAAc,MAAM,eAAe,IAAI,KAAK,KAAK,KAAK,QAAQ;AAGpE,UAAM,eAAe,qBAAqB;AAC1C,UAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,sBAAsB,KAAK,GAAI;AAEvE,UAAM,oBAAoB,IAAI;AAAA,MAC5B;AAAA,MACA,QAAQ,KAAK;AAAA,MACb,WAAW;AAAA,MACX,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,MACrB,YAAY;AAAA,IACd,CAAC;AAED,WAAO,aAAa,KAAK;AAAA,MACvB;AAAA,MACA,WAAW,UAAU,YAAY;AAAA,IACnC,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,4BAAuB,EAAE,KAAK,MAAM,CAAC;AAClD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,+BAA+B;AAAA,MACxC,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAGA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,cAAc,EAAE,OAAO,EAAE,SAAS,4CAA4C;AAAA,EAC9E,WAAW,EAAE,OAAO,EAAE,SAAS,2CAA2C;AAC5E,CAAC;AAED,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aACE;AAAA,EACF,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aACE;AAAA,MACF,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,YAAY;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,YAAY;AAAA,MAClF;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,10 +1,8 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
2
  import { NextResponse } from "next/server";
4
3
  import { z } from "zod";
5
4
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
6
5
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
7
- import { findApiKeyBySecret } from "@open-mercato/core/modules/api_keys/services/apiKeyService";
8
6
  import { llmProviderRegistry } from "@open-mercato/shared/lib/ai/llm-provider-registry";
9
7
  import { joinProviderModel } from "@open-mercato/shared/lib/ai/model-id";
10
8
  import {
@@ -118,20 +116,7 @@ async function GET(req) {
118
116
  const fallbackModelWithProvider = joinProviderModel(providerId, configuredModelHint);
119
117
  const apiKeyConfigured = registryProvider ? registryProvider.isConfigured(env) : fallbackOpenCodeProvider ? isOpenCodeProviderConfigured(fallbackOpenCodeProviderId) : false;
120
118
  const displayEnvKey = registryProvider ? registryProvider.getConfiguredEnvKey(env) : fallbackOpenCodeProvider ? getOpenCodeProviderConfiguredEnvKey(fallbackOpenCodeProviderId) : null;
121
- const mcpKeyFilePath = process.env.MCP_SERVER_API_KEY_FILE?.trim();
122
- let mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim();
123
- if (!mcpKeyConfigured && mcpKeyFilePath && existsSync(mcpKeyFilePath)) {
124
- try {
125
- const fileSecret = readFileSync(mcpKeyFilePath, "utf8").trim();
126
- if (fileSecret.startsWith("omk_")) {
127
- const keyContainer = await createRequestContainer();
128
- const keyEm = keyContainer.resolve("em");
129
- mcpKeyConfigured = !!await findApiKeyBySecret(keyEm, fileSecret);
130
- }
131
- } catch {
132
- mcpKeyConfigured = false;
133
- }
134
- }
119
+ const mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim();
135
120
  let tenantOverride = null;
136
121
  let agentResolutions = [];
137
122
  let resolvedDefault = null;