mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-18 10:22:00 -05:00
Compare commits
2 Commits
cursor/sla
...
v0.5.92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -3,7 +3,6 @@ import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
@@ -30,17 +29,6 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||
// Remove workspaceId from body to prevent it from being updated
|
||||
const { workspaceId: _, ...updateData } = body
|
||||
|
||||
if (updateData.url) {
|
||||
try {
|
||||
validateMcpDomain(updateData.url)
|
||||
} catch (e) {
|
||||
if (e instanceof McpDomainNotAllowedError) {
|
||||
return createMcpErrorResponse(e, e.message, 403)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current server to check if URL is changing
|
||||
const [currentServer] = await db
|
||||
.select({ url: mcpServers.url })
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import {
|
||||
@@ -73,15 +72,6 @@ export const POST = withMcpAuth('write')(
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
validateMcpDomain(body.url)
|
||||
} catch (e) {
|
||||
if (e instanceof McpDomainNotAllowedError) {
|
||||
return createMcpErrorResponse(e, e.message, 403)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
|
||||
|
||||
const [existingServer] = await db
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
@@ -72,15 +71,6 @@ export const POST = withMcpAuth('write')(
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
validateMcpDomain(body.url)
|
||||
} catch (e) {
|
||||
if (e instanceof McpDomainNotAllowedError) {
|
||||
return createMcpErrorResponse(e, e.message, 403)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// Build initial config for resolution
|
||||
const initialConfig = {
|
||||
id: `test-${requestId}`,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
allowedIntegrations: getAllowedIntegrationsFromEnv(),
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const configuredDomains = getAllowedMcpDomainsFromEnv()
|
||||
if (configuredDomains === null) {
|
||||
return NextResponse.json({ allowedMcpDomains: null })
|
||||
}
|
||||
|
||||
try {
|
||||
const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
|
||||
if (!configuredDomains.includes(platformHostname)) {
|
||||
return NextResponse.json({
|
||||
allowedMcpDomains: [...configuredDomains, platformHostname],
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({ allowedMcpDomains: configuredDomains })
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
/**
|
||||
* Tests for Slack Add Reaction API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockFetch,
|
||||
createMockLogger,
|
||||
createMockRequest,
|
||||
createMockResponse,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Slack Add Reaction API Route', () => {
|
||||
const mockLogger = createMockLogger()
|
||||
const mockCheckInternalAuth = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkInternalAuth: mockCheckInternalAuth,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should add reaction successfully', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: true },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.content).toBe('Successfully added :thumbsup: reaction')
|
||||
expect(data.output.metadata).toEqual({
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
reaction: 'thumbsup',
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://slack.com/api/reactions.add',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer xoxb-test-token',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle emoji name without colons', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: true },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'eyes',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.content).toBe('Successfully added :eyes: reaction')
|
||||
})
|
||||
|
||||
it('should handle unauthenticated request', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Authentication required')
|
||||
})
|
||||
|
||||
it('should handle missing access token', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Invalid request data')
|
||||
})
|
||||
|
||||
it('should handle missing channel', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Invalid request data')
|
||||
})
|
||||
|
||||
it('should handle missing timestamp', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Invalid request data')
|
||||
})
|
||||
|
||||
it('should handle missing emoji name', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Invalid request data')
|
||||
})
|
||||
|
||||
it('should handle Slack API missing_scope error', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: false, error: 'missing_scope' },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('missing_scope')
|
||||
})
|
||||
|
||||
it('should handle Slack API channel_not_found error', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: false, error: 'channel_not_found' },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'CINVALID',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('channel_not_found')
|
||||
})
|
||||
|
||||
it('should handle Slack API message_not_found error', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: false, error: 'message_not_found' },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '0000000000.000000',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('message_not_found')
|
||||
})
|
||||
|
||||
it('should handle Slack API invalid_name error for invalid emoji', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: false, error: 'invalid_name' },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'not_a_valid_emoji',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('invalid_name')
|
||||
})
|
||||
|
||||
it('should handle Slack API already_reacted error', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: false, error: 'already_reacted' },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('already_reacted')
|
||||
})
|
||||
|
||||
it('should handle network error when calling Slack API', async () => {
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('Network error'))
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('should handle various emoji names correctly', async () => {
|
||||
const emojiNames = ['heart', 'fire', 'rocket', '+1', '-1', 'tada', 'eyes', 'thinking_face']
|
||||
|
||||
for (const emojiName of emojiNames) {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkInternalAuth: mockCheckInternalAuth,
|
||||
}))
|
||||
|
||||
mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'api-key',
|
||||
})
|
||||
|
||||
const mockSlackResponse = createMockResponse({
|
||||
status: 200,
|
||||
json: { ok: true },
|
||||
})
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(mockSlackResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: emojiName,
|
||||
},
|
||||
{},
|
||||
'http://localhost:3000/api/tools/slack/add-reaction'
|
||||
)
|
||||
|
||||
const { POST } = await import('@/app/api/tools/slack/add-reaction/route')
|
||||
const response = await POST(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.metadata.reaction).toBe(emojiName)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
|
||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { CopilotToolCall } from '@/stores/panel'
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -341,20 +341,16 @@ export function OptionsSelector({
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1)
|
||||
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const activeTab = usePanelStore((s) => s.activeTab)
|
||||
|
||||
const isLocked = chosenKey !== null
|
||||
|
||||
// Handle keyboard navigation - only for the active options selector when copilot is active
|
||||
// Handle keyboard navigation - only for the active options selector
|
||||
useEffect(() => {
|
||||
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return
|
||||
|
||||
// Only handle keyboard shortcuts when the copilot panel is active
|
||||
if (activeTab !== 'copilot') return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
const isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
@@ -391,15 +387,7 @@ export function OptionsSelector({
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
isInteractionDisabled,
|
||||
enableKeyboardNav,
|
||||
isLocked,
|
||||
sortedOptions,
|
||||
hoveredIndex,
|
||||
onSelect,
|
||||
activeTab,
|
||||
])
|
||||
}, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect])
|
||||
|
||||
if (sortedOptions.length === 0) return null
|
||||
|
||||
|
||||
@@ -36,18 +36,17 @@ export function isBlockProtected(blockId: string, blocks: Record<string, BlockSt
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected only if its target block is protected.
|
||||
* Outbound connections from locked blocks are allowed to be modified.
|
||||
* An edge is protected if either its source or target block is protected.
|
||||
*
|
||||
* @param edge - The edge to check (must have source and target)
|
||||
* @param blocks - Record of all blocks in the workflow
|
||||
* @returns True if the edge is protected (target is locked)
|
||||
* @returns True if the edge is protected
|
||||
*/
|
||||
export function isEdgeProtected(
|
||||
edge: { source: string; target: string },
|
||||
blocks: Record<string, BlockState>
|
||||
): boolean {
|
||||
return isBlockProtected(edge.target, blocks)
|
||||
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2523,7 +2523,7 @@ const WorkflowContent = React.memo(() => {
|
||||
.filter((change: any) => change.type === 'remove')
|
||||
.map((change: any) => change.id)
|
||||
.filter((edgeId: string) => {
|
||||
// Prevent removing edges targeting protected blocks
|
||||
// Prevent removing edges connected to protected blocks
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
return !isEdgeProtected(edge, blocks)
|
||||
@@ -2595,7 +2595,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
// Prevent connections to protected blocks (outbound from locked blocks is allowed)
|
||||
// Prevent connections to/from protected blocks
|
||||
if (isEdgeProtected(connection, blocks)) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
@@ -3357,12 +3357,12 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Stable delete handler to avoid creating new function references per edge. */
|
||||
const handleEdgeDelete = useCallback(
|
||||
(edgeId: string) => {
|
||||
// Prevent removing edges targeting protected blocks
|
||||
// Prevent removing edges connected to protected blocks
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (edge && isEdgeProtected(edge, blocks)) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot remove connections to locked blocks',
|
||||
message: 'Cannot remove connections from locked blocks',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
@@ -3420,7 +3420,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Handle edge deletion first (edges take priority if selected)
|
||||
if (selectedEdges.size > 0) {
|
||||
// Get all selected edge IDs and filter out edges targeting protected blocks
|
||||
// Get all selected edge IDs and filter out edges connected to protected blocks
|
||||
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
|
||||
@@ -223,11 +223,13 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
|
||||
}
|
||||
}
|
||||
|
||||
// Group services by provider, filtering by permission config
|
||||
const groupedServices = services.reduce(
|
||||
(acc, service) => {
|
||||
// Filter based on allowedIntegrations
|
||||
if (
|
||||
permissionConfig.allowedIntegrations !== null &&
|
||||
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
|
||||
!permissionConfig.allowedIntegrations.includes(service.id)
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
|
||||
@@ -106,21 +106,6 @@ interface McpServer {
|
||||
|
||||
const logger = createLogger('McpSettings')
|
||||
|
||||
/**
|
||||
* Checks if a URL's hostname is in the allowed domains list.
|
||||
* Returns true if no allowlist is configured (null) or the domain matches.
|
||||
*/
|
||||
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
|
||||
if (allowedDomains === null) return true
|
||||
if (!url) return true
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
return allowedDomains.includes(hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_DATA: McpServerFormData = {
|
||||
name: '',
|
||||
transport: 'streamable-http',
|
||||
@@ -405,15 +390,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
} = useMcpServerTest()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const [allowedMcpDomains, setAllowedMcpDomains] = useState<string[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/allowed-mcp-domains')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null))
|
||||
.catch(() => setAllowedMcpDomains(null))
|
||||
}, [])
|
||||
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -1030,12 +1006,10 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
|
||||
|
||||
const isFormValid = formData.name.trim() && formData.url?.trim()
|
||||
const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains)
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
|
||||
const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains)
|
||||
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
|
||||
const hasEditChanges = useMemo(() => {
|
||||
if (editFormData.name !== editOriginalData.name) return true
|
||||
@@ -1325,11 +1299,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
onChange={(e) => handleEditInputChange('url', e.target.value)}
|
||||
onScroll={setEditUrlScrollLeft}
|
||||
/>
|
||||
{isEditDomainBlocked && (
|
||||
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
|
||||
Domain not permitted by server policy
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -1382,7 +1351,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleEditTestConnection}
|
||||
disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
|
||||
disabled={isEditTestingConnection || !isEditFormValid}
|
||||
>
|
||||
{editTestButtonLabel}
|
||||
</Button>
|
||||
@@ -1392,9 +1361,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={
|
||||
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
|
||||
}
|
||||
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
|
||||
variant='tertiary'
|
||||
>
|
||||
{isUpdatingServer ? 'Saving...' : 'Save'}
|
||||
@@ -1467,11 +1434,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
|
||||
/>
|
||||
{isAddDomainBlocked && (
|
||||
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
|
||||
Domain not permitted by server policy
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -1517,7 +1479,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
|
||||
disabled={isTestingConnection || !isFormValid}
|
||||
>
|
||||
{testButtonLabel}
|
||||
</Button>
|
||||
@@ -1527,9 +1489,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
|
||||
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
|
||||
? 'Adding...'
|
||||
: 'Add Server'}
|
||||
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Tests for Slack Block configuration
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlackBlock } from './slack'
|
||||
|
||||
describe('SlackBlock', () => {
|
||||
describe('basic configuration', () => {
|
||||
it('should have correct type and name', () => {
|
||||
expect(SlackBlock.type).toBe('slack')
|
||||
expect(SlackBlock.name).toBe('Slack')
|
||||
})
|
||||
|
||||
it('should have slack_add_reaction in tools access', () => {
|
||||
expect(SlackBlock.tools.access).toContain('slack_add_reaction')
|
||||
})
|
||||
|
||||
it('should have tools.config.tool function', () => {
|
||||
expect(typeof SlackBlock.tools.config?.tool).toBe('function')
|
||||
})
|
||||
|
||||
it('should have tools.config.params function', () => {
|
||||
expect(typeof SlackBlock.tools.config?.params).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools.config.tool', () => {
|
||||
const getToolName = SlackBlock.tools.config?.tool
|
||||
|
||||
it('should return slack_add_reaction for react operation', () => {
|
||||
expect(getToolName?.({ operation: 'react' })).toBe('slack_add_reaction')
|
||||
})
|
||||
|
||||
it('should return slack_message for send operation', () => {
|
||||
expect(getToolName?.({ operation: 'send' })).toBe('slack_message')
|
||||
})
|
||||
|
||||
it('should return slack_delete_message for delete operation', () => {
|
||||
expect(getToolName?.({ operation: 'delete' })).toBe('slack_delete_message')
|
||||
})
|
||||
|
||||
it('should return slack_update_message for update operation', () => {
|
||||
expect(getToolName?.({ operation: 'update' })).toBe('slack_update_message')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools.config.params for react operation', () => {
|
||||
const getParams = SlackBlock.tools.config?.params
|
||||
|
||||
it('should map reaction params correctly with OAuth auth', () => {
|
||||
const inputParams = {
|
||||
operation: 'react',
|
||||
authMethod: 'oauth',
|
||||
credential: 'oauth-credential-123',
|
||||
channel: 'C1234567890',
|
||||
reactionTimestamp: '1405894322.002768',
|
||||
emojiName: 'thumbsup',
|
||||
}
|
||||
|
||||
const result = getParams?.(inputParams)
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: 'oauth-credential-123',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'thumbsup',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map reaction params correctly with bot token auth', () => {
|
||||
const inputParams = {
|
||||
operation: 'react',
|
||||
authMethod: 'bot_token',
|
||||
botToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
reactionTimestamp: '1405894322.002768',
|
||||
emojiName: 'eyes',
|
||||
}
|
||||
|
||||
const result = getParams?.(inputParams)
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
timestamp: '1405894322.002768',
|
||||
name: 'eyes',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle various emoji names', () => {
|
||||
const emojiNames = ['heart', 'fire', 'rocket', '+1', '-1', 'tada', 'thinking_face']
|
||||
|
||||
for (const emoji of emojiNames) {
|
||||
const inputParams = {
|
||||
operation: 'react',
|
||||
authMethod: 'bot_token',
|
||||
botToken: 'xoxb-test-token',
|
||||
channel: 'C1234567890',
|
||||
reactionTimestamp: '1405894322.002768',
|
||||
emojiName: emoji,
|
||||
}
|
||||
|
||||
const result = getParams?.(inputParams)
|
||||
|
||||
expect(result?.name).toBe(emoji)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle channel from trigger data', () => {
|
||||
const inputParams = {
|
||||
operation: 'react',
|
||||
authMethod: 'bot_token',
|
||||
botToken: 'xoxb-test-token',
|
||||
channel: 'C9876543210',
|
||||
reactionTimestamp: '1234567890.123456',
|
||||
emojiName: 'white_check_mark',
|
||||
}
|
||||
|
||||
const result = getParams?.(inputParams)
|
||||
|
||||
expect(result?.channel).toBe('C9876543210')
|
||||
expect(result?.timestamp).toBe('1234567890.123456')
|
||||
})
|
||||
|
||||
it('should trim whitespace from channel', () => {
|
||||
const inputParams = {
|
||||
operation: 'react',
|
||||
authMethod: 'bot_token',
|
||||
botToken: 'xoxb-test-token',
|
||||
channel: ' C1234567890 ',
|
||||
reactionTimestamp: '1405894322.002768',
|
||||
emojiName: 'thumbsup',
|
||||
}
|
||||
|
||||
const result = getParams?.(inputParams)
|
||||
|
||||
expect(result?.channel).toBe('C1234567890')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subBlocks for react operation', () => {
|
||||
it('should have reactionTimestamp subBlock with correct condition', () => {
|
||||
const reactionTimestampSubBlock = SlackBlock.subBlocks.find(
|
||||
(sb) => sb.id === 'reactionTimestamp'
|
||||
)
|
||||
|
||||
expect(reactionTimestampSubBlock).toBeDefined()
|
||||
expect(reactionTimestampSubBlock?.type).toBe('short-input')
|
||||
expect(reactionTimestampSubBlock?.required).toBe(true)
|
||||
expect(reactionTimestampSubBlock?.condition).toEqual({
|
||||
field: 'operation',
|
||||
value: 'react',
|
||||
})
|
||||
})
|
||||
|
||||
it('should have emojiName subBlock with correct condition', () => {
|
||||
const emojiNameSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'emojiName')
|
||||
|
||||
expect(emojiNameSubBlock).toBeDefined()
|
||||
expect(emojiNameSubBlock?.type).toBe('short-input')
|
||||
expect(emojiNameSubBlock?.required).toBe(true)
|
||||
expect(emojiNameSubBlock?.condition).toEqual({
|
||||
field: 'operation',
|
||||
value: 'react',
|
||||
})
|
||||
})
|
||||
|
||||
it('should have channel subBlock that shows for react operation', () => {
|
||||
const channelSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'channel')
|
||||
|
||||
expect(channelSubBlock).toBeDefined()
|
||||
|
||||
const condition = channelSubBlock?.condition as {
|
||||
field: string
|
||||
value: string[]
|
||||
not: boolean
|
||||
and: { field: string; value: string; not: boolean }
|
||||
}
|
||||
|
||||
expect(condition.field).toBe('operation')
|
||||
expect(condition.value).not.toContain('react')
|
||||
expect(condition.not).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputs configuration', () => {
|
||||
it('should have timestamp input for reaction', () => {
|
||||
expect(SlackBlock.inputs.timestamp).toBeDefined()
|
||||
expect(SlackBlock.inputs.timestamp.type).toBe('string')
|
||||
})
|
||||
|
||||
it('should have name input for emoji', () => {
|
||||
expect(SlackBlock.inputs.name).toBeDefined()
|
||||
expect(SlackBlock.inputs.name.type).toBe('string')
|
||||
})
|
||||
|
||||
it('should have reactionTimestamp input', () => {
|
||||
expect(SlackBlock.inputs.reactionTimestamp).toBeDefined()
|
||||
expect(SlackBlock.inputs.reactionTimestamp.type).toBe('string')
|
||||
})
|
||||
|
||||
it('should have emojiName input', () => {
|
||||
expect(SlackBlock.inputs.emojiName).toBeDefined()
|
||||
expect(SlackBlock.inputs.emojiName.type).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('operation dropdown', () => {
|
||||
it('should include Add Reaction option', () => {
|
||||
const operationSubBlock = SlackBlock.subBlocks.find((sb) => sb.id === 'operation')
|
||||
expect(operationSubBlock?.type).toBe('dropdown')
|
||||
|
||||
const options = operationSubBlock?.options as Array<{ label: string; id: string }>
|
||||
const reactOption = options?.find((opt) => opt.id === 'react')
|
||||
|
||||
expect(reactOption).toBeDefined()
|
||||
expect(reactOption?.label).toBe('Add Reaction')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
mockGetAllowedIntegrationsFromEnv,
|
||||
mockIsOrganizationOnEnterprisePlan,
|
||||
mockGetProviderFromModel,
|
||||
} = vi.hoisted(() => ({
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG: {
|
||||
allowedIntegrations: null,
|
||||
allowedModelProviders: null,
|
||||
hideTraceSpans: false,
|
||||
hideKnowledgeBaseTab: false,
|
||||
hideCopilot: false,
|
||||
hideApiKeysTab: false,
|
||||
hideEnvironmentTab: false,
|
||||
hideFilesTab: false,
|
||||
disableMcpTools: false,
|
||||
disableCustomTools: false,
|
||||
disableSkills: false,
|
||||
hideTemplates: false,
|
||||
disableInvitations: false,
|
||||
hideDeployApi: false,
|
||||
hideDeployMcp: false,
|
||||
hideDeployA2a: false,
|
||||
hideDeployChatbot: false,
|
||||
hideDeployTemplate: false,
|
||||
},
|
||||
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => databaseMock)
|
||||
vi.mock('@sim/db/schema', () => ({}))
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled: false,
|
||||
isHosted: false,
|
||||
}))
|
||||
vi.mock('@/lib/permission-groups/types', () => ({
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
parsePermissionGroupConfig: (config: unknown) => {
|
||||
if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG
|
||||
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config }
|
||||
},
|
||||
}))
|
||||
vi.mock('@/providers/utils', () => ({
|
||||
getProviderFromModel: mockGetProviderFromModel,
|
||||
}))
|
||||
|
||||
import {
|
||||
getUserPermissionConfig,
|
||||
IntegrationNotAllowedError,
|
||||
validateBlockType,
|
||||
} from './permission-check'
|
||||
|
||||
describe('IntegrationNotAllowedError', () => {
|
||||
it.concurrent('creates error with correct name and message', () => {
|
||||
const error = new IntegrationNotAllowedError('discord')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('IntegrationNotAllowedError')
|
||||
expect(error.message).toContain('discord')
|
||||
})
|
||||
|
||||
it.concurrent('includes custom reason when provided', () => {
|
||||
const error = new IntegrationNotAllowedError('discord', 'blocked by server policy')
|
||||
|
||||
expect(error.message).toContain('blocked by server policy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserPermissionConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when no env allowlist is configured', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
|
||||
const config = await getUserPermissionConfig('user-123')
|
||||
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it('returns config with env allowlist when configured', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123')
|
||||
|
||||
expect(config).not.toBeNull()
|
||||
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
|
||||
})
|
||||
|
||||
it('preserves default values for non-allowlist fields', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123')
|
||||
|
||||
expect(config!.disableMcpTools).toBe(false)
|
||||
expect(config!.allowedModelProviders).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('env allowlist fallback when userId is absent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null allowlist when no userId and no env allowlist', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
|
||||
const userId: string | undefined = undefined
|
||||
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
||||
|
||||
expect(allowedIntegrations).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to env allowlist when no userId is provided', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
||||
|
||||
const userId: string | undefined = undefined
|
||||
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
||||
|
||||
expect(allowedIntegrations).toEqual(['slack', 'gmail'])
|
||||
})
|
||||
|
||||
it('env allowlist filters block types when userId is absent', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
||||
|
||||
const userId: string | undefined = undefined
|
||||
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
|
||||
|
||||
expect(allowedIntegrations).not.toBeNull()
|
||||
expect(allowedIntegrations!.includes('slack')).toBe(true)
|
||||
expect(allowedIntegrations!.includes('discord')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses permission config when userId is present, ignoring env fallback', async () => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
|
||||
|
||||
const config = await getUserPermissionConfig('user-123')
|
||||
|
||||
expect(config).not.toBeNull()
|
||||
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBlockType', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when no env allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('allows any block type', async () => {
|
||||
await validateBlockType(undefined, 'google_drive')
|
||||
})
|
||||
|
||||
it('allows multi-word block types', async () => {
|
||||
await validateBlockType(undefined, 'microsoft_excel')
|
||||
})
|
||||
|
||||
it('always allows start_trigger', async () => {
|
||||
await validateBlockType(undefined, 'start_trigger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when env allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
|
||||
'slack',
|
||||
'google_drive',
|
||||
'microsoft_excel',
|
||||
])
|
||||
})
|
||||
|
||||
it('allows block types on the allowlist', async () => {
|
||||
await validateBlockType(undefined, 'slack')
|
||||
await validateBlockType(undefined, 'google_drive')
|
||||
await validateBlockType(undefined, 'microsoft_excel')
|
||||
})
|
||||
|
||||
it('rejects block types not on the allowlist', async () => {
|
||||
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(
|
||||
IntegrationNotAllowedError
|
||||
)
|
||||
})
|
||||
|
||||
it('always allows start_trigger regardless of allowlist', async () => {
|
||||
await validateBlockType(undefined, 'start_trigger')
|
||||
})
|
||||
|
||||
it('matches case-insensitively', async () => {
|
||||
await validateBlockType(undefined, 'Slack')
|
||||
await validateBlockType(undefined, 'GOOGLE_DRIVE')
|
||||
})
|
||||
|
||||
it('includes env reason in error when env allowlist is the source', async () => {
|
||||
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
|
||||
})
|
||||
|
||||
it('includes env reason even when userId is present if env is the source', async () => {
|
||||
await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('service ID to block type normalization', () => {
|
||||
it.concurrent('hyphenated service IDs match underscore block types after normalization', () => {
|
||||
const allowedBlockTypes = [
|
||||
'google_drive',
|
||||
'microsoft_excel',
|
||||
'microsoft_teams',
|
||||
'google_sheets',
|
||||
'google_docs',
|
||||
'google_calendar',
|
||||
'google_forms',
|
||||
'microsoft_planner',
|
||||
]
|
||||
const serviceIds = [
|
||||
'google-drive',
|
||||
'microsoft-excel',
|
||||
'microsoft-teams',
|
||||
'google-sheets',
|
||||
'google-docs',
|
||||
'google-calendar',
|
||||
'google-forms',
|
||||
'microsoft-planner',
|
||||
]
|
||||
|
||||
for (const serviceId of serviceIds) {
|
||||
const normalized = serviceId.replace(/-/g, '_')
|
||||
expect(allowedBlockTypes).toContain(normalized)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('single-word service IDs are unaffected by normalization', () => {
|
||||
const serviceIds = ['slack', 'gmail', 'notion', 'discord', 'jira', 'trello']
|
||||
|
||||
for (const serviceId of serviceIds) {
|
||||
const normalized = serviceId.replace(/-/g, '_')
|
||||
expect(normalized).toBe(serviceId)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,8 @@ import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
getAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled,
|
||||
isHosted,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
@@ -28,12 +23,8 @@ export class ProviderNotAllowedError extends Error {
|
||||
}
|
||||
|
||||
export class IntegrationNotAllowedError extends Error {
|
||||
constructor(blockType: string, reason?: string) {
|
||||
super(
|
||||
reason
|
||||
? `Integration "${blockType}" is not allowed: ${reason}`
|
||||
: `Integration "${blockType}" is not allowed based on your permission group settings`
|
||||
)
|
||||
constructor(blockType: string) {
|
||||
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
|
||||
this.name = 'IntegrationNotAllowedError'
|
||||
}
|
||||
}
|
||||
@@ -66,38 +57,11 @@ export class InvitationsNotAllowedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the env allowlist into a permission config.
|
||||
* If `config` is null and no env allowlist is set, returns null.
|
||||
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
|
||||
* If both are set, intersects the two allowlists.
|
||||
*/
|
||||
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
|
||||
if (envAllowlist === null) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
|
||||
}
|
||||
|
||||
const merged =
|
||||
config.allowedIntegrations === null
|
||||
? envAllowlist
|
||||
: config.allowedIntegrations
|
||||
.map((i) => i.toLowerCase())
|
||||
.filter((i) => envAllowlist.includes(i))
|
||||
|
||||
return { ...config, allowedIntegrations: merged }
|
||||
}
|
||||
|
||||
export async function getUserPermissionConfig(
|
||||
userId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
@@ -107,12 +71,12 @@ export async function getUserPermissionConfig(
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
@@ -128,10 +92,10 @@ export async function getUserPermissionConfig(
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
|
||||
return parsePermissionGroupConfig(groupMembership.config)
|
||||
}
|
||||
|
||||
export async function getPermissionConfig(
|
||||
@@ -188,25 +152,19 @@ export async function validateBlockType(
|
||||
return
|
||||
}
|
||||
|
||||
const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null)
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config || config.allowedIntegrations === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
|
||||
logger.warn(
|
||||
blockedByEnv
|
||||
? 'Integration blocked by env allowlist'
|
||||
: 'Integration blocked by permission group',
|
||||
{ userId, blockType }
|
||||
)
|
||||
throw new IntegrationNotAllowedError(
|
||||
blockType,
|
||||
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
|
||||
)
|
||||
if (!config.allowedIntegrations.includes(blockType)) {
|
||||
logger.warn('Integration blocked by permission group', { userId, blockType })
|
||||
throw new IntegrationNotAllowedError(blockType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
|
||||
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -418,29 +418,29 @@ export function SSO() {
|
||||
|
||||
{/* Callback URL */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<div className='relative'>
|
||||
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
|
||||
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{providerCallbackUrl}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => copyToClipboard(providerCallbackUrl)}
|
||||
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[13px] w-[13px]' />
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[13px] w-[13px]' />
|
||||
<Copy className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span className='sr-only'>Copy callback URL</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
|
||||
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{providerCallbackUrl}
|
||||
</code>
|
||||
</div>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Configure this in your identity provider
|
||||
</p>
|
||||
@@ -852,29 +852,29 @@ export function SSO() {
|
||||
|
||||
{/* Callback URL display */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<div className='relative'>
|
||||
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
|
||||
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => copyToClipboard(callbackUrl)}
|
||||
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[13px] w-[13px]' />
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[13px] w-[13px]' />
|
||||
<Copy className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<span className='sr-only'>Copy callback URL</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
|
||||
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
</div>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Configure this in your identity provider
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: true,
|
||||
isTest: false,
|
||||
getCostMultiplier: vi.fn().mockReturnValue(1),
|
||||
getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null),
|
||||
isEmailVerificationEnabled: false,
|
||||
isBillingEnabled: false,
|
||||
isOrganizationsEnabled: false,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
|
||||
import {
|
||||
@@ -22,44 +21,12 @@ export interface PermissionConfigResult {
|
||||
isInvitationsDisabled: boolean
|
||||
}
|
||||
|
||||
interface AllowedIntegrationsResponse {
|
||||
allowedIntegrations: string[] | null
|
||||
}
|
||||
|
||||
function useAllowedIntegrationsFromEnv() {
|
||||
return useQuery<AllowedIntegrationsResponse>({
|
||||
queryKey: ['allowedIntegrations', 'env'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/settings/allowed-integrations')
|
||||
if (!response.ok) return { allowedIntegrations: null }
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects two allowlists. If either is null (unrestricted), returns the other.
|
||||
* If both are set, returns only items present in both.
|
||||
*/
|
||||
function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null {
|
||||
if (a === null) return b
|
||||
if (b === null) return a
|
||||
return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i))
|
||||
}
|
||||
|
||||
export function usePermissionConfig(): PermissionConfigResult {
|
||||
const accessControlDisabled = !isHosted && !isAccessControlEnabled
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
|
||||
const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(
|
||||
activeOrganization?.id
|
||||
)
|
||||
const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } =
|
||||
useAllowedIntegrationsFromEnv()
|
||||
|
||||
const isLoading = isPermissionLoading || isEnvAllowlistLoading
|
||||
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (accessControlDisabled) {
|
||||
@@ -73,18 +40,13 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
|
||||
const isInPermissionGroup = !accessControlDisabled && !!permissionData?.permissionGroupId
|
||||
|
||||
const mergedAllowedIntegrations = useMemo(() => {
|
||||
const envAllowlist = envAllowlistData?.allowedIntegrations ?? null
|
||||
return intersectAllowlists(config.allowedIntegrations, envAllowlist)
|
||||
}, [config.allowedIntegrations, envAllowlistData])
|
||||
|
||||
const isBlockAllowed = useMemo(() => {
|
||||
return (blockType: string) => {
|
||||
if (blockType === 'start_trigger') return true
|
||||
if (mergedAllowedIntegrations === null) return true
|
||||
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
|
||||
if (config.allowedIntegrations === null) return true
|
||||
return config.allowedIntegrations.includes(blockType)
|
||||
}
|
||||
}, [mergedAllowedIntegrations])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const isProviderAllowed = useMemo(() => {
|
||||
return (providerId: string) => {
|
||||
@@ -95,14 +57,13 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
|
||||
const filterBlocks = useMemo(() => {
|
||||
return <T extends { type: string }>(blocks: T[]): T[] => {
|
||||
if (mergedAllowedIntegrations === null) return blocks
|
||||
if (config.allowedIntegrations === null) return blocks
|
||||
return blocks.filter(
|
||||
(block) =>
|
||||
block.type === 'start_trigger' ||
|
||||
mergedAllowedIntegrations.includes(block.type.toLowerCase())
|
||||
block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type)
|
||||
)
|
||||
}
|
||||
}, [mergedAllowedIntegrations])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const filterProviders = useMemo(() => {
|
||||
return (providerIds: string[]): string[] => {
|
||||
@@ -116,14 +77,9 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
return featureFlagDisabled || config.disableInvitations
|
||||
}, [config.disableInvitations])
|
||||
|
||||
const mergedConfig = useMemo(
|
||||
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
|
||||
[config, mergedAllowedIntegrations]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
config: mergedConfig,
|
||||
config,
|
||||
isLoading,
|
||||
isInPermissionGroup,
|
||||
filterBlocks,
|
||||
@@ -133,7 +89,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
isInvitationsDisabled,
|
||||
}),
|
||||
[
|
||||
mergedConfig,
|
||||
config,
|
||||
isLoading,
|
||||
isInPermissionGroup,
|
||||
filterBlocks,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { db } from '@sim/db'
|
||||
import { copilotChats, document, knowledgeBase, templates } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { isHiddenFromDisplay } from '@/blocks/types'
|
||||
@@ -350,14 +349,16 @@ async function processBlockMetadata(
|
||||
userId?: string
|
||||
): Promise<AgentContext | null> {
|
||||
try {
|
||||
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
|
||||
logger.debug('Block not allowed by integration allowlist', { blockId, userId })
|
||||
return null
|
||||
if (userId) {
|
||||
const permissionConfig = await getUserPermissionConfig(userId)
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId, userId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse registry to match get_blocks_metadata tool result
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
const { tools: toolsRegistry } = await import('@/tools/registry')
|
||||
const SPECIAL_BLOCKS_METADATA: Record<string, any> = {}
|
||||
@@ -465,6 +466,7 @@ async function processWorkflowBlockFromDb(
|
||||
if (!block) return null
|
||||
const tag = label ? `@${label} in Workflow` : `@${block.name || blockId} in Workflow`
|
||||
|
||||
// Build content: isolate the block and include its subBlocks fully
|
||||
const contentObj = {
|
||||
workflowId,
|
||||
block: block,
|
||||
@@ -516,6 +518,7 @@ async function processExecutionLogFromDb(
|
||||
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
|
||||
totalDurationMs: log.totalDurationMs ?? null,
|
||||
workflowName: log.workflowName || '',
|
||||
// Include trace spans and any available details without being huge
|
||||
executionData: log.executionData
|
||||
? {
|
||||
traceSpans: (log.executionData as any).traceSpans || undefined,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
GetBlockConfigResult,
|
||||
type GetBlockConfigResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
@@ -440,10 +439,9 @@ export const getBlockConfigServerTool: BaseServerTool<
|
||||
}
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
|
||||
throw new Error(`Block "${blockType}" is not available`)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
GetBlockOptionsResult,
|
||||
type GetBlockOptionsResultType,
|
||||
} from '@/lib/copilot/tools/shared/schemas'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
import { tools as toolsRegistry } from '@/tools/registry'
|
||||
@@ -60,10 +59,9 @@ export const getBlockOptionsServerTool: BaseServerTool<
|
||||
}
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
throw new Error(`Block "${blockId}" is not available`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { GetBlocksAndToolsInput, GetBlocksAndToolsResult } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
@@ -18,8 +17,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
logger.debug('Executing get_blocks_and_tools')
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
type BlockListItem = {
|
||||
type: string
|
||||
@@ -32,8 +30,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
|
||||
Object.entries(blockRegistry)
|
||||
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return false
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase()))
|
||||
return false
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
|
||||
return true
|
||||
})
|
||||
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { join } from 'path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { GetBlocksMetadataInput, GetBlocksMetadataResult } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
@@ -113,12 +112,11 @@ export const getBlocksMetadataServerTool: BaseServerTool<
|
||||
logger.debug('Executing get_blocks_metadata', { count: blockIds?.length })
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
const result: Record<string, CopilotBlockMetadata> = {}
|
||||
for (const blockId of blockIds || []) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId })
|
||||
continue
|
||||
}
|
||||
@@ -422,6 +420,7 @@ function extractInputs(metadata: CopilotBlockMetadata): {
|
||||
}
|
||||
|
||||
if (schema.options && schema.options.length > 0) {
|
||||
// Always return the id (actual value to use), not the display label
|
||||
input.options = schema.options.map((opt) => opt.id || opt.label)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { z } from 'zod'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { registry as blockRegistry } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
|
||||
@@ -23,15 +22,13 @@ export const getTriggerBlocksServerTool: BaseServerTool<
|
||||
logger.debug('Executing get_trigger_blocks')
|
||||
|
||||
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
|
||||
const allowedIntegrations =
|
||||
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
|
||||
const triggerBlockIds: string[] = []
|
||||
|
||||
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
|
||||
if (blockConfig.hideFromToolbar) return
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase()))
|
||||
return
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
|
||||
|
||||
if (blockConfig.category === 'triggers') {
|
||||
triggerBlockIds.push(blockType)
|
||||
|
||||
@@ -657,7 +657,7 @@ export function isBlockTypeAllowed(
|
||||
if (!permissionConfig || permissionConfig.allowedIntegrations === null) {
|
||||
return true
|
||||
}
|
||||
return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase())
|
||||
return permissionConfig.allowedIntegrations.includes(blockType)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,8 +93,6 @@ export const env = createEnv({
|
||||
EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search
|
||||
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
|
||||
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
|
||||
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
|
||||
ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
|
||||
|
||||
// Azure Configuration - Shared credentials with feature-specific models
|
||||
AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint
|
||||
|
||||
@@ -123,47 +123,6 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED)
|
||||
*/
|
||||
export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED)
|
||||
|
||||
/**
|
||||
* Returns the parsed allowlist of integration block types from the environment variable.
|
||||
* If not set or empty, returns null (meaning all integrations are allowed).
|
||||
*/
|
||||
export function getAllowedIntegrationsFromEnv(): string[] | null {
|
||||
if (!env.ALLOWED_INTEGRATIONS) return null
|
||||
const parsed = env.ALLOWED_INTEGRATIONS.split(',')
|
||||
.map((i) => i.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var.
|
||||
* Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com").
|
||||
* Extracts the hostname in either case.
|
||||
*/
|
||||
function normalizeDomainEntry(entry: string): string {
|
||||
const trimmed = entry.trim().toLowerCase()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.includes('://')) {
|
||||
try {
|
||||
return new URL(trimmed).hostname
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var.
|
||||
* Returns null if not set (all domains allowed), or parsed array of lowercase hostnames.
|
||||
* Accepts both bare hostnames and full URLs in the env var value.
|
||||
*/
|
||||
export function getAllowedMcpDomainsFromEnv(): string[] | null {
|
||||
if (!env.ALLOWED_MCP_DOMAINS) return null
|
||||
const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost multiplier based on environment
|
||||
*/
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockGetAllowedMcpDomainsFromEnv = vi.fn<() => string[] | null>()
|
||||
const mockGetBaseUrl = vi.fn<() => string>()
|
||||
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: mockGetBaseUrl,
|
||||
}))
|
||||
|
||||
const { McpDomainNotAllowedError, isMcpDomainAllowed, validateMcpDomain } = await import(
|
||||
'./domain-check'
|
||||
)
|
||||
|
||||
describe('McpDomainNotAllowedError', () => {
|
||||
it.concurrent('creates error with correct name and message', () => {
|
||||
const error = new McpDomainNotAllowedError('evil.com')
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error).toBeInstanceOf(McpDomainNotAllowedError)
|
||||
expect(error.name).toBe('McpDomainNotAllowedError')
|
||||
expect(error.message).toContain('evil.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMcpDomainAllowed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when no allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('allows any URL', () => {
|
||||
expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows undefined URL', () => {
|
||||
expect(isMcpDomainAllowed(undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows empty string URL', () => {
|
||||
expect(isMcpDomainAllowed('')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com'])
|
||||
mockGetBaseUrl.mockReturnValue('https://platform.example.com')
|
||||
})
|
||||
|
||||
it('allows URLs on the allowlist', () => {
|
||||
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
|
||||
expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects URLs not on the allowlist', () => {
|
||||
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects undefined URL (fail-closed)', () => {
|
||||
expect(isMcpDomainAllowed(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty string URL (fail-closed)', () => {
|
||||
expect(isMcpDomainAllowed('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(isMcpDomainAllowed('not-a-url')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('always allows the platform hostname', () => {
|
||||
expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows platform hostname even when not in the allowlist', () => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['other.com'])
|
||||
expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when getBaseUrl is not configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
|
||||
mockGetBaseUrl.mockImplementation(() => {
|
||||
throw new Error('Not configured')
|
||||
})
|
||||
})
|
||||
|
||||
it('still allows URLs on the allowlist', () => {
|
||||
expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('still rejects URLs not on the allowlist', () => {
|
||||
expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMcpDomain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when no allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('does not throw for any URL', () => {
|
||||
expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not throw for undefined URL', () => {
|
||||
expect(() => validateMcpDomain(undefined)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when allowlist is configured', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com'])
|
||||
mockGetBaseUrl.mockReturnValue('https://platform.example.com')
|
||||
})
|
||||
|
||||
it('does not throw for allowed URLs', () => {
|
||||
expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws McpDomainNotAllowedError for disallowed URLs', () => {
|
||||
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws for undefined URL (fail-closed)', () => {
|
||||
expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError)
|
||||
})
|
||||
|
||||
it('throws for malformed URLs', () => {
|
||||
expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError)
|
||||
})
|
||||
|
||||
it('includes the rejected domain in the error message', () => {
|
||||
expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/)
|
||||
})
|
||||
|
||||
it('does not throw for platform hostname', () => {
|
||||
expect(() => validateMcpDomain('https://platform.example.com/mcp')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export class McpDomainNotAllowedError extends Error {
|
||||
constructor(domain: string) {
|
||||
super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`)
|
||||
this.name = 'McpDomainNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
let cachedPlatformHostname: string | null = null
|
||||
|
||||
/**
|
||||
* Returns the platform's own hostname (from getBaseUrl), lazy-cached.
|
||||
* Always lowercase. Returns null if the base URL is not configured or invalid.
|
||||
*/
|
||||
function getPlatformHostname(): string | null {
|
||||
if (cachedPlatformHostname !== null) return cachedPlatformHostname
|
||||
try {
|
||||
cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return cachedPlatformHostname
|
||||
}
|
||||
|
||||
/**
|
||||
* Core domain check. Returns null if the URL is allowed, or the hostname/url
|
||||
* string to use in the rejection error.
|
||||
*/
|
||||
function checkMcpDomain(url: string): string | null {
|
||||
const allowedDomains = getAllowedMcpDomainsFromEnv()
|
||||
if (allowedDomains === null) return null
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
if (hostname === getPlatformHostname()) return null
|
||||
return allowedDomains.includes(hostname) ? null : hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the URL's domain is allowed (or no restriction is configured).
|
||||
* The platform's own hostname (from getBaseUrl) is always allowed.
|
||||
*/
|
||||
export function isMcpDomainAllowed(url: string | undefined): boolean {
|
||||
if (!url) {
|
||||
return getAllowedMcpDomainsFromEnv() === null
|
||||
}
|
||||
return checkMcpDomain(url) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist.
|
||||
* The platform's own hostname (from getBaseUrl) is always allowed.
|
||||
*/
|
||||
export function validateMcpDomain(url: string | undefined): void {
|
||||
if (!url) {
|
||||
if (getAllowedMcpDomainsFromEnv() !== null) {
|
||||
throw new McpDomainNotAllowedError('(empty)')
|
||||
}
|
||||
return
|
||||
}
|
||||
const rejected = checkMcpDomain(url)
|
||||
if (rejected !== null) {
|
||||
throw new McpDomainNotAllowedError(rejected)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
|
||||
import { isMcpDomainAllowed } from '@/lib/mcp/domain-check'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import {
|
||||
createMcpCacheAdapter,
|
||||
@@ -94,10 +93,6 @@ class McpService {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isMcpDomainAllowed(server.url || undefined)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
@@ -128,21 +123,19 @@ class McpService {
|
||||
.from(mcpServers)
|
||||
.where(and(...whereConditions))
|
||||
|
||||
return servers
|
||||
.map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description || undefined,
|
||||
transport: server.transport as McpTransport,
|
||||
url: server.url || undefined,
|
||||
headers: (server.headers as Record<string, string>) || {},
|
||||
timeout: server.timeout || 30000,
|
||||
retries: server.retries || 3,
|
||||
enabled: server.enabled,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
}))
|
||||
.filter((config) => isMcpDomainAllowed(config.url))
|
||||
return servers.map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description || undefined,
|
||||
transport: server.transport as McpTransport,
|
||||
url: server.url || undefined,
|
||||
headers: (server.headers as Record<string, string>) || {},
|
||||
timeout: server.timeout || 30000,
|
||||
retries: server.retries || 3,
|
||||
enabled: server.enabled,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -324,17 +324,20 @@ const nextConfig: NextConfig = {
|
||||
)
|
||||
}
|
||||
|
||||
// Beluga campaign short link tracking
|
||||
if (isHosted) {
|
||||
redirects.push({
|
||||
source: '/r/:shortCode',
|
||||
destination: 'https://go.trybeluga.ai/:shortCode',
|
||||
permanent: false,
|
||||
})
|
||||
}
|
||||
|
||||
return redirects
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
...(isHosted
|
||||
? [
|
||||
{
|
||||
source: '/r/:shortCode',
|
||||
destination: 'https://go.trybeluga.ai/:shortCode',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
@@ -193,10 +193,6 @@ app:
|
||||
# LLM Provider/Model Restrictions (leave empty if not restricting)
|
||||
BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google")
|
||||
BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
|
||||
ALLOWED_MCP_DOMAINS: "" # Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
|
||||
|
||||
# Integration/Block Restrictions (leave empty if not restricting)
|
||||
ALLOWED_INTEGRATIONS: "" # Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
|
||||
|
||||
# Invitation Control
|
||||
DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally
|
||||
|
||||
Reference in New Issue
Block a user