@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
|
@@ -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,
|
|
@@ -477,3 +477,149 @@ describe('mutation call cap (issue #2724)', () => {
|
|
|
477
477
|
expect(counts()).toEqual({ apiCallCount: CODE_MODE_MAX_MUTATION_CALLS + 5, mutationCallCount: 0 })
|
|
478
478
|
})
|
|
479
479
|
})
|
|
480
|
+
|
|
481
|
+
describe('scope enforcement (issue #2658)', () => {
|
|
482
|
+
const listCompaniesEndpoint = {
|
|
483
|
+
id: 'list_companies',
|
|
484
|
+
operationId: 'list_companies',
|
|
485
|
+
method: 'GET',
|
|
486
|
+
path: '/api/customers/companies',
|
|
487
|
+
summary: '',
|
|
488
|
+
description: '',
|
|
489
|
+
tags: [],
|
|
490
|
+
requiredFeatures: [],
|
|
491
|
+
parameters: [],
|
|
492
|
+
requestBodySchema: null,
|
|
493
|
+
deprecated: false,
|
|
494
|
+
}
|
|
495
|
+
const createCompanyEndpoint = {
|
|
496
|
+
id: 'create_company',
|
|
497
|
+
operationId: 'create_company',
|
|
498
|
+
method: 'POST',
|
|
499
|
+
path: '/api/customers/companies',
|
|
500
|
+
summary: '',
|
|
501
|
+
description: '',
|
|
502
|
+
tags: [],
|
|
503
|
+
requiredFeatures: ['customers.companies.create'],
|
|
504
|
+
parameters: [],
|
|
505
|
+
requestBodySchema: null,
|
|
506
|
+
deprecated: false,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
beforeEach(() => {
|
|
510
|
+
mockedGetApiEndpoints.mockReset()
|
|
511
|
+
mockedFetchWithTimeout.mockReset()
|
|
512
|
+
mockedFetchWithTimeout.mockResolvedValue(okResponse())
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
function fetchedUrl(): URL {
|
|
516
|
+
const [url] = mockedFetchWithTimeout.mock.calls[0]
|
|
517
|
+
return new URL(url as string)
|
|
518
|
+
}
|
|
519
|
+
function fetchedBody(): Record<string, unknown> {
|
|
520
|
+
const [, init] = mockedFetchWithTimeout.mock.calls[0]
|
|
521
|
+
return JSON.parse((init as { body: string }).body)
|
|
522
|
+
}
|
|
523
|
+
function creatorContext(scope: Partial<McpToolContext> = {}): McpToolContext {
|
|
524
|
+
return createContext({
|
|
525
|
+
userFeatures: ['ai_assistant.view', 'customers.companies.create'],
|
|
526
|
+
...scope,
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
it('drops AI-supplied query scope when ctx scope is null (fail closed)', async () => {
|
|
531
|
+
mockedGetApiEndpoints.mockResolvedValue([listCompaniesEndpoint])
|
|
532
|
+
const apiRequest = createApiRequestFn(
|
|
533
|
+
createContext({ tenantId: null, organizationId: null }),
|
|
534
|
+
() => {}
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
await apiRequest({
|
|
538
|
+
method: 'GET',
|
|
539
|
+
path: '/api/customers/companies',
|
|
540
|
+
query: { tenantId: 'evil', organizationId: 'evil-org', city: 'NYC' },
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
const params = fetchedUrl().searchParams
|
|
544
|
+
expect(params.get('tenantId')).toBeNull()
|
|
545
|
+
expect(params.get('organizationId')).toBeNull()
|
|
546
|
+
expect(params.get('city')).toBe('NYC')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('overrides AI-supplied query scope with ctx scope', async () => {
|
|
550
|
+
mockedGetApiEndpoints.mockResolvedValue([listCompaniesEndpoint])
|
|
551
|
+
const apiRequest = createApiRequestFn(createContext(), () => {})
|
|
552
|
+
|
|
553
|
+
await apiRequest({
|
|
554
|
+
method: 'GET',
|
|
555
|
+
path: '/api/customers/companies',
|
|
556
|
+
query: { tenantId: 'evil' },
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
expect(fetchedUrl().searchParams.get('tenantId')).toBe('tenant-1')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('strips AI-supplied body scope when ctx scope is null', async () => {
|
|
563
|
+
mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
|
|
564
|
+
const apiRequest = createApiRequestFn(
|
|
565
|
+
creatorContext({ tenantId: null, organizationId: null }),
|
|
566
|
+
() => {}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
await apiRequest({
|
|
570
|
+
method: 'POST',
|
|
571
|
+
path: '/api/customers/companies',
|
|
572
|
+
body: { name: 'Acme', tenantId: 'evil', organizationId: 'evil-org' },
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
const body = fetchedBody()
|
|
576
|
+
expect(body.name).toBe('Acme')
|
|
577
|
+
expect('tenantId' in body).toBe(false)
|
|
578
|
+
expect('organizationId' in body).toBe(false)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('overrides AI-supplied body scope with ctx scope', async () => {
|
|
582
|
+
mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
|
|
583
|
+
const apiRequest = createApiRequestFn(creatorContext(), () => {})
|
|
584
|
+
|
|
585
|
+
await apiRequest({
|
|
586
|
+
method: 'POST',
|
|
587
|
+
path: '/api/customers/companies',
|
|
588
|
+
body: { name: 'Acme', tenantId: 'evil' },
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const body = fetchedBody()
|
|
592
|
+
expect(body.tenantId).toBe('tenant-1')
|
|
593
|
+
expect(body.organizationId).toBe('org-1')
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('enforces query scope on non-GET methods too (not only GET)', async () => {
|
|
597
|
+
mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
|
|
598
|
+
const apiRequest = createApiRequestFn(creatorContext(), () => {})
|
|
599
|
+
|
|
600
|
+
await apiRequest({
|
|
601
|
+
method: 'POST',
|
|
602
|
+
path: '/api/customers/companies',
|
|
603
|
+
query: { tenantId: 'evil' },
|
|
604
|
+
body: {},
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
expect(fetchedUrl().searchParams.get('tenantId')).toBe('tenant-1')
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('drops dangerous prototype-pollution keys from the body', async () => {
|
|
611
|
+
mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
|
|
612
|
+
const apiRequest = createApiRequestFn(creatorContext(), () => {})
|
|
613
|
+
|
|
614
|
+
await apiRequest({
|
|
615
|
+
method: 'POST',
|
|
616
|
+
path: '/api/customers/companies',
|
|
617
|
+
body: { name: 'Acme', ['__proto__']: { polluted: true } } as Record<string, unknown>,
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
const body = fetchedBody()
|
|
621
|
+
expect(body.name).toBe('Acme')
|
|
622
|
+
expect(Object.prototype.hasOwnProperty.call(body, '__proto__')).toBe(false)
|
|
623
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
624
|
+
})
|
|
625
|
+
})
|
|
@@ -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,66 @@
|
|
|
1
|
+
import { applyContextScopeToQuery, applyContextScopeToBody } from '../scope-injection'
|
|
2
|
+
|
|
3
|
+
const NULL_SCOPE = { tenantId: null, organizationId: null }
|
|
4
|
+
const BOUND_SCOPE = { tenantId: 'ctx-tenant', organizationId: 'ctx-org' }
|
|
5
|
+
|
|
6
|
+
describe('applyContextScopeToQuery', () => {
|
|
7
|
+
it('strips AI-supplied scope when ctx scope is null (fail closed)', () => {
|
|
8
|
+
const result = applyContextScopeToQuery(
|
|
9
|
+
{ tenantId: 'evil', organizationId: 'evil-org', city: 'NYC' },
|
|
10
|
+
NULL_SCOPE
|
|
11
|
+
)
|
|
12
|
+
expect('tenantId' in result).toBe(false)
|
|
13
|
+
expect('organizationId' in result).toBe(false)
|
|
14
|
+
expect(result.city).toBe('NYC')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('overrides AI-supplied scope with ctx scope', () => {
|
|
18
|
+
const result = applyContextScopeToQuery({ tenantId: 'evil' }, BOUND_SCOPE)
|
|
19
|
+
expect(result.tenantId).toBe('ctx-tenant')
|
|
20
|
+
expect(result.organizationId).toBe('ctx-org')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('handles undefined query', () => {
|
|
24
|
+
expect(applyContextScopeToQuery(undefined, NULL_SCOPE)).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('drops dangerous prototype-pollution keys', () => {
|
|
28
|
+
const result = applyContextScopeToQuery(
|
|
29
|
+
{ ['__proto__']: 'x', city: 'NYC' } as Record<string, string>,
|
|
30
|
+
NULL_SCOPE
|
|
31
|
+
)
|
|
32
|
+
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false)
|
|
33
|
+
expect(result.city).toBe('NYC')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('applyContextScopeToBody', () => {
|
|
38
|
+
it('strips AI-supplied scope when ctx scope is null', () => {
|
|
39
|
+
const result = applyContextScopeToBody(
|
|
40
|
+
{ name: 'Acme', tenantId: 'evil', organizationId: 'evil-org' },
|
|
41
|
+
NULL_SCOPE
|
|
42
|
+
)
|
|
43
|
+
expect(result.name).toBe('Acme')
|
|
44
|
+
expect('tenantId' in result).toBe(false)
|
|
45
|
+
expect('organizationId' in result).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('applies ctx tenant even when only tenantId is scoped', () => {
|
|
49
|
+
const result = applyContextScopeToBody(
|
|
50
|
+
{ tenantId: 'evil' },
|
|
51
|
+
{ tenantId: 'ctx-tenant', organizationId: null }
|
|
52
|
+
)
|
|
53
|
+
expect(result.tenantId).toBe('ctx-tenant')
|
|
54
|
+
expect('organizationId' in result).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('handles undefined body', () => {
|
|
58
|
+
expect(applyContextScopeToBody(undefined, NULL_SCOPE)).toEqual({})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not mutate the original body object', () => {
|
|
62
|
+
const original = { tenantId: 'evil' }
|
|
63
|
+
applyContextScopeToBody(original, BOUND_SCOPE)
|
|
64
|
+
expect(original.tenantId).toBe('evil')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -16,6 +16,7 @@ import { registerMcpTool } from './tool-registry'
|
|
|
16
16
|
import type { McpToolContext } from './types'
|
|
17
17
|
import { createSandbox } from './sandbox'
|
|
18
18
|
import { truncateResult } from './truncate'
|
|
19
|
+
import { applyContextScopeToQuery, applyContextScopeToBody } from './scope-injection'
|
|
19
20
|
import { hasRequiredFeatures } from './auth'
|
|
20
21
|
import { getApiEndpoints, getRawOpenApiSpec, type ApiEndpoint } from './api-endpoint-index'
|
|
21
22
|
import {
|
|
@@ -792,25 +793,19 @@ export function createApiRequestFn(
|
|
|
792
793
|
|
|
793
794
|
let url = `${baseUrl}${apiPath}`
|
|
794
795
|
|
|
795
|
-
// Build query parameters
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (normalizedMethod === 'GET') {
|
|
799
|
-
if (ctx.tenantId) queryParams.tenantId = ctx.tenantId
|
|
800
|
-
if (ctx.organizationId) queryParams.organizationId = ctx.organizationId
|
|
801
|
-
}
|
|
796
|
+
// Build query parameters — scope is enforced from ctx for every method, not only
|
|
797
|
+
// GET, so AI-supplied tenantId/organizationId can never survive (see scope-injection).
|
|
798
|
+
const queryParams = applyContextScopeToQuery(query, ctx)
|
|
802
799
|
|
|
803
800
|
if (Object.keys(queryParams).length > 0) {
|
|
804
801
|
const separator = url.includes('?') ? '&' : '?'
|
|
805
802
|
url += separator + new URLSearchParams(queryParams).toString()
|
|
806
803
|
}
|
|
807
804
|
|
|
808
|
-
// Build request body with context
|
|
805
|
+
// Build request body with context-enforced scope
|
|
809
806
|
let requestBody: Record<string, unknown> | undefined
|
|
810
807
|
if (['POST', 'PUT', 'PATCH'].includes(normalizedMethod)) {
|
|
811
|
-
requestBody =
|
|
812
|
-
if (ctx.tenantId) requestBody.tenantId = ctx.tenantId
|
|
813
|
-
if (ctx.organizationId) requestBody.organizationId = ctx.organizationId
|
|
808
|
+
requestBody = applyContextScopeToBody(body, ctx)
|
|
814
809
|
}
|
|
815
810
|
|
|
816
811
|
// Build headers
|