@open-mercato/ai-assistant 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -2
  3. package/README.md +1 -1
  4. package/dist/modules/ai_assistant/ai-tools/meta-pack.js +3 -1
  5. package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
  6. package/dist/modules/ai_assistant/api/mcp-key/route.js +95 -0
  7. package/dist/modules/ai_assistant/api/mcp-key/route.js.map +7 -0
  8. package/dist/modules/ai_assistant/cli.js +14 -6
  9. package/dist/modules/ai_assistant/cli.js.map +2 -2
  10. package/dist/modules/ai_assistant/components/McpConfigDialog.js +1 -1
  11. package/dist/modules/ai_assistant/components/McpConfigDialog.js.map +2 -2
  12. package/dist/modules/ai_assistant/data/entities.js +4 -0
  13. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  14. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +3 -1
  15. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
  16. package/dist/modules/ai_assistant/lib/ai-overrides.js.map +1 -1
  17. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +1 -1
  18. package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
  19. package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
  20. package/dist/modules/ai_assistant/lib/chat-config.js.map +1 -1
  21. package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
  22. package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
  23. package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
  24. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  25. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
  26. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +1 -1
  28. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +1 -1
  29. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +1 -1
  30. package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +1 -1
  31. package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
  32. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  33. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
  34. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
  35. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
  36. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  37. package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
  38. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  39. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +1 -1
  40. package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +1 -1
  41. package/dist/modules/ai_assistant/setup.js +9 -2
  42. package/dist/modules/ai_assistant/setup.js.map +2 -2
  43. package/package.json +11 -12
  44. package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
  45. package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
  46. package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +29 -0
  47. package/src/modules/ai_assistant/ai-tools/meta-pack.ts +7 -1
  48. package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +144 -0
  49. package/src/modules/ai_assistant/api/mcp-key/route.ts +125 -0
  50. package/src/modules/ai_assistant/cli.ts +18 -5
  51. package/src/modules/ai_assistant/components/McpConfigDialog.tsx +5 -1
  52. package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
  53. package/src/modules/ai_assistant/data/entities.ts +5 -0
  54. package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +1 -1
  55. package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
  56. package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
  57. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
  58. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
  59. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
  60. package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
  61. package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
  62. package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +1 -1
  63. package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +9 -0
  64. package/src/modules/ai_assistant/lib/ai-overrides.ts +1 -1
  65. package/src/modules/ai_assistant/lib/ai-sdk.ts +1 -1
  66. package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
  67. package/src/modules/ai_assistant/lib/chat-config.ts +1 -1
  68. package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
  69. package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
  70. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
  71. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +1 -1
  72. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +1 -1
  73. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -1
  74. package/src/modules/ai_assistant/lib/llm-bootstrap.ts +1 -1
  75. package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
  76. package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
  77. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
  78. package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
  79. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -1
  80. package/src/modules/ai_assistant/lib/task-plan-stream.ts +1 -1
  81. package/src/modules/ai_assistant/lib/types.ts +7 -0
  82. package/src/modules/ai_assistant/setup.ts +11 -2
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tests for POST /api/ai_assistant/mcp-key.
3
+ *
4
+ * The route creates a persistent MCP API key that inherits the calling user's
5
+ * roles. These tests pin the security-critical behavior: the key carries
6
+ * exactly the caller's own roles (no escalation), is scoped to the caller's
7
+ * tenant/organization, and degrades gracefully when the caller has no tenant.
8
+ */
9
+
10
+ const authMock = jest.fn()
11
+ const createRequestContainerMock = jest.fn()
12
+ const createApiKeyMock = jest.fn()
13
+ const findWithDecryptionMock = jest.fn()
14
+
15
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
16
+ getAuthFromRequest: (...args: unknown[]) => authMock(...args),
17
+ }))
18
+
19
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
20
+ createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
21
+ }))
22
+
23
+ jest.mock('@open-mercato/core/modules/api_keys/services/apiKeyService', () => ({
24
+ createApiKey: (...args: unknown[]) => createApiKeyMock(...args),
25
+ }))
26
+
27
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
28
+ findWithDecryption: (...args: unknown[]) => findWithDecryptionMock(...args),
29
+ }))
30
+
31
+ jest.mock('@open-mercato/core/modules/auth/data/entities', () => ({
32
+ UserRole: class UserRole {},
33
+ }))
34
+
35
+ import { POST } from '../route'
36
+
37
+ function buildRequest(body: unknown): Request {
38
+ return new Request('http://localhost/api/ai_assistant/mcp-key', {
39
+ method: 'POST',
40
+ body: typeof body === 'string' ? body : JSON.stringify(body),
41
+ headers: { 'content-type': 'application/json' },
42
+ })
43
+ }
44
+
45
+ describe('POST /api/ai_assistant/mcp-key', () => {
46
+ let consoleErrorSpy: jest.SpyInstance
47
+
48
+ beforeEach(() => {
49
+ jest.clearAllMocks()
50
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
51
+ authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
52
+ createRequestContainerMock.mockResolvedValue({
53
+ resolve: (name: string) => (name === 'em' ? {} : null),
54
+ })
55
+ findWithDecryptionMock.mockResolvedValue([
56
+ { role: { id: 'role-a' } },
57
+ { role: { id: 'role-b' } },
58
+ ])
59
+ createApiKeyMock.mockResolvedValue({
60
+ record: {
61
+ id: 'key-1',
62
+ name: 'My MCP Key',
63
+ keyPrefix: 'omk_abc1234',
64
+ tenantId: 'tenant-1',
65
+ organizationId: 'org-1',
66
+ },
67
+ secret: 'omk_abc1234.deadbeef',
68
+ })
69
+ })
70
+
71
+ afterEach(() => {
72
+ consoleErrorSpy.mockRestore()
73
+ })
74
+
75
+ it('returns 401 when unauthenticated', async () => {
76
+ authMock.mockResolvedValueOnce(null)
77
+
78
+ const response = await POST(buildRequest({ name: 'My MCP Key' }) as any)
79
+
80
+ expect(response.status).toBe(401)
81
+ expect(createApiKeyMock).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('creates a key carrying exactly the caller\'s own roles (no escalation)', async () => {
85
+ const response = await POST(buildRequest({ name: 'My MCP Key' }) as any)
86
+
87
+ expect(response.status).toBe(200)
88
+ const json = (await response.json()) as Record<string, unknown>
89
+ expect(json.secret).toBe('omk_abc1234.deadbeef')
90
+ expect(json.roles).toEqual(['role-a', 'role-b'])
91
+
92
+ expect(createApiKeyMock).toHaveBeenCalledTimes(1)
93
+ const [, input] = createApiKeyMock.mock.calls[0]
94
+ expect(input.roles).toEqual(['role-a', 'role-b'])
95
+ expect(input.tenantId).toBe('tenant-1')
96
+ expect(input.organizationId).toBe('org-1')
97
+ expect(input.createdBy).toBe('user-1')
98
+ })
99
+
100
+ it('scopes the role lookup to the caller\'s tenant', async () => {
101
+ await POST(buildRequest({}) as any)
102
+
103
+ expect(findWithDecryptionMock).toHaveBeenCalledTimes(1)
104
+ const [, , where, , scope] = findWithDecryptionMock.mock.calls[0]
105
+ expect(where.user).toBe('user-1')
106
+ expect(where.role).toEqual({ tenantId: 'tenant-1' })
107
+ expect(scope).toEqual({ tenantId: 'tenant-1', organizationId: null })
108
+ })
109
+
110
+ it('assigns no roles and skips the lookup when the caller has no tenant', async () => {
111
+ authMock.mockResolvedValueOnce({ sub: 'user-1', tenantId: null, orgId: null })
112
+
113
+ const response = await POST(buildRequest({}) as any)
114
+
115
+ expect(response.status).toBe(200)
116
+ expect(findWithDecryptionMock).not.toHaveBeenCalled()
117
+ const [, input] = createApiKeyMock.mock.calls[0]
118
+ expect(input.roles).toEqual([])
119
+ })
120
+
121
+ it('ignores malformed role links when resolving ids', async () => {
122
+ findWithDecryptionMock.mockResolvedValueOnce([
123
+ { role: { id: 'role-a' } },
124
+ { role: null },
125
+ { role: { id: '' } },
126
+ {},
127
+ ])
128
+
129
+ await POST(buildRequest({}) as any)
130
+
131
+ const [, input] = createApiKeyMock.mock.calls[0]
132
+ expect(input.roles).toEqual(['role-a'])
133
+ })
134
+
135
+ it('returns 500 with a generic message when key creation fails', async () => {
136
+ createApiKeyMock.mockRejectedValueOnce(new Error('db down'))
137
+
138
+ const response = await POST(buildRequest({}) as any)
139
+
140
+ expect(response.status).toBe(500)
141
+ const json = (await response.json()) as Record<string, unknown>
142
+ expect(json.error).toBe('Failed to create MCP API key')
143
+ })
144
+ })
@@ -0,0 +1,125 @@
1
+ import { NextResponse, type NextRequest } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
7
+ import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
8
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
10
+
11
+ export const metadata = {
12
+ POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },
13
+ }
14
+
15
+ /**
16
+ * Resolve the calling user's role ids for the active tenant. Mirrors the
17
+ * `session-key` route so the generated key carries exactly the caller's roles.
18
+ */
19
+ async function getUserRoleIds(
20
+ em: EntityManager,
21
+ userId: string,
22
+ tenantId: string | null,
23
+ ): Promise<string[]> {
24
+ if (!tenantId) return []
25
+ const links = await findWithDecryption(
26
+ em,
27
+ UserRole,
28
+ { user: userId as any, role: { tenantId } } as any,
29
+ { populate: ['role'] },
30
+ { tenantId, organizationId: null },
31
+ )
32
+ const linkList = Array.isArray(links) ? links : []
33
+ return linkList
34
+ .map((link) => (link.role as any)?.id)
35
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
36
+ }
37
+
38
+ const bodySchema = z.object({
39
+ name: z.string().min(1).max(120).optional(),
40
+ description: z.string().max(1000).optional().nullable(),
41
+ })
42
+
43
+ /**
44
+ * POST /api/ai_assistant/mcp-key
45
+ *
46
+ * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a
47
+ * raw `POST /api/api_keys/keys` call — which assigns no roles unless the client
48
+ * passes them — this endpoint resolves the **caller's own roles** server-side
49
+ * and attaches them to the key, so the key inherits the current user's ACL.
50
+ * The user cannot escalate: they only ever grant their own roles to their own
51
+ * key.
52
+ */
53
+ export async function POST(req: NextRequest) {
54
+ const auth = await getAuthFromRequest(req)
55
+ if (!auth?.sub) {
56
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
57
+ }
58
+
59
+ try {
60
+ const raw = await req.json().catch(() => ({}))
61
+ const body = bodySchema.parse(raw ?? {})
62
+
63
+ const container = await createRequestContainer()
64
+ const em = container.resolve<EntityManager>('em')
65
+
66
+ const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)
67
+
68
+ const { record, secret } = await createApiKey(em, {
69
+ name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,
70
+ description: body.description ?? 'Generated from AI Assistant settings for MCP client',
71
+ tenantId: auth.tenantId ?? null,
72
+ organizationId: auth.orgId ?? null,
73
+ roles: roleIds,
74
+ createdBy: auth.sub,
75
+ })
76
+
77
+ return NextResponse.json({
78
+ id: record.id,
79
+ name: record.name,
80
+ keyPrefix: record.keyPrefix,
81
+ secret,
82
+ tenantId: record.tenantId ?? null,
83
+ organizationId: record.organizationId ?? null,
84
+ roles: roleIds,
85
+ })
86
+ } catch (error) {
87
+ console.error('[MCP Key] Error:', error)
88
+ return NextResponse.json(
89
+ { error: 'Failed to create MCP API key' },
90
+ { status: 500 },
91
+ )
92
+ }
93
+ }
94
+
95
+ const responseSchema = z.object({
96
+ id: z.string(),
97
+ name: z.string(),
98
+ keyPrefix: z.string(),
99
+ secret: z.string().describe('Full API key value (omk_...). Shown once.'),
100
+ tenantId: z.string().uuid().nullable(),
101
+ organizationId: z.string().uuid().nullable(),
102
+ roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),
103
+ })
104
+
105
+ const errorSchema = z.object({ error: z.string() })
106
+
107
+ export const openApi: OpenApiRouteDoc = {
108
+ summary: 'Create MCP API key',
109
+ description:
110
+ 'Creates a persistent MCP API key that inherits the calling user\'s roles, for use in an MCP client `.mcp.json`.',
111
+ methods: {
112
+ POST: {
113
+ summary: 'Generate MCP API key',
114
+ description:
115
+ 'Generates a persistent `omk_` API key scoped to the calling user\'s tenant/organization and carrying the caller\'s own roles, so the key has the same ACL as the user.',
116
+ responses: [
117
+ { status: 200, description: 'API key created', schema: responseSchema },
118
+ ],
119
+ errors: [
120
+ { status: 401, description: 'Unauthorized', schema: errorSchema },
121
+ { status: 500, description: 'Failed to create MCP API key', schema: errorSchema },
122
+ ],
123
+ },
124
+ },
125
+ }
@@ -47,30 +47,42 @@ const mcpServe: ModuleCli = {
47
47
  command: 'mcp:serve',
48
48
  async run(rest) {
49
49
  const args = parseArgs(rest)
50
- const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || null
50
+ // Prefer the OPEN_MERCATO_API_KEY env var so the secret never has to be
51
+ // placed on the command line (argv is world-readable via ps / /proc).
52
+ // The --api-key flag stays supported for backward compatibility.
53
+ const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || process.env.OPEN_MERCATO_API_KEY || null
51
54
  const tenantId = String(args.tenant ?? args.tenantId ?? '') || null
52
55
  const organizationId = String(args.org ?? args.organizationId ?? '') || null
53
56
  const userId = String(args.user ?? args.userId ?? '') || null
54
57
  const debug = args.debug === true || args.debug === 'true'
58
+ const allowUnauthenticatedSuperadmin =
59
+ args['allow-unauthenticated-superadmin'] === true ||
60
+ args['allow-unauthenticated-superadmin'] === 'true'
55
61
 
56
62
  // Either API key or tenant is required
57
63
  if (!apiKey && !tenantId) {
58
64
  console.error('Usage: mercato ai_assistant mcp:serve [options]')
59
65
  console.error('')
60
66
  console.error('Authentication (choose one):')
61
- console.error(' --api-key <secret> API key secret for authentication (recommended)')
62
- console.error(' --tenant <id> Tenant ID (for manual context)')
67
+ console.error(' OPEN_MERCATO_API_KEY env API key secret (recommended — keeps the secret off argv)')
68
+ console.error(' --api-key <secret> API key secret for authentication (visible in process listings)')
69
+ console.error(' --tenant <id> Tenant ID (for manual context)')
63
70
  console.error('')
64
71
  console.error('Options (with --tenant):')
65
72
  console.error(' --org <id> Organization ID (optional)')
66
- console.error(' --user <id> User ID for ACL (optional, uses superadmin if not set)')
73
+ console.error(' --user <id> User ID for ACL (required unless --allow-unauthenticated-superadmin is set)')
67
74
  console.error('')
68
75
  console.error('Common options:')
69
76
  console.error(' --debug Enable debug logging')
77
+ console.error(' --allow-unauthenticated-superadmin')
78
+ console.error(' DEV/TEST ONLY: run as superadmin with no per-user ACL when')
79
+ console.error(' no --user (and no --api-key) is supplied. Never use in production.')
70
80
  console.error('')
71
81
  console.error('Examples:')
82
+ console.error(' OPEN_MERCATO_API_KEY=omk_xxxx.yyyy... mercato ai_assistant mcp:serve')
72
83
  console.error(' mercato ai_assistant mcp:serve --api-key omk_xxxx.yyyy...')
73
- console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000')
84
+ console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --user <user-id>')
85
+ console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --allow-unauthenticated-superadmin')
74
86
  return
75
87
  }
76
88
 
@@ -102,6 +114,7 @@ const mcpServe: ModuleCli = {
102
114
  organizationId,
103
115
  userId,
104
116
  },
117
+ allowUnauthenticatedSuperadmin,
105
118
  })
106
119
  }
107
120
  },
@@ -41,7 +41,11 @@ export default function McpConfigDialog({ open, onOpenChange, mcpUrl }: Props) {
41
41
  setIsGenerating(true)
42
42
  setError(null)
43
43
  try {
44
- const res = await apiCall<ApiKeyResponse>('/api/api_keys/keys', {
44
+ // Use the dedicated MCP-key endpoint so the key inherits the current
45
+ // user's roles/ACL. A raw POST /api/api_keys/keys assigns no roles, which
46
+ // produced a key with zero features (the MCP server then exposes only
47
+ // `context_whoami`).
48
+ const res = await apiCall<ApiKeyResponse>('/api/ai_assistant/mcp-key', {
45
49
  method: 'POST',
46
50
  body: JSON.stringify({
47
51
  name: `MCP Config - ${new Date().toLocaleDateString()}`,
@@ -38,6 +38,45 @@ describe('AI assistant nullable organization unique indexes', () => {
38
38
  },
39
39
  )
40
40
 
41
+ it('declares every snapshot expression index on the participants entity (no snapshot/entity drift)', () => {
42
+ const snapshot = JSON.parse(
43
+ readFileSync(
44
+ new URL('../../migrations/.snapshot-open-mercato.json', import.meta.url),
45
+ 'utf8',
46
+ ),
47
+ ) as {
48
+ tables: Array<{ name: string; indexes?: Array<{ keyName: string; expression?: string }> }>
49
+ }
50
+
51
+ const participants = snapshot.tables.find(
52
+ (table) => table.name === 'ai_chat_conversation_participants',
53
+ )
54
+ expect(participants).toBeDefined()
55
+
56
+ const entitySource = readFileSync(
57
+ new URL('../entities.ts', import.meta.url),
58
+ 'utf8',
59
+ )
60
+
61
+ const expressionIndexes = (participants?.indexes ?? []).filter((index) => index.expression)
62
+ expect(expressionIndexes.length).toBeGreaterThan(0)
63
+
64
+ for (const index of expressionIndexes) {
65
+ expect(entitySource).toContain(index.keyName)
66
+ }
67
+ })
68
+
69
+ it('keeps the active-participants partial index declared on the entity', () => {
70
+ const entitySource = readFileSync(
71
+ new URL('../entities.ts', import.meta.url),
72
+ 'utf8',
73
+ )
74
+
75
+ expect(entitySource).toContain(
76
+ 'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
77
+ )
78
+ })
79
+
41
80
  it('keeps the module snapshot aligned with partial unique indexes', () => {
42
81
  const snapshot = JSON.parse(
43
82
  readFileSync(
@@ -789,6 +789,11 @@ export class AiChatConversation {
789
789
  expression:
790
790
  'create unique index "ai_chat_conv_participants_tenant_conv_user_null_org_uq" on "ai_chat_conversation_participants" ("tenant_id", "conversation_id", "user_id") where "organization_id" is null',
791
791
  })
792
+ @Index({
793
+ name: 'ai_chat_conv_participants_active_conv_user_idx',
794
+ expression:
795
+ 'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
796
+ })
792
797
  @Index({
793
798
  name: 'ai_chat_conv_participants_tenant_org_user_conv_idx',
794
799
  properties: ['tenantId', 'organizationId', 'userId', 'conversationId'],
@@ -2,7 +2,7 @@
2
2
  * Coverage for the AI override pipeline. The pipeline is the only public
3
3
  * way for downstream modules (or app-level code) to replace or disable
4
4
  * an AI agent / AI tool registered by another module — see spec
5
- * `.ai/specs/2026-04-30-ai-overrides-and-module-disable.md`.
5
+ * `.ai/specs/implemented/2026-04-30-ai-overrides-and-module-disable.md`.
6
6
  *
7
7
  * The pipeline has three tiers (highest precedence first):
8
8
  * 1. programmatic — `applyAiAgentOverrides` / `applyAiToolOverrides`
@@ -349,6 +349,118 @@ describe('resolveAttachmentParts — tenant / org scope enforcement', () => {
349
349
  expect(parts).toEqual([])
350
350
  expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not found'))
351
351
  })
352
+
353
+ // Regression for #2663 — null-scoped rows used to bypass the tenant check via
354
+ // a truthiness short-circuit and leak into the caller's LLM context.
355
+ it('drops a null-tenant (global) record for a non super-admin caller', async () => {
356
+ findOneWithDecryptionMock.mockResolvedValueOnce(
357
+ makeRow({
358
+ id: 'null-tenant-1',
359
+ tenantId: null,
360
+ organizationId: null,
361
+ mimeType: 'image/png',
362
+ fileSize: 64,
363
+ content: 'secret OCR text',
364
+ }),
365
+ )
366
+
367
+ const parts = await resolveAttachmentParts({
368
+ attachmentIds: ['null-tenant-1'],
369
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
370
+ container: makeContainer(),
371
+ })
372
+
373
+ expect(parts).toEqual([])
374
+ expect(fsReadFileMock).not.toHaveBeenCalled()
375
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
376
+ })
377
+
378
+ // Regression for #2663 — `tenantId='X', organizationId=null` used to be
379
+ // readable by every org within tenant X.
380
+ it('drops a null-org record in the caller tenant for an org-scoped caller', async () => {
381
+ findOneWithDecryptionMock.mockResolvedValueOnce(
382
+ makeRow({
383
+ id: 'null-org-1',
384
+ tenantId: 'tenant-1',
385
+ organizationId: null,
386
+ mimeType: 'image/png',
387
+ fileSize: 64,
388
+ }),
389
+ )
390
+
391
+ const parts = await resolveAttachmentParts({
392
+ attachmentIds: ['null-org-1'],
393
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
394
+ container: makeContainer(),
395
+ })
396
+
397
+ expect(parts).toEqual([])
398
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
399
+ })
400
+
401
+ it('lets a tenant-wide caller (null org) read any org within its tenant', async () => {
402
+ findOneWithDecryptionMock.mockResolvedValueOnce(
403
+ makeRow({
404
+ id: 'same-tenant-other-org',
405
+ tenantId: 'tenant-1',
406
+ organizationId: 'org-OTHER',
407
+ mimeType: 'application/zip',
408
+ fileSize: 64,
409
+ content: null,
410
+ }),
411
+ )
412
+
413
+ const parts = await resolveAttachmentParts({
414
+ attachmentIds: ['same-tenant-other-org'],
415
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
416
+ container: makeContainer(),
417
+ })
418
+
419
+ expect(parts).toHaveLength(1)
420
+ expect(parts[0].attachmentId).toBe('same-tenant-other-org')
421
+ })
422
+
423
+ it('scopes the ORM query by tenant + org for a non super-admin caller', async () => {
424
+ findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-1', fileSize: 0 }))
425
+
426
+ await resolveAttachmentParts({
427
+ attachmentIds: ['att-scope-1'],
428
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
429
+ container: makeContainer(),
430
+ })
431
+
432
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
433
+ expect(whereArg).toEqual({ id: 'att-scope-1', tenantId: 'tenant-1', organizationId: 'org-1' })
434
+ })
435
+
436
+ it('omits org from the ORM query for a tenant-wide caller (null org)', async () => {
437
+ findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-2', fileSize: 0 }))
438
+
439
+ await resolveAttachmentParts({
440
+ attachmentIds: ['att-scope-2'],
441
+ authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
442
+ container: makeContainer(),
443
+ })
444
+
445
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
446
+ expect(whereArg).toEqual({ id: 'att-scope-2', tenantId: 'tenant-1' })
447
+ expect(whereArg).not.toHaveProperty('organizationId')
448
+ })
449
+
450
+ it('does not scope the ORM query for super-admin callers', async () => {
451
+ findOneWithDecryptionMock.mockResolvedValueOnce(
452
+ makeRow({ id: 'att-scope-3', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER', fileSize: 0 }),
453
+ )
454
+
455
+ await resolveAttachmentParts({
456
+ attachmentIds: ['att-scope-3'],
457
+ authContext: makeAuth({ isSuperAdmin: true }),
458
+ container: makeContainer(),
459
+ })
460
+
461
+ const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
462
+ expect(whereArg).toEqual({ id: 'att-scope-3' })
463
+ })
352
464
  })
353
465
 
354
466
  describe('resolveAttachmentParts — unavailable service graceful skip', () => {
@@ -0,0 +1,42 @@
1
+ import { createMcpClient } from '../client-factory'
2
+
3
+ const mockConnect = jest.fn(async () => ({ listTools: jest.fn(), callTool: jest.fn(), close: jest.fn() }))
4
+
5
+ jest.mock('../mcp-client', () => ({
6
+ McpClient: {
7
+ connect: (...args: unknown[]) => mockConnect(...args),
8
+ },
9
+ }))
10
+
11
+ describe('createMcpClient stdio mode (issue #2669)', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks()
14
+ })
15
+
16
+ it('never places the API key on the default argv', async () => {
17
+ await createMcpClient({ mode: 'stdio', apiKeySecret: 'omk_secret.value' })
18
+
19
+ expect(mockConnect).toHaveBeenCalledTimes(1)
20
+ const options = mockConnect.mock.calls[0][0] as { apiKeySecret: string; args?: string[] }
21
+
22
+ // The secret is forwarded to McpClient.connect, which delivers it via env.
23
+ expect(options.apiKeySecret).toBe('omk_secret.value')
24
+
25
+ // The factory must not build default args that embed the secret. Leaving
26
+ // args undefined lets connectStdio use its secret-free default.
27
+ expect(options.args).toBeUndefined()
28
+ expect(JSON.stringify(options.args ?? [])).not.toContain('--api-key')
29
+ expect(JSON.stringify(options.args ?? [])).not.toContain('omk_secret.value')
30
+ })
31
+
32
+ it('honors explicitly provided stdio args without injecting the secret', async () => {
33
+ await createMcpClient({
34
+ mode: 'stdio',
35
+ apiKeySecret: 'omk_secret.value',
36
+ stdioArgs: ['node', 'custom-server.js'],
37
+ })
38
+
39
+ const options = mockConnect.mock.calls[0][0] as { args?: string[] }
40
+ expect(options.args).toEqual(['node', 'custom-server.js'])
41
+ })
42
+ })