@open-mercato/ai-assistant 0.6.6-develop.6540.1.ae856df1ec → 0.6.6-develop.6541.1.c6f8fa5d2b
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/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/user-role-ids.js +18 -0
- package/dist/modules/ai_assistant/lib/user-role-ids.js.map +7 -0
- package/package.json +6 -6
- 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__/mcp-ensure-api-key.test.ts +182 -0
- package/src/modules/ai_assistant/lib/mcp-ensure-api-key.ts +132 -0
- package/src/modules/ai_assistant/lib/user-role-ids.ts +30 -0
|
@@ -5,9 +5,8 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
5
5
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
6
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
7
|
import { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
8
|
-
import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
9
|
-
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
8
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { getUserRoleIds } from '../../lib/user-role-ids'
|
|
11
10
|
|
|
12
11
|
const logger = createLogger('ai_assistant')
|
|
13
12
|
|
|
@@ -15,29 +14,6 @@ export const metadata = {
|
|
|
15
14
|
POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
* Resolve the calling user's role ids for the active tenant. Mirrors the
|
|
20
|
-
* `session-key` route so the generated key carries exactly the caller's roles.
|
|
21
|
-
*/
|
|
22
|
-
async function getUserRoleIds(
|
|
23
|
-
em: EntityManager,
|
|
24
|
-
userId: string,
|
|
25
|
-
tenantId: string | null,
|
|
26
|
-
): Promise<string[]> {
|
|
27
|
-
if (!tenantId) return []
|
|
28
|
-
const links = await findWithDecryption(
|
|
29
|
-
em,
|
|
30
|
-
UserRole,
|
|
31
|
-
{ user: userId as any, role: { tenantId } } as any,
|
|
32
|
-
{ populate: ['role'] },
|
|
33
|
-
{ tenantId, organizationId: null },
|
|
34
|
-
)
|
|
35
|
-
const linkList = Array.isArray(links) ? links : []
|
|
36
|
-
return linkList
|
|
37
|
-
.map((link) => (link.role as any)?.id)
|
|
38
|
-
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
17
|
const bodySchema = z.object({
|
|
42
18
|
name: z.string().min(1).max(120).optional(),
|
|
43
19
|
description: z.string().max(1000).optional().nullable(),
|
|
@@ -8,9 +8,8 @@ import {
|
|
|
8
8
|
generateSessionToken,
|
|
9
9
|
createSessionApiKey,
|
|
10
10
|
} from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
11
|
-
import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
12
|
-
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
13
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
12
|
+
import { getUserRoleIds } from '../../lib/user-role-ids'
|
|
14
13
|
|
|
15
14
|
const logger = createLogger('ai_assistant')
|
|
16
15
|
|
|
@@ -20,29 +19,6 @@ export const metadata = {
|
|
|
20
19
|
|
|
21
20
|
const SESSION_TTL_MINUTES = 120 // 2 hours
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
* Get user's role IDs from the database.
|
|
25
|
-
*/
|
|
26
|
-
async function getUserRoleIds(
|
|
27
|
-
em: EntityManager,
|
|
28
|
-
userId: string,
|
|
29
|
-
tenantId: string | null
|
|
30
|
-
): Promise<string[]> {
|
|
31
|
-
if (!tenantId) return []
|
|
32
|
-
|
|
33
|
-
const links = await findWithDecryption(
|
|
34
|
-
em,
|
|
35
|
-
UserRole,
|
|
36
|
-
{ user: userId as any, role: { tenantId } } as any,
|
|
37
|
-
{ populate: ['role'] },
|
|
38
|
-
{ tenantId, organizationId: null },
|
|
39
|
-
)
|
|
40
|
-
const linkList = Array.isArray(links) ? links : []
|
|
41
|
-
return linkList
|
|
42
|
-
.map((l) => (l.role as any)?.id)
|
|
43
|
-
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
22
|
/**
|
|
47
23
|
* POST /api/ai_assistant/session-key
|
|
48
24
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
1
2
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
3
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
3
4
|
import { z } from 'zod'
|
|
@@ -6,6 +7,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
6
7
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
8
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
8
9
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
10
|
+
import { findApiKeyBySecret } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
9
11
|
import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
|
|
10
12
|
import { joinProviderModel } from '@open-mercato/shared/lib/ai/model-id'
|
|
11
13
|
import {
|
|
@@ -170,8 +172,26 @@ export async function GET(req: NextRequest) {
|
|
|
170
172
|
? getOpenCodeProviderConfiguredEnvKey(fallbackOpenCodeProviderId)
|
|
171
173
|
: null
|
|
172
174
|
|
|
173
|
-
// Check if
|
|
174
|
-
|
|
175
|
+
// Check if the MCP API key is configured (required for MCP authentication):
|
|
176
|
+
// either directly via MCP_SERVER_API_KEY or via the shared key file that
|
|
177
|
+
// the containerized mcp service provisions (MCP_SERVER_API_KEY_FILE).
|
|
178
|
+
// The file variant is validated against the api_keys table — the file
|
|
179
|
+
// outlives DB resets and key deletion, so mere existence would report a
|
|
180
|
+
// green badge while OpenCode -> MCP auth is actually broken.
|
|
181
|
+
const mcpKeyFilePath = process.env.MCP_SERVER_API_KEY_FILE?.trim()
|
|
182
|
+
let mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim()
|
|
183
|
+
if (!mcpKeyConfigured && mcpKeyFilePath && existsSync(mcpKeyFilePath)) {
|
|
184
|
+
try {
|
|
185
|
+
const fileSecret = readFileSync(mcpKeyFilePath, 'utf8').trim()
|
|
186
|
+
if (fileSecret.startsWith('omk_')) {
|
|
187
|
+
const keyContainer = await createRequestContainer()
|
|
188
|
+
const keyEm = keyContainer.resolve<EntityManager>('em')
|
|
189
|
+
mcpKeyConfigured = !!(await findApiKeyBySecret(keyEm, fileSecret))
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
mcpKeyConfigured = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
175
195
|
|
|
176
196
|
// Phase 4a: resolve tenant override row and per-agent resolution matrix
|
|
177
197
|
let tenantOverride: {
|
|
@@ -161,6 +161,47 @@ const mcpDev: ModuleCli = {
|
|
|
161
161
|
},
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
const mcpEnsureApiKey: ModuleCli = {
|
|
165
|
+
command: 'mcp:ensure-api-key',
|
|
166
|
+
async run(rest) {
|
|
167
|
+
const args = parseArgs(rest)
|
|
168
|
+
// A bare `--file` (no value) parses to boolean true — treat it as missing
|
|
169
|
+
// rather than provisioning into a file literally named "true".
|
|
170
|
+
const fileArg = typeof args.file === 'string' ? args.file.trim() : ''
|
|
171
|
+
const filePath =
|
|
172
|
+
fileArg || process.env.MCP_SERVER_API_KEY_FILE?.trim() || process.env.MCP_API_KEY_FILE?.trim() || ''
|
|
173
|
+
if (!filePath) {
|
|
174
|
+
console.error('Usage: mercato ai_assistant mcp:ensure-api-key --file <path> [options]')
|
|
175
|
+
console.error('')
|
|
176
|
+
console.error('Ensures a valid MCP server API key exists in the database and that its')
|
|
177
|
+
console.error('plaintext secret is stored in <path> (the idempotency anchor). The secret')
|
|
178
|
+
console.error('is never printed; consumers such as the OpenCode container read the file.')
|
|
179
|
+
console.error('')
|
|
180
|
+
console.error('Options:')
|
|
181
|
+
console.error(' --file <path> Secret file path (or MCP_SERVER_API_KEY_FILE env)')
|
|
182
|
+
console.error(' --name <name> API key name (default __mcp_server__)')
|
|
183
|
+
console.error(' --email <email> Owner user email (default OM_INIT_SUPERADMIN_EMAIL, then superadmin@acme.com)')
|
|
184
|
+
console.error(' --rotate Force rotation even when the file secret is still valid')
|
|
185
|
+
process.exitCode = 1
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const keyName = typeof args.name === 'string' && args.name.length > 0 ? args.name : undefined
|
|
190
|
+
const ownerEmail = typeof args.email === 'string' && args.email.length > 0 ? args.email : undefined
|
|
191
|
+
const rotate = args.rotate === true || args.rotate === 'true'
|
|
192
|
+
|
|
193
|
+
await ensureBootstrap()
|
|
194
|
+
const container = await createRequestContainer()
|
|
195
|
+
const em = container.resolve<import('@mikro-orm/postgresql').EntityManager>('em')
|
|
196
|
+
const rbac = container.resolve('rbacService') as import('@open-mercato/core/modules/auth/services/rbacService').RbacService
|
|
197
|
+
|
|
198
|
+
const { ensureMcpApiKey } = await import('./lib/mcp-ensure-api-key')
|
|
199
|
+
const result = await ensureMcpApiKey({ em, filePath, keyName, ownerEmail, rotate, rbac })
|
|
200
|
+
const message = result.status === 'valid' ? 'Existing key is valid' : 'New key created'
|
|
201
|
+
console.log(`[mcp:ensure-api-key] ${message} (id=${result.keyId}, prefix=${result.keyPrefix})`)
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
164
205
|
const listTools: ModuleCli = {
|
|
165
206
|
command: 'mcp:list-tools',
|
|
166
207
|
async run(rest) {
|
|
@@ -377,6 +418,7 @@ export default [
|
|
|
377
418
|
mcpServe,
|
|
378
419
|
mcpServeHttp,
|
|
379
420
|
mcpDev,
|
|
421
|
+
mcpEnsureApiKey,
|
|
380
422
|
listTools,
|
|
381
423
|
entityGraph,
|
|
382
424
|
runPendingActionCleanup,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/core/modules/api_keys/services/apiKeyService', () => ({
|
|
6
|
+
createApiKey: jest.fn(),
|
|
7
|
+
deleteApiKey: jest.fn(),
|
|
8
|
+
findApiKeyBySecret: jest.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
12
|
+
findOneWithDecryption: jest.fn(),
|
|
13
|
+
findWithDecryption: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createApiKey,
|
|
18
|
+
deleteApiKey,
|
|
19
|
+
findApiKeyBySecret,
|
|
20
|
+
} from '@open-mercato/core/modules/api_keys/services/apiKeyService'
|
|
21
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
22
|
+
import { ensureMcpApiKey, DEFAULT_MCP_KEY_NAME } from '../mcp-ensure-api-key'
|
|
23
|
+
|
|
24
|
+
const mockedCreateApiKey = createApiKey as jest.Mock
|
|
25
|
+
const mockedDeleteApiKey = deleteApiKey as jest.Mock
|
|
26
|
+
const mockedFindApiKeyBySecret = findApiKeyBySecret as jest.Mock
|
|
27
|
+
const mockedFindOneWithDecryption = findOneWithDecryption as jest.Mock
|
|
28
|
+
const mockedFindWithDecryption = findWithDecryption as jest.Mock
|
|
29
|
+
|
|
30
|
+
const owner = {
|
|
31
|
+
id: 'user-1',
|
|
32
|
+
tenantId: 'tenant-1',
|
|
33
|
+
email: 'superadmin@acme.com',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createEm() {
|
|
37
|
+
return { find: jest.fn().mockResolvedValue([]) } as any
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('ensureMcpApiKey', () => {
|
|
41
|
+
let workDir: string
|
|
42
|
+
let filePath: string
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
jest.clearAllMocks()
|
|
46
|
+
workDir = await fs.mkdtemp(join(tmpdir(), 'mcp-key-test-'))
|
|
47
|
+
filePath = join(workDir, 'shared', 'mcp-api-key')
|
|
48
|
+
mockedFindOneWithDecryption.mockResolvedValue(owner)
|
|
49
|
+
mockedFindWithDecryption.mockResolvedValue([{ role: { id: 'role-1' } }])
|
|
50
|
+
mockedCreateApiKey.mockResolvedValue({
|
|
51
|
+
record: { id: 'key-new', keyPrefix: 'omk_new.' },
|
|
52
|
+
secret: 'omk_new1.aaaabbbbccccddddeeeeffff',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await fs.rm(workDir, { recursive: true, force: true })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns valid without touching the DB when the file secret resolves to a live key of the same name', async () => {
|
|
61
|
+
await fs.mkdir(join(workDir, 'shared'), { recursive: true })
|
|
62
|
+
await fs.writeFile(filePath, 'omk_old1.secret\n')
|
|
63
|
+
mockedFindApiKeyBySecret.mockResolvedValue({
|
|
64
|
+
id: 'key-old',
|
|
65
|
+
name: DEFAULT_MCP_KEY_NAME,
|
|
66
|
+
keyPrefix: 'omk_old1.sec',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const em = createEm()
|
|
70
|
+
const result = await ensureMcpApiKey({ em, filePath })
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({ status: 'valid', keyId: 'key-old', keyPrefix: 'omk_old1.sec' })
|
|
73
|
+
expect(mockedCreateApiKey).not.toHaveBeenCalled()
|
|
74
|
+
expect(mockedDeleteApiKey).not.toHaveBeenCalled()
|
|
75
|
+
expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_old1.secret')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('creates a key owned by the superadmin and writes the secret file when no file exists', async () => {
|
|
79
|
+
const em = createEm()
|
|
80
|
+
const result = await ensureMcpApiKey({ em, filePath })
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ status: 'created', keyId: 'key-new', keyPrefix: 'omk_new.' })
|
|
83
|
+
expect(mockedCreateApiKey).toHaveBeenCalledWith(
|
|
84
|
+
em,
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
name: DEFAULT_MCP_KEY_NAME,
|
|
87
|
+
tenantId: 'tenant-1',
|
|
88
|
+
organizationId: null,
|
|
89
|
+
roles: ['role-1'],
|
|
90
|
+
createdBy: 'user-1',
|
|
91
|
+
}),
|
|
92
|
+
expect.anything(),
|
|
93
|
+
)
|
|
94
|
+
expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_new1.aaaabbbbccccddddeeeeffff')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('resolves the owner with an encrypted-email-aware $or filter', async () => {
|
|
98
|
+
const em = createEm()
|
|
99
|
+
await ensureMcpApiKey({ em, filePath })
|
|
100
|
+
|
|
101
|
+
expect(mockedFindOneWithDecryption).toHaveBeenCalledWith(
|
|
102
|
+
em,
|
|
103
|
+
expect.anything(),
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
deletedAt: null,
|
|
106
|
+
$or: expect.arrayContaining([
|
|
107
|
+
{ email: 'superadmin@acme.com' },
|
|
108
|
+
expect.objectContaining({ emailHash: expect.anything() }),
|
|
109
|
+
]),
|
|
110
|
+
}),
|
|
111
|
+
expect.anything(),
|
|
112
|
+
expect.anything(),
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('scopes stale-key cleanup to the owner tenant', async () => {
|
|
117
|
+
const em = createEm()
|
|
118
|
+
await ensureMcpApiKey({ em, filePath })
|
|
119
|
+
|
|
120
|
+
expect(em.find).toHaveBeenCalledWith(
|
|
121
|
+
expect.anything(),
|
|
122
|
+
expect.objectContaining({ name: DEFAULT_MCP_KEY_NAME, tenantId: 'tenant-1', deletedAt: null }),
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('soft-deletes stale keys with the same name before creating a replacement', async () => {
|
|
127
|
+
await fs.mkdir(join(workDir, 'shared'), { recursive: true })
|
|
128
|
+
await fs.writeFile(filePath, 'omk_stale.secret\n')
|
|
129
|
+
mockedFindApiKeyBySecret.mockResolvedValue(null)
|
|
130
|
+
|
|
131
|
+
const em = createEm()
|
|
132
|
+
em.find.mockResolvedValue([{ id: 'stale-1' }, { id: 'stale-2' }])
|
|
133
|
+
|
|
134
|
+
const result = await ensureMcpApiKey({ em, filePath })
|
|
135
|
+
|
|
136
|
+
expect(result.status).toBe('created')
|
|
137
|
+
expect(mockedDeleteApiKey).toHaveBeenCalledTimes(2)
|
|
138
|
+
expect(mockedDeleteApiKey).toHaveBeenCalledWith(em, 'stale-1', expect.anything())
|
|
139
|
+
expect(mockedDeleteApiKey).toHaveBeenCalledWith(em, 'stale-2', expect.anything())
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('rotates even when the file secret is valid if rotate is set', async () => {
|
|
143
|
+
await fs.mkdir(join(workDir, 'shared'), { recursive: true })
|
|
144
|
+
await fs.writeFile(filePath, 'omk_old1.secret\n')
|
|
145
|
+
mockedFindApiKeyBySecret.mockResolvedValue({
|
|
146
|
+
id: 'key-old',
|
|
147
|
+
name: DEFAULT_MCP_KEY_NAME,
|
|
148
|
+
keyPrefix: 'omk_old1.sec',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const em = createEm()
|
|
152
|
+
const result = await ensureMcpApiKey({ em, filePath, rotate: true })
|
|
153
|
+
|
|
154
|
+
expect(result.status).toBe('created')
|
|
155
|
+
expect(mockedFindApiKeyBySecret).not.toHaveBeenCalled()
|
|
156
|
+
expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_new1.aaaabbbbccccddddeeeeffff')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('rotates when the file secret resolves to a key with a different name', async () => {
|
|
160
|
+
await fs.mkdir(join(workDir, 'shared'), { recursive: true })
|
|
161
|
+
await fs.writeFile(filePath, 'omk_old1.secret\n')
|
|
162
|
+
mockedFindApiKeyBySecret.mockResolvedValue({
|
|
163
|
+
id: 'key-other',
|
|
164
|
+
name: '__session_something__',
|
|
165
|
+
keyPrefix: 'omk_old1.sec',
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const em = createEm()
|
|
169
|
+
const result = await ensureMcpApiKey({ em, filePath })
|
|
170
|
+
|
|
171
|
+
expect(result.status).toBe('created')
|
|
172
|
+
expect(mockedCreateApiKey).toHaveBeenCalled()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('throws a clear error when the owner user does not exist', async () => {
|
|
176
|
+
mockedFindOneWithDecryption.mockResolvedValue(null)
|
|
177
|
+
|
|
178
|
+
const em = createEm()
|
|
179
|
+
await expect(ensureMcpApiKey({ em, filePath })).rejects.toThrow(/owner not found/)
|
|
180
|
+
expect(mockedCreateApiKey).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -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,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
|
+
}
|