@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js +146 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +4 -8
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +2 -2
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js +119 -0
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map +7 -0
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js +174 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js +132 -0
  12. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js.map +7 -0
  13. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -0
  14. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  15. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js +68 -0
  16. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js.map +7 -0
  17. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js +74 -0
  18. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map +7 -0
  19. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +6 -5
  20. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
  21. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js +57 -0
  22. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map +7 -0
  23. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +127 -0
  24. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +7 -0
  25. package/dist/modules/ai_assistant/lib/auth.js +2 -11
  26. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/codemode-tools.js +17 -20
  28. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  29. package/dist/modules/ai_assistant/lib/http-server.js +3 -2
  30. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  31. package/dist/modules/ai_assistant/lib/log-redaction.js +25 -0
  32. package/dist/modules/ai_assistant/lib/log-redaction.js.map +7 -0
  33. package/dist/modules/ai_assistant/lib/tool-test-runner.js +5 -3
  34. package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
  35. package/package.json +10 -11
  36. package/src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts +209 -0
  37. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +6 -18
  38. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts +176 -0
  39. package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts +222 -0
  40. package/src/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.ts +184 -0
  41. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -0
  42. package/src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts +95 -0
  43. package/src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts +115 -0
  44. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +7 -5
  45. package/src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
  46. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
  47. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +27 -0
  48. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +128 -0
  49. package/src/modules/ai_assistant/lib/__tests__/log-redaction.test.ts +65 -0
  50. package/src/modules/ai_assistant/lib/__tests__/tool-test-runner-pick-default-tenant.test.ts +70 -0
  51. package/src/modules/ai_assistant/lib/auth.ts +9 -15
  52. package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -29
  53. package/src/modules/ai_assistant/lib/http-server.ts +3 -2
  54. package/src/modules/ai_assistant/lib/log-redaction.ts +41 -0
  55. 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 (keeps backward compatibility)
144
- return requiredFeatures.every((required) => {
145
- if (userFeatures.includes(required)) return true
146
- if (userFeatures.includes('*')) return true
147
-
148
- // Check wildcard patterns (e.g., 'customers.*' grants 'customers.people.view')
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
- * Detect mutation HTTP methods in code via static analysis.
555
- * Returns which methods were found (POST, PUT, PATCH, DELETE).
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
- // Detect mutations via static analysis cap API calls for safety
705
- const mutationInfo = detectMutationInCode(input.code)
706
- const maxApiCalls = mutationInfo.hasMutation ? 20 : 50
707
- if (mutationInfo.hasMutation) {
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: 'apikey_' + effectiveContext.apiKeySecret.slice(0, 16),
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: () => { execute: (sql: string) => Promise<unknown[]> }
220
+ getConnection: () => {
221
+ execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
222
+ }
221
223
  }>('em') as unknown as {
222
- getConnection: () => { execute: (sql: string) => Promise<Record<string, unknown>[]> }
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 = '${escaped}' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
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 = '${escaped}' AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
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]