@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
@@ -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 MCP_SERVER_API_KEY is configured (required for MCP authentication)
174
- const mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim()
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
- const queryParams: Record<string, string> = { ...query }
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 injection
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 = { ...body }
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