@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +20 -7
- package/dist/modules/ai_assistant/api/chat/route.js +1 -14
- package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/mcp-key/route.js +1 -14
- package/dist/modules/ai_assistant/api/mcp-key/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/session-key/route.js +1 -14
- package/dist/modules/ai_assistant/api/session-key/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +16 -1
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/cli.js +35 -0
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +3 -8
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js +89 -0
- package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js.map +7 -0
- package/dist/modules/ai_assistant/lib/scope-injection.js +23 -0
- package/dist/modules/ai_assistant/lib/scope-injection.js.map +7 -0
- package/dist/modules/ai_assistant/lib/user-role-ids.js +18 -0
- package/dist/modules/ai_assistant/lib/user-role-ids.js.map +7 -0
- package/package.json +8 -7
- package/src/modules/ai_assistant/api/chat/route.ts +1 -25
- package/src/modules/ai_assistant/api/mcp-key/route.ts +1 -25
- package/src/modules/ai_assistant/api/session-key/route.ts +1 -25
- package/src/modules/ai_assistant/api/settings/route.ts +22 -2
- package/src/modules/ai_assistant/cli.ts +42 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +146 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-ensure-api-key.test.ts +182 -0
- package/src/modules/ai_assistant/lib/__tests__/scope-injection.test.ts +66 -0
- package/src/modules/ai_assistant/lib/codemode-tools.ts +6 -11
- package/src/modules/ai_assistant/lib/mcp-ensure-api-key.ts +132 -0
- package/src/modules/ai_assistant/lib/scope-injection.ts +47 -0
- 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
|
+
}
|