@open-mercato/ai-assistant 0.6.6 → 0.6.7-develop.6553.1.2ec7246e04

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 +20 -7
  3. package/dist/modules/ai_assistant/api/chat/route.js +1 -14
  4. package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
  5. package/dist/modules/ai_assistant/api/mcp-key/route.js +1 -14
  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 +1 -14
  8. package/dist/modules/ai_assistant/api/session-key/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/settings/route.js +16 -1
  10. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/cli.js +35 -0
  12. package/dist/modules/ai_assistant/cli.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/codemode-tools.js +3 -8
  14. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  15. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js +89 -0
  16. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js.map +7 -0
  17. package/dist/modules/ai_assistant/lib/scope-injection.js +23 -0
  18. package/dist/modules/ai_assistant/lib/scope-injection.js.map +7 -0
  19. package/dist/modules/ai_assistant/lib/user-role-ids.js +18 -0
  20. package/dist/modules/ai_assistant/lib/user-role-ids.js.map +7 -0
  21. package/package.json +8 -7
  22. package/src/modules/ai_assistant/api/chat/route.ts +1 -25
  23. package/src/modules/ai_assistant/api/mcp-key/route.ts +1 -25
  24. package/src/modules/ai_assistant/api/session-key/route.ts +1 -25
  25. package/src/modules/ai_assistant/api/settings/route.ts +22 -2
  26. package/src/modules/ai_assistant/cli.ts +42 -0
  27. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +146 -0
  28. package/src/modules/ai_assistant/lib/__tests__/mcp-ensure-api-key.test.ts +182 -0
  29. package/src/modules/ai_assistant/lib/__tests__/scope-injection.test.ts +66 -0
  30. package/src/modules/ai_assistant/lib/codemode-tools.ts +6 -11
  31. package/src/modules/ai_assistant/lib/mcp-ensure-api-key.ts +132 -0
  32. package/src/modules/ai_assistant/lib/scope-injection.ts +47 -0
  33. package/src/modules/ai_assistant/lib/user-role-ids.ts +30 -0
@@ -0,0 +1,132 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
5
+ import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
+ import {
7
+ createApiKey,
8
+ deleteApiKey,
9
+ findApiKeyBySecret,
10
+ } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
11
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
12
+ import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
13
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
+ import { getUserRoleIds } from './user-role-ids'
15
+
16
+ export const DEFAULT_MCP_KEY_NAME = '__mcp_server__'
17
+ export const DEFAULT_MCP_KEY_OWNER_EMAIL = 'superadmin@acme.com'
18
+
19
+ export type EnsureMcpApiKeyOptions = {
20
+ em: EntityManager
21
+ filePath: string
22
+ keyName?: string
23
+ ownerEmail?: string
24
+ rotate?: boolean
25
+ rbac?: RbacService
26
+ }
27
+
28
+ export type EnsureMcpApiKeyResult = {
29
+ status: 'valid' | 'created'
30
+ keyId: string
31
+ keyPrefix: string
32
+ }
33
+
34
+ async function readSecretFile(filePath: string): Promise<string | null> {
35
+ try {
36
+ const raw = await fs.readFile(filePath, 'utf8')
37
+ const trimmed = raw.trim()
38
+ return trimmed.length > 0 ? trimmed : null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ async function writeSecretFile(filePath: string, secret: string): Promise<void> {
45
+ await fs.mkdir(dirname(filePath), { recursive: true })
46
+ const tmpPath = `${filePath}.tmp`
47
+ // World-readable on purpose: the file lives in a dedicated named volume
48
+ // mounted only into this stack's containers, and the consumer (the OpenCode
49
+ // container) runs as a non-root user that cannot read a root-owned 0600
50
+ // file. The volume boundary, not the file mode, is the security boundary.
51
+ await fs.writeFile(tmpPath, `${secret}\n`, { mode: 0o644 })
52
+ await fs.rename(tmpPath, filePath)
53
+ await fs.chmod(filePath, 0o644)
54
+ }
55
+
56
+ /**
57
+ * Ensure a valid MCP server API key exists and its plaintext secret is stored
58
+ * in `filePath`. The DB only holds a bcrypt hash, so the secret file is the
59
+ * idempotency anchor: when it still resolves to a live key of the expected
60
+ * name, nothing changes. Otherwise stale keys with that name are soft-deleted
61
+ * and a fresh key is created, owned by the superadmin user so header-only MCP
62
+ * calls resolve an ACL context via `createdBy`.
63
+ */
64
+ export async function ensureMcpApiKey(options: EnsureMcpApiKeyOptions): Promise<EnsureMcpApiKeyResult> {
65
+ const keyName = options.keyName?.trim() || DEFAULT_MCP_KEY_NAME
66
+ const existingSecret = await readSecretFile(options.filePath)
67
+
68
+ if (existingSecret && existingSecret.startsWith('omk_') && !options.rotate) {
69
+ const existingKey = await findApiKeyBySecret(options.em, existingSecret)
70
+ if (existingKey && existingKey.name === keyName) {
71
+ return { status: 'valid', keyId: existingKey.id, keyPrefix: existingKey.keyPrefix }
72
+ }
73
+ }
74
+
75
+ const ownerEmail =
76
+ options.ownerEmail?.trim() ||
77
+ process.env.OM_INIT_SUPERADMIN_EMAIL?.trim() ||
78
+ DEFAULT_MCP_KEY_OWNER_EMAIL
79
+ // User.email is encrypted at rest with an email_hash lookup column, and
80
+ // findOneWithDecryption does not rewrite filters — a plaintext { email }
81
+ // match would find nothing once encryption is on. Mirror the $or pattern
82
+ // from AuthService.findUserByEmail so both modes resolve the owner.
83
+ const owner = await findOneWithDecryption(
84
+ options.em,
85
+ User,
86
+ {
87
+ deletedAt: null,
88
+ $or: [
89
+ { email: ownerEmail },
90
+ { emailHash: { $in: emailHashLookupValues(ownerEmail) } },
91
+ ],
92
+ },
93
+ {},
94
+ { tenantId: null, organizationId: null },
95
+ )
96
+ if (!owner) {
97
+ throw new Error(
98
+ `[internal] MCP API key owner not found: no active user with email "${ownerEmail}". ` +
99
+ 'Run "mercato init" first or pass --email pointing at an existing admin user.',
100
+ )
101
+ }
102
+
103
+ const roleIds = await getUserRoleIds(options.em, owner.id, owner.tenantId ?? null)
104
+
105
+ // Scope the cleanup to the owner's tenant: keys with the same name in other
106
+ // tenants belong to other stacks/tenants and must never be revoked here.
107
+ const staleKeys = await options.em.find(ApiKey, {
108
+ name: keyName,
109
+ tenantId: owner.tenantId ?? null,
110
+ deletedAt: null,
111
+ })
112
+ for (const staleKey of staleKeys) {
113
+ await deleteApiKey(options.em, staleKey.id, { rbac: options.rbac })
114
+ }
115
+
116
+ const { record, secret } = await createApiKey(
117
+ options.em,
118
+ {
119
+ name: keyName,
120
+ description: 'MCP server key provisioned by mcp:ensure-api-key',
121
+ tenantId: owner.tenantId ?? null,
122
+ organizationId: null,
123
+ roles: roleIds,
124
+ createdBy: owner.id,
125
+ },
126
+ { rbac: options.rbac },
127
+ )
128
+
129
+ await writeSecretFile(options.filePath, secret)
130
+
131
+ return { status: 'created', keyId: record.id, keyPrefix: record.keyPrefix }
132
+ }
@@ -0,0 +1,47 @@
1
+ import type { McpToolContext } from './types'
2
+
3
+ /**
4
+ * Tenant/org scope for outgoing api.request() calls is derived strictly from the
5
+ * operating context — never from sandbox (AI-authored) code. Any tenantId/organizationId
6
+ * the sandbox placed in query/body is stripped first, then re-applied from ctx only when
7
+ * present. When the ctx scope is null the fields stay removed (fail closed), so AI-supplied
8
+ * scope can never survive regardless of whether the context is tenant-bound.
9
+ *
10
+ * Path safety (traversal, percent-encoded separators, embedded query strings) is handled
11
+ * upstream by normalizeApiRequestPath / isUnsafeApiRequestPath in codemode-tools.
12
+ */
13
+
14
+ const SCOPE_FIELDS = ['tenantId', 'organizationId'] as const
15
+
16
+ // Dropped from any sandbox-supplied payload defensively so a top-level "__proto__" (etc.)
17
+ // can never ride along to a downstream handler that merges request data unsafely.
18
+ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const
19
+
20
+ type ContextScope = Pick<McpToolContext, 'tenantId' | 'organizationId'>
21
+
22
+ export function applyContextScopeToQuery(
23
+ query: Record<string, string> | undefined,
24
+ ctx: ContextScope
25
+ ): Record<string, string> {
26
+ const result: Record<string, string> = { ...query }
27
+ for (const field of DANGEROUS_KEYS) delete result[field]
28
+ for (const field of SCOPE_FIELDS) delete result[field]
29
+ if (ctx.tenantId) result.tenantId = ctx.tenantId
30
+ if (ctx.organizationId) result.organizationId = ctx.organizationId
31
+ return result
32
+ }
33
+
34
+ export function applyContextScopeToBody(
35
+ body: Record<string, unknown> | undefined,
36
+ ctx: ContextScope
37
+ ): Record<string, unknown> {
38
+ // Object spread copies own enumerable props via data-property definition (not the
39
+ // `__proto__` setter), so the result is a plain object; deleting the dangerous keys
40
+ // then removes any that rode in as own properties.
41
+ const result: Record<string, unknown> = { ...body }
42
+ for (const field of DANGEROUS_KEYS) delete result[field]
43
+ for (const field of SCOPE_FIELDS) delete result[field]
44
+ if (ctx.tenantId) result.tenantId = ctx.tenantId
45
+ if (ctx.organizationId) result.organizationId = ctx.organizationId
46
+ return result
47
+ }
@@ -0,0 +1,30 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+
5
+ /**
6
+ * Resolve a user's role ids for the given tenant.
7
+ *
8
+ * Single source for every AI-assistant surface that mints a role-carrying
9
+ * API key (the mcp-key and session-key routes, the chat route's ephemeral
10
+ * session keys, and the mcp:ensure-api-key CLI), so all of those keys
11
+ * inherit exactly the same ACL for the same user.
12
+ */
13
+ export async function getUserRoleIds(
14
+ em: EntityManager,
15
+ userId: string,
16
+ tenantId: string | null,
17
+ ): Promise<string[]> {
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
28
+ .map((link) => link.role?.id)
29
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
30
+ }