@open-mercato/ai-assistant 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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 +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js +146 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +4 -8
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js +119 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js +174 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js +132 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js +68 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js +74 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +6 -5
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js +57 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +127 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +7 -0
- package/dist/modules/ai_assistant/lib/auth.js +2 -11
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +17 -20
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/http-server.js +3 -2
- package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/log-redaction.js +25 -0
- package/dist/modules/ai_assistant/lib/log-redaction.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-test-runner.js +5 -3
- package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
- package/package.json +10 -11
- package/src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts +209 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +6 -18
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts +176 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts +222 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.ts +184 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts +95 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts +115 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +7 -5
- package/src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +27 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +128 -0
- package/src/modules/ai_assistant/lib/__tests__/log-redaction.test.ts +65 -0
- package/src/modules/ai_assistant/lib/__tests__/tool-test-runner-pick-default-tenant.test.ts +70 -0
- package/src/modules/ai_assistant/lib/auth.ts +9 -15
- package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -29
- package/src/modules/ai_assistant/lib/http-server.ts +3 -2
- package/src/modules/ai_assistant/lib/log-redaction.ts +41 -0
- package/src/modules/ai_assistant/lib/tool-test-runner.ts +11 -6
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { McpToolContext } from '../types'
|
|
2
2
|
import { getApiEndpoints } from '../api-endpoint-index'
|
|
3
|
+
import { fetchWithTimeout } from '@open-mercato/shared/lib/http/fetchWithTimeout'
|
|
3
4
|
import {
|
|
4
5
|
authorizeCodeModeApiRequest,
|
|
6
|
+
CODE_MODE_MAX_API_CALLS,
|
|
7
|
+
CODE_MODE_MAX_MUTATION_CALLS,
|
|
5
8
|
CODE_MODE_REQUIRED_FEATURES,
|
|
9
|
+
createApiRequestFn,
|
|
10
|
+
isUnsafeHttpMethod,
|
|
6
11
|
matchApiEndpointPath,
|
|
7
12
|
} from '../codemode-tools'
|
|
8
13
|
|
|
@@ -11,7 +16,46 @@ jest.mock('../api-endpoint-index', () => ({
|
|
|
11
16
|
getRawOpenApiSpec: jest.fn(),
|
|
12
17
|
}))
|
|
13
18
|
|
|
19
|
+
jest.mock('@open-mercato/shared/lib/http/fetchWithTimeout', () => ({
|
|
20
|
+
fetchWithTimeout: jest.fn(),
|
|
21
|
+
resolveTimeoutMs: jest.fn(() => 30000),
|
|
22
|
+
}))
|
|
23
|
+
|
|
14
24
|
const mockedGetApiEndpoints = jest.mocked(getApiEndpoints)
|
|
25
|
+
const mockedFetchWithTimeout = jest.mocked(fetchWithTimeout)
|
|
26
|
+
|
|
27
|
+
function okResponse() {
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
status: 200,
|
|
31
|
+
text: jest.fn().mockResolvedValue('{}'),
|
|
32
|
+
} as unknown as Response
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replicate the execute() handler's per-run counters so the regression test
|
|
37
|
+
* exercises the same accounting that gates real api.request() calls.
|
|
38
|
+
*/
|
|
39
|
+
function createCountingOnCall() {
|
|
40
|
+
let apiCallCount = 0
|
|
41
|
+
let mutationCallCount = 0
|
|
42
|
+
const onCall = (normalizedMethod: string) => {
|
|
43
|
+
apiCallCount++
|
|
44
|
+
if (apiCallCount > CODE_MODE_MAX_API_CALLS) {
|
|
45
|
+
throw new Error(`API call limit exceeded (max ${CODE_MODE_MAX_API_CALLS})`)
|
|
46
|
+
}
|
|
47
|
+
if (isUnsafeHttpMethod(normalizedMethod)) {
|
|
48
|
+
mutationCallCount++
|
|
49
|
+
if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
|
|
50
|
+
throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
onCall,
|
|
56
|
+
counts: () => ({ apiCallCount, mutationCallCount }),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
15
59
|
|
|
16
60
|
type MockRbacService = {
|
|
17
61
|
hasAllFeatures: jest.Mock<boolean, [string[], string[]]>
|
|
@@ -242,3 +286,87 @@ describe('authorizeCodeModeApiRequest', () => {
|
|
|
242
286
|
})
|
|
243
287
|
})
|
|
244
288
|
})
|
|
289
|
+
|
|
290
|
+
describe('mutation call cap (issue #2724)', () => {
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
mockedGetApiEndpoints.mockReset()
|
|
293
|
+
mockedFetchWithTimeout.mockReset()
|
|
294
|
+
mockedFetchWithTimeout.mockResolvedValue(okResponse())
|
|
295
|
+
// Documented, feature-authorized POST endpoint so RBAC always allows the call.
|
|
296
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
297
|
+
{
|
|
298
|
+
id: 'create_company',
|
|
299
|
+
operationId: 'create_company',
|
|
300
|
+
method: 'POST',
|
|
301
|
+
path: '/api/customers/companies',
|
|
302
|
+
summary: '',
|
|
303
|
+
description: '',
|
|
304
|
+
tags: [],
|
|
305
|
+
requiredFeatures: ['customers.companies.create'],
|
|
306
|
+
parameters: [],
|
|
307
|
+
requestBodySchema: null,
|
|
308
|
+
deprecated: false,
|
|
309
|
+
},
|
|
310
|
+
])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
function authorizedContext(): McpToolContext {
|
|
314
|
+
return createContext({
|
|
315
|
+
userFeatures: ['ai_assistant.view', 'customers.companies.create'],
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it('counts a dynamically-built POST method against the mutation cap', async () => {
|
|
320
|
+
const { onCall, counts } = createCountingOnCall()
|
|
321
|
+
const apiRequest = createApiRequestFn(authorizedContext(), onCall)
|
|
322
|
+
|
|
323
|
+
// The method string is built at runtime — the old static regex never saw it.
|
|
324
|
+
const dynamicMethod = 'PO' + 'ST'
|
|
325
|
+
await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
326
|
+
|
|
327
|
+
expect(counts()).toEqual({ apiCallCount: 1, mutationCallCount: 1 })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('refuses once the dynamically-built mutation cap is exceeded', async () => {
|
|
331
|
+
const { onCall } = createCountingOnCall()
|
|
332
|
+
const apiRequest = createApiRequestFn(authorizedContext(), onCall)
|
|
333
|
+
|
|
334
|
+
const dynamicMethod = ['P', 'O', 'S', 'T'].join('')
|
|
335
|
+
for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS; index++) {
|
|
336
|
+
await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await expect(
|
|
340
|
+
apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
341
|
+
).rejects.toThrow(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
342
|
+
|
|
343
|
+
// Authorized mutations actually hit fetch up to the cap; the cap throws before fetch on the overflow call.
|
|
344
|
+
expect(mockedFetchWithTimeout).toHaveBeenCalledTimes(CODE_MODE_MAX_MUTATION_CALLS)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not charge GET reads against the mutation cap', async () => {
|
|
348
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
349
|
+
{
|
|
350
|
+
id: 'list_companies',
|
|
351
|
+
operationId: 'list_companies',
|
|
352
|
+
method: 'GET',
|
|
353
|
+
path: '/api/customers/companies',
|
|
354
|
+
summary: '',
|
|
355
|
+
description: '',
|
|
356
|
+
tags: [],
|
|
357
|
+
requiredFeatures: [],
|
|
358
|
+
parameters: [],
|
|
359
|
+
requestBodySchema: null,
|
|
360
|
+
deprecated: false,
|
|
361
|
+
},
|
|
362
|
+
])
|
|
363
|
+
const { onCall, counts } = createCountingOnCall()
|
|
364
|
+
const apiRequest = createApiRequestFn(createContext(), onCall)
|
|
365
|
+
|
|
366
|
+
for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS + 5; index++) {
|
|
367
|
+
await apiRequest({ method: 'get', path: '/api/customers/companies' })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(counts()).toEqual({ apiCallCount: CODE_MODE_MAX_MUTATION_CALLS + 5, mutationCallCount: 0 })
|
|
371
|
+
})
|
|
372
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { redactSecretForLog, deriveApiKeySessionId } from '../log-redaction'
|
|
3
|
+
|
|
4
|
+
const SESSION_ID_DIGEST_HEX = 16
|
|
5
|
+
|
|
6
|
+
describe('redactSecretForLog', () => {
|
|
7
|
+
it('never emits the full session token', () => {
|
|
8
|
+
const token = `sess_${'a'.repeat(32)}`
|
|
9
|
+
const redacted = redactSecretForLog(token)
|
|
10
|
+
expect(redacted).not.toBe(token)
|
|
11
|
+
expect(redacted).not.toContain(token)
|
|
12
|
+
expect(token.startsWith(redacted.replace(/\.\.\.$/, ''))).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reveals at most a short leading fingerprint and never more than half', () => {
|
|
16
|
+
const token = `sess_${'b'.repeat(32)}`
|
|
17
|
+
const redacted = redactSecretForLog(token)
|
|
18
|
+
const visible = redacted.replace(/\.\.\.$/, '')
|
|
19
|
+
expect(redacted.endsWith('...')).toBe(true)
|
|
20
|
+
expect(visible.length).toBeLessThanOrEqual(12)
|
|
21
|
+
expect(visible.length).toBeLessThanOrEqual(Math.floor(token.length / 2))
|
|
22
|
+
expect(token.startsWith(visible)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not leak short values', () => {
|
|
26
|
+
expect(redactSecretForLog('abcd')).toBe('ab...')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns a placeholder for empty or non-string input', () => {
|
|
30
|
+
expect(redactSecretForLog('')).toBe('<redacted>')
|
|
31
|
+
expect(redactSecretForLog(undefined)).toBe('<redacted>')
|
|
32
|
+
expect(redactSecretForLog(null)).toBe('<redacted>')
|
|
33
|
+
expect(redactSecretForLog(12345)).toBe('<redacted>')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('deriveApiKeySessionId', () => {
|
|
38
|
+
it('does not embed any slice of the secret', () => {
|
|
39
|
+
const secret = 'omk_publicprefix_secretbodythatmustnotleak'
|
|
40
|
+
const sessionId = deriveApiKeySessionId(secret)
|
|
41
|
+
expect(sessionId.startsWith('apikey_')).toBe(true)
|
|
42
|
+
expect(sessionId).not.toContain(secret.slice(0, 16))
|
|
43
|
+
expect(sessionId).not.toContain('secretbody')
|
|
44
|
+
const digestPart = sessionId.slice('apikey_'.length)
|
|
45
|
+
expect(secret).not.toContain(digestPart)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('is stable within a process and shaped as a truncated hex digest', () => {
|
|
49
|
+
const secret = 'omk_test_secret_value'
|
|
50
|
+
const sessionId = deriveApiKeySessionId(secret)
|
|
51
|
+
expect(sessionId).toBe(deriveApiKeySessionId(secret))
|
|
52
|
+
const digestPart = sessionId.slice('apikey_'.length)
|
|
53
|
+
expect(digestPart).toMatch(new RegExp(`^[0-9a-f]{${SESSION_ID_DIGEST_HEX}}$`))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('is keyed, not a recomputable unkeyed sha-256 of the secret', () => {
|
|
57
|
+
const secret = 'omk_test_secret_value'
|
|
58
|
+
const unkeyed = `apikey_${createHash('sha256').update(secret).digest('hex').slice(0, SESSION_ID_DIGEST_HEX)}`
|
|
59
|
+
expect(deriveApiKeySessionId(secret)).not.toBe(unkeyed)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('maps distinct secrets to distinct ids', () => {
|
|
63
|
+
expect(deriveApiKeySessionId('secret-one')).not.toBe(deriveApiKeySessionId('secret-two'))
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import { pickDefaultTenant } from '../tool-test-runner'
|
|
3
|
+
|
|
4
|
+
type ExecuteCall = { sql: string; params?: unknown[] }
|
|
5
|
+
|
|
6
|
+
function makeContainer(
|
|
7
|
+
rowsBySql: (sql: string) => Record<string, unknown>[],
|
|
8
|
+
calls: ExecuteCall[],
|
|
9
|
+
): AwilixContainer {
|
|
10
|
+
const connection = {
|
|
11
|
+
execute: async (sql: string, params?: unknown[]) => {
|
|
12
|
+
calls.push({ sql, params })
|
|
13
|
+
return rowsBySql(sql)
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const em = { getConnection: () => connection }
|
|
17
|
+
return {
|
|
18
|
+
resolve: (token: string) => {
|
|
19
|
+
if (token === 'em') return em
|
|
20
|
+
throw new Error(`[internal] unexpected resolve token: ${token}`)
|
|
21
|
+
},
|
|
22
|
+
} as unknown as AwilixContainer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('pickDefaultTenant SQL safety (#2725)', () => {
|
|
26
|
+
it('binds the tenant id as a query parameter instead of interpolating it', async () => {
|
|
27
|
+
const calls: ExecuteCall[] = []
|
|
28
|
+
const tenantId = '11111111-2222-3333-4444-555555555555'
|
|
29
|
+
const orgId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
30
|
+
const userId = 'ffffffff-0000-1111-2222-333333333333'
|
|
31
|
+
const container = makeContainer((sql) => {
|
|
32
|
+
if (sql.includes('FROM tenants')) return [{ id: tenantId }]
|
|
33
|
+
if (sql.includes('FROM organizations')) return [{ id: orgId }]
|
|
34
|
+
if (sql.includes('FROM users')) return [{ id: userId }]
|
|
35
|
+
return []
|
|
36
|
+
}, calls)
|
|
37
|
+
|
|
38
|
+
const result = await pickDefaultTenant(container)
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({ tenantId, organizationId: orgId, userId })
|
|
41
|
+
|
|
42
|
+
const orgCall = calls.find((call) => call.sql.includes('FROM organizations'))
|
|
43
|
+
const userCall = calls.find((call) => call.sql.includes('FROM users'))
|
|
44
|
+
expect(orgCall).toBeDefined()
|
|
45
|
+
expect(userCall).toBeDefined()
|
|
46
|
+
|
|
47
|
+
// Tenant value flows through bound parameters, never string-interpolated.
|
|
48
|
+
expect(orgCall?.params).toEqual([tenantId])
|
|
49
|
+
expect(userCall?.params).toEqual([tenantId])
|
|
50
|
+
expect(orgCall?.sql).toContain('tenant_id = ?')
|
|
51
|
+
expect(userCall?.sql).toContain('tenant_id = ?')
|
|
52
|
+
|
|
53
|
+
// No call may embed the tenant value or hand-rolled quote-escaping in SQL.
|
|
54
|
+
for (const call of calls) {
|
|
55
|
+
expect(call.sql).not.toContain(tenantId)
|
|
56
|
+
expect(call.sql).not.toContain("''")
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null when no tenant exists without issuing scoped lookups', async () => {
|
|
61
|
+
const calls: ExecuteCall[] = []
|
|
62
|
+
const container = makeContainer(() => [], calls)
|
|
63
|
+
|
|
64
|
+
const result = await pickDefaultTenant(container)
|
|
65
|
+
|
|
66
|
+
expect(result).toBeNull()
|
|
67
|
+
expect(calls).toHaveLength(1)
|
|
68
|
+
expect(calls[0]?.sql).toContain('FROM tenants')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
4
|
+
import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Successful authentication result.
|
|
@@ -118,7 +119,8 @@ export async function authenticateMcpRequest(
|
|
|
118
119
|
* - Super admin bypass (always returns true)
|
|
119
120
|
* - Direct feature match (e.g., 'customers.view')
|
|
120
121
|
* - Global wildcard ('*' grants all features)
|
|
121
|
-
* - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view'
|
|
122
|
+
* - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view' and the
|
|
123
|
+
* bare 'customers' segment itself)
|
|
122
124
|
*
|
|
123
125
|
* @param requiredFeatures - List of features required for access
|
|
124
126
|
* @param userFeatures - List of features the user has
|
|
@@ -140,20 +142,12 @@ export function hasRequiredFeatures(
|
|
|
140
142
|
return rbacService.hasAllFeatures(requiredFeatures, userFeatures)
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
// Fallback for cases without rbacService
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return userFeatures.some((feature) => {
|
|
150
|
-
if (feature.endsWith('.*')) {
|
|
151
|
-
const prefix = feature.slice(0, -2)
|
|
152
|
-
return required.startsWith(prefix + '.')
|
|
153
|
-
}
|
|
154
|
-
return false
|
|
155
|
-
})
|
|
156
|
-
})
|
|
145
|
+
// Fallback for cases without rbacService: delegate to the canonical
|
|
146
|
+
// wildcard-aware matcher so this path stays consistent with
|
|
147
|
+
// RbacService.hasAllFeatures (which uses the same helper). The previous
|
|
148
|
+
// bespoke loop rejected a bare-segment requirement (e.g. 'entities')
|
|
149
|
+
// against an 'entities.*' grant, diverging from the canonical matcher.
|
|
150
|
+
return hasAllFeatures(requiredFeatures, userFeatures)
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
/**
|
|
@@ -550,22 +550,10 @@ function buildEntitySchemas(graph: EntityGraph) {
|
|
|
550
550
|
})
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
/**
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
function detectMutationInCode(code: string): { hasMutation: boolean; methods: string[] } {
|
|
558
|
-
const methods: string[] = []
|
|
559
|
-
const pattern = /method:\s*['"](\w+)['"]/gi
|
|
560
|
-
let match
|
|
561
|
-
while ((match = pattern.exec(code)) !== null) {
|
|
562
|
-
const method = match[1].toUpperCase()
|
|
563
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
564
|
-
methods.push(method)
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
return { hasMutation: methods.length > 0, methods }
|
|
568
|
-
}
|
|
553
|
+
/** Maximum api.request() calls allowed per execute() run, regardless of method. */
|
|
554
|
+
export const CODE_MODE_MAX_API_CALLS = 50
|
|
555
|
+
/** Maximum mutation (non-GET/HEAD/OPTIONS) api.request() calls allowed per execute() run. */
|
|
556
|
+
export const CODE_MODE_MAX_MUTATION_CALLS = 20
|
|
569
557
|
|
|
570
558
|
/**
|
|
571
559
|
* Load and register the two Code Mode tools.
|
|
@@ -701,19 +689,24 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
|
|
|
701
689
|
}
|
|
702
690
|
}
|
|
703
691
|
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
console.error(`[AI Usage] execute: MUTATION DETECTED (${mutationInfo.methods.join(',')}) — capping API calls to ${maxApiCalls}`)
|
|
709
|
-
}
|
|
692
|
+
// Cap API calls for safety. The mutation cap is enforced against the
|
|
693
|
+
// actually-observed HTTP method, not a static scan of the source — so a
|
|
694
|
+
// dynamically-built method (e.g. 'PO' + 'ST') can never escape it.
|
|
695
|
+
const maxApiCalls = CODE_MODE_MAX_API_CALLS
|
|
710
696
|
let apiCallCount = 0
|
|
697
|
+
let mutationCallCount = 0
|
|
711
698
|
|
|
712
|
-
const apiRequestFn = createApiRequestFn(ctx, () => {
|
|
699
|
+
const apiRequestFn = createApiRequestFn(ctx, (normalizedMethod) => {
|
|
713
700
|
apiCallCount++
|
|
714
701
|
if (apiCallCount > maxApiCalls) {
|
|
715
702
|
throw new Error(`API call limit exceeded (max ${maxApiCalls})`)
|
|
716
703
|
}
|
|
704
|
+
if (isUnsafeHttpMethod(normalizedMethod)) {
|
|
705
|
+
mutationCallCount++
|
|
706
|
+
if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
|
|
707
|
+
throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
717
710
|
})
|
|
718
711
|
|
|
719
712
|
const context = {
|
|
@@ -761,9 +754,9 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
|
|
|
761
754
|
/**
|
|
762
755
|
* Create the api.request() function for the execute sandbox.
|
|
763
756
|
*/
|
|
764
|
-
function createApiRequestFn(
|
|
757
|
+
export function createApiRequestFn(
|
|
765
758
|
ctx: McpToolContext,
|
|
766
|
-
onCall: () => void
|
|
759
|
+
onCall: (normalizedMethod: string) => void
|
|
767
760
|
): (params: {
|
|
768
761
|
method: string
|
|
769
762
|
path: string
|
|
@@ -777,11 +770,10 @@ function createApiRequestFn(
|
|
|
777
770
|
'http://localhost:3000'
|
|
778
771
|
|
|
779
772
|
return async (params) => {
|
|
780
|
-
onCall()
|
|
781
|
-
|
|
782
773
|
const { method, path, query, body } = params
|
|
783
774
|
const callStart = Date.now()
|
|
784
|
-
const normalizedMethod = method.toUpperCase()
|
|
775
|
+
const normalizedMethod = String(method ?? '').toUpperCase()
|
|
776
|
+
onCall(normalizedMethod)
|
|
785
777
|
const apiPath = normalizeApiRequestPath(path)
|
|
786
778
|
const authorization = await authorizeCodeModeApiRequest(ctx, normalizedMethod, apiPath)
|
|
787
779
|
|
|
@@ -995,7 +987,7 @@ function isPathParameterSegment(segment: string): boolean {
|
|
|
995
987
|
)
|
|
996
988
|
}
|
|
997
989
|
|
|
998
|
-
function isUnsafeHttpMethod(method: string): boolean {
|
|
990
|
+
export function isUnsafeHttpMethod(method: string): boolean {
|
|
999
991
|
return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())
|
|
1000
992
|
}
|
|
1001
993
|
|
|
@@ -9,6 +9,7 @@ import { executeTool } from './tool-executor'
|
|
|
9
9
|
import { loadAllModuleTools, indexToolsForSearch } from './tool-loader'
|
|
10
10
|
import { authenticateMcpRequest, extractApiKeyFromHeaders, hasRequiredFeatures } from './auth'
|
|
11
11
|
import { jsonSchemaToZod, toSafeZodSchema } from './schema-utils'
|
|
12
|
+
import { redactSecretForLog, deriveApiKeySessionId } from './log-redaction'
|
|
12
13
|
import type { McpServerConfig, McpToolContext } from './types'
|
|
13
14
|
import type { SearchService } from '@open-mercato/search/service'
|
|
14
15
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
@@ -42,7 +43,7 @@ async function resolveSessionContext(
|
|
|
42
43
|
const sessionResult = await findSessionApiKeyWithSecret(em, sessionToken)
|
|
43
44
|
if (!sessionResult) {
|
|
44
45
|
if (debug) {
|
|
45
|
-
console.error(`[MCP HTTP] Session token not found, expired, or secret unavailable: ${sessionToken}`)
|
|
46
|
+
console.error(`[MCP HTTP] Session token not found, expired, or secret unavailable: ${redactSecretForLog(sessionToken)}`)
|
|
46
47
|
}
|
|
47
48
|
return null
|
|
48
49
|
}
|
|
@@ -277,7 +278,7 @@ function createMcpServerForRequest(
|
|
|
277
278
|
if (!effectiveContext.sessionId && effectiveContext.apiKeySecret) {
|
|
278
279
|
effectiveContext = {
|
|
279
280
|
...effectiveContext,
|
|
280
|
-
sessionId:
|
|
281
|
+
sessionId: deriveApiKeySessionId(effectiveContext.apiKeySecret),
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { pbkdf2Sync, randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redact a bearer-style secret (session token, API key) for safe logging.
|
|
5
|
+
* Reveals at most a short leading fingerprint and never more than half of the
|
|
6
|
+
* value, so durable logs never carry a replayable credential.
|
|
7
|
+
*/
|
|
8
|
+
export function redactSecretForLog(value: unknown): string {
|
|
9
|
+
if (typeof value !== 'string' || value.length === 0) return '<redacted>'
|
|
10
|
+
const prefixLength = Math.min(12, Math.floor(value.length / 2))
|
|
11
|
+
if (prefixLength <= 0) return '<redacted>'
|
|
12
|
+
return `${value.slice(0, prefixLength)}...`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Per-process random key so the API key secret is fingerprinted through a keyed
|
|
16
|
+
// HMAC rather than a fast, unkeyed digest. The session id is only an in-process
|
|
17
|
+
// grouping key for the session-memory cache (a process-local Map), so a key that
|
|
18
|
+
// lives for the process lifetime keeps the same-secret-maps-to-same-id guarantee
|
|
19
|
+
// within an MCP connection while ensuring the digest is not derivable from the
|
|
20
|
+
// secret alone. Mirrors the secret fingerprinter in apiKeyAuthCache.ts.
|
|
21
|
+
const sessionIdHmacKey = randomBytes(32)
|
|
22
|
+
const SESSION_ID_PBKDF2_ITERATIONS = 210000
|
|
23
|
+
// 8 bytes → 16 hex chars: a short in-process grouping key, not a security token.
|
|
24
|
+
const SESSION_ID_PBKDF2_KEYLEN = 8
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive a stable, non-reversible session-memory id from an API key secret.
|
|
28
|
+
* The same secret always maps to the same id within a process (so tool calls on
|
|
29
|
+
* one MCP connection share a memory cache), but no secret material is exposed:
|
|
30
|
+
* the id is derived with PBKDF2 (slow KDF) using a per-process random salt.
|
|
31
|
+
*/
|
|
32
|
+
export function deriveApiKeySessionId(apiKeySecret: string): string {
|
|
33
|
+
const digest = pbkdf2Sync(
|
|
34
|
+
apiKeySecret,
|
|
35
|
+
sessionIdHmacKey,
|
|
36
|
+
SESSION_ID_PBKDF2_ITERATIONS,
|
|
37
|
+
SESSION_ID_PBKDF2_KEYLEN,
|
|
38
|
+
'sha256'
|
|
39
|
+
).toString('hex')
|
|
40
|
+
return `apikey_${digest}`
|
|
41
|
+
}
|
|
@@ -212,14 +212,18 @@ async function loadGeneratedTools(): Promise<{ moduleId: string; tools: AiToolDe
|
|
|
212
212
|
return result
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
async function pickDefaultTenant(
|
|
215
|
+
export async function pickDefaultTenant(
|
|
216
216
|
container: AwilixContainer,
|
|
217
217
|
): Promise<{ tenantId: string; organizationId: string | null; userId: string | null } | null> {
|
|
218
218
|
try {
|
|
219
219
|
const em = container.resolve<{
|
|
220
|
-
getConnection: () => {
|
|
220
|
+
getConnection: () => {
|
|
221
|
+
execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
|
|
222
|
+
}
|
|
221
223
|
}>('em') as unknown as {
|
|
222
|
-
getConnection: () => {
|
|
224
|
+
getConnection: () => {
|
|
225
|
+
execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
|
|
226
|
+
}
|
|
223
227
|
}
|
|
224
228
|
const conn = em.getConnection()
|
|
225
229
|
const tenantRows = await conn.execute(
|
|
@@ -230,16 +234,17 @@ async function pickDefaultTenant(
|
|
|
230
234
|
? String((tenantRows[0] as Record<string, unknown>).id)
|
|
231
235
|
: null
|
|
232
236
|
if (!tenantId) return null
|
|
233
|
-
const escaped = tenantId.replace(/'/g, "''")
|
|
234
237
|
const orgRows = await conn.execute(
|
|
235
|
-
`SELECT id FROM organizations WHERE tenant_id =
|
|
238
|
+
`SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
|
|
239
|
+
[tenantId],
|
|
236
240
|
)
|
|
237
241
|
const organizationId =
|
|
238
242
|
Array.isArray(orgRows) && orgRows[0]
|
|
239
243
|
? String((orgRows[0] as Record<string, unknown>).id)
|
|
240
244
|
: null
|
|
241
245
|
const userRows = await conn.execute(
|
|
242
|
-
`SELECT id FROM users WHERE tenant_id =
|
|
246
|
+
`SELECT id FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
|
|
247
|
+
[tenantId],
|
|
243
248
|
)
|
|
244
249
|
const userId =
|
|
245
250
|
Array.isArray(userRows) && userRows[0]
|