Compare commits

...

6 Commits

Author SHA1 Message Date
Cursor Agent
6fbf5741d4 feat: hide usage limits and seats info from enterprise members (non-admin)
- Add isEnterpriseMember and canViewUsageInfo flags to subscription permissions
- Hide UsageHeader, CreditBalance, billing date, and usage notifications from enterprise members
- Show only plan name in subscription tab for enterprise members (non-admin)
- Hide usage indicator details (amount, progress pills) from enterprise members
- Team tab already hidden via requiresTeam check in settings modal

Closes #6882

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-02-18 05:40:34 +00:00
Emir Karabeg
eab01e0272 fix(copilot): copilot shortcut conflict (#3219)
* fix: prevent copilot keyboard shortcuts from triggering when panel is inactive

The OptionsSelector component was capturing keyboard events (1-9 number keys and Enter)
globally on the document, causing accidental option selections when users were
interacting with other parts of the application.

This fix adds a check to only handle keyboard shortcuts when the copilot panel
is the active tab, preventing the shortcuts from interfering with other workflows.

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>

* lint

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-02-17 18:47:07 -08:00
Waleed
bbcef7ce5c feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions (#3238)
* feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions

* fix(tests): add getAllowedIntegrationsFromEnv mock to agent-handler tests

* fix(access-control): add auth to allowlist endpoint, fix loading state race, use accurate error message

* fix(access-control): remove auth from allowed-integrations endpoint to match models endpoint pattern

* fix(access-control): normalize blockType to lowercase before env allowlist check

* fix(access-control): expose merged allowedIntegrations on config to prevent bypass via direct access

* consolidate merging of allowed blocks so all callers have it by default

* normalize to lower case

* added tests

* added tests, normalize to lower case

* added safety incase userId is missing

* fix failing tests
2026-02-17 18:46:24 -08:00
Emir Karabeg
0ee52df5a7 feat(canvas): allow locked block outbound connections (#3229)
* Allow outbound connections from locked blocks to be modified

- Modified isEdgeProtected to only check target block protection
- Outbound connections (from locked blocks) can now be added/removed
- Inbound connections (to locked blocks) remain protected
- Updated notification messages and comments to reflect the change

Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>

* update notif msg

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
Co-authored-by: waleed <walif6@gmail.com>
2026-02-17 18:16:17 -08:00
Waleed
6421b1a0ca feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist (#3240)
* feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist

* ack PR comments

* cleanup
2026-02-17 18:01:52 -08:00
Waleed
61a5c98717 fix(shortlink): use redirect instead of rewrite for Beluga tracking (#3239) 2026-02-17 16:27:20 -08:00
32 changed files with 987 additions and 187 deletions

View File

@@ -3,6 +3,7 @@ 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'
@@ -29,6 +30,17 @@ 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 })

View File

@@ -3,6 +3,7 @@ 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 {
@@ -72,6 +73,15 @@ 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

View File

@@ -1,6 +1,7 @@
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'
@@ -71,6 +72,15 @@ 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}`,

View File

@@ -0,0 +1,14 @@
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(),
})
}

View File

@@ -0,0 +1,27 @@
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 })
}

View File

@@ -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 } from '@/stores/panel'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -341,16 +341,20 @@ 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
// Handle keyboard navigation - only for the active options selector when copilot is active
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' ||
@@ -387,7 +391,15 @@ export function OptionsSelector({
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect])
}, [
isInteractionDisabled,
enableKeyboardNav,
isLocked,
sortedOptions,
hoveredIndex,
onSelect,
activeTab,
])
if (sortedOptions.length === 0) return null

View File

@@ -36,17 +36,18 @@ export function isBlockProtected(blockId: string, blocks: Record<string, BlockSt
/**
* Checks if an edge is protected from modification.
* An edge is protected if either its source or target block is protected.
* An edge is protected only if its target block is protected.
* Outbound connections from locked blocks are allowed to be modified.
*
* @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
* @returns True if the edge is protected (target is locked)
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
return isBlockProtected(edge.target, blocks)
}
/**

View File

@@ -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 connected to protected blocks
// Prevent removing edges targeting 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/from protected blocks
// Prevent connections to protected blocks (outbound from locked blocks is allowed)
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 connected to protected blocks
// Prevent removing edges targeting protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
message: 'Cannot remove connections to 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 connected to protected blocks
// Get all selected edge IDs and filter out edges targeting protected blocks
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true

View File

@@ -223,13 +223,11 @@ 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)
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
) {
return acc
}

View File

@@ -106,6 +106,21 @@ 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',
@@ -390,6 +405,15 @@ 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)
@@ -1006,10 +1030,12 @@ export function MCP({ initialServerId }: MCPProps) {
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
const isFormValid = formData.name.trim() && formData.url?.trim()
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains)
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
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
@@ -1299,6 +1325,11 @@ 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]'>
@@ -1351,7 +1382,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button
variant='default'
onClick={handleEditTestConnection}
disabled={isEditTestingConnection || !isEditFormValid}
disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
>
{editTestButtonLabel}
</Button>
@@ -1361,7 +1392,9 @@ export function MCP({ initialServerId }: MCPProps) {
</Button>
<Button
onClick={handleSaveEdit}
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
disabled={
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
}
variant='tertiary'
>
{isUpdatingServer ? 'Saving...' : 'Save'}
@@ -1434,6 +1467,11 @@ 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]'>
@@ -1479,7 +1517,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button
variant='default'
onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValid}
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
>
{testButtonLabel}
</Button>
@@ -1489,7 +1527,9 @@ export function MCP({ initialServerId }: MCPProps) {
Cancel
</Button>
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'}
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
? 'Adding...'
: 'Add Server'}
</Button>
</div>
</div>

View File

@@ -7,6 +7,8 @@ export interface SubscriptionPermissions {
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
isEnterpriseMember: boolean
canViewUsageInfo: boolean
}
export interface SubscriptionState {
@@ -31,6 +33,9 @@ export function getSubscriptionPermissions(
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isTeamAdmin } = userRole
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
@@ -40,6 +45,8 @@ export function getSubscriptionPermissions(
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
isEnterpriseMember,
canViewUsageInfo,
}
}

View File

@@ -300,12 +300,16 @@ export function Subscription() {
)
const showBadge =
(permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked
!permissions.isEnterpriseMember &&
((permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked)
const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => {
if (permissions.isEnterpriseMember) {
return { text: '', variant: 'blue-secondary' }
}
if (permissions.showTeamMemberView || subscription.isEnterprise) {
return { text: `${subscription.seats} seats`, variant: 'blue-secondary' }
}
@@ -443,67 +447,75 @@ export function Subscription() {
return (
<div className='flex h-full flex-col gap-[20px]'>
{/* Current Plan & Usage Overview */}
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
{/* Current Plan & Usage Overview - hidden from enterprise members (non-admin) */}
{permissions.canViewUsageInfo ? (
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
) : (
<div className='flex items-center'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
{formatPlanName(subscription.plan)}
</span>
</div>
)}
{/* Upgrade Plans */}
{permissions.showUpgradePlans && (
@@ -539,8 +551,8 @@ export function Subscription() {
</div>
)}
{/* Credit Balance */}
{subscription.isPaid && (
{/* Credit Balance - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
@@ -554,10 +566,11 @@ export function Subscription() {
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{/* Next Billing Date - hidden from team members */}
{/* Next Billing Date - hidden from team members and enterprise members (non-admin) */}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView && (
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<Label>Next Billing Date</Label>
<span className='text-[12px] text-[var(--text-secondary)]'>
@@ -566,8 +579,8 @@ export function Subscription() {
</div>
)}
{/* Usage notifications */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{/* Usage notifications - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && <BillingUsageNotificationsToggle />}
{/* Cancel Subscription */}
{permissions.canCancelSubscription && (

View File

@@ -285,6 +285,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isPro = planType === 'pro'
const isTeam = planType === 'team'
const isEnterprise = planType === 'enterprise'
const isEnterpriseMember = isEnterprise && !userCanManageBilling
const handleUpgradeToPro = useCallback(async () => {
try {
@@ -463,6 +464,18 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
}
if (isEnterpriseMember) {
return (
<div className='flex flex-shrink-0 flex-col border-t px-[13.5px] pt-[8px] pb-[10px]'>
<div className='flex h-[18px] items-center'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{PLAN_NAMES[planType]}
</span>
</div>
</div>
)
}
return (
<>
<div

View File

@@ -0,0 +1,265 @@
/**
* @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)
}
})
})

View File

@@ -3,8 +3,13 @@ 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'
@@ -23,8 +28,12 @@ export class ProviderNotAllowedError extends Error {
}
export class IntegrationNotAllowedError extends Error {
constructor(blockType: string) {
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
constructor(blockType: string, reason?: string) {
super(
reason
? `Integration "${blockType}" is not allowed: ${reason}`
: `Integration "${blockType}" is not allowed based on your permission group settings`
)
this.name = 'IntegrationNotAllowedError'
}
}
@@ -57,11 +66,38 @@ 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 null
return mergeEnvAllowlist(null)
}
const [membership] = await db
@@ -71,12 +107,12 @@ export async function getUserPermissionConfig(
.limit(1)
if (!membership) {
return null
return mergeEnvAllowlist(null)
}
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
if (!isEnterprise) {
return null
return mergeEnvAllowlist(null)
}
const [groupMembership] = await db
@@ -92,10 +128,10 @@ export async function getUserPermissionConfig(
.limit(1)
if (!groupMembership) {
return null
return mergeEnvAllowlist(null)
}
return parsePermissionGroupConfig(groupMembership.config)
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
}
export async function getPermissionConfig(
@@ -152,19 +188,25 @@ export async function validateBlockType(
return
}
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null)
if (!config || config.allowedIntegrations === null) {
return
}
if (!config.allowedIntegrations.includes(blockType)) {
logger.warn('Integration blocked by permission group', { userId, blockType })
throw new IntegrationNotAllowedError(blockType)
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
)
}
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
import { Check, ChevronDown, Clipboard, 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]'>
<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>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)}
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)]'
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{copied ? (
<Check className='h-[14px] w-[14px]' />
<Check className='h-[13px] w-[13px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
<Clipboard className='h-[13px] w-[13px]' />
)}
<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]'>
<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>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(callbackUrl)}
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)]'
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{copied ? (
<Check className='h-[14px] w-[14px]' />
<Check className='h-[13px] w-[13px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
<Clipboard className='h-[13px] w-[13px]' />
)}
<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>

View File

@@ -17,6 +17,7 @@ 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,

View File

@@ -1,6 +1,7 @@
'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 {
@@ -21,12 +22,44 @@ 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 } = useUserPermissionConfig(activeOrganization?.id)
const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(
activeOrganization?.id
)
const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } =
useAllowedIntegrationsFromEnv()
const isLoading = isPermissionLoading || isEnvAllowlistLoading
const config = useMemo(() => {
if (accessControlDisabled) {
@@ -40,13 +73,18 @@ 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 (config.allowedIntegrations === null) return true
return config.allowedIntegrations.includes(blockType)
if (mergedAllowedIntegrations === null) return true
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
}
}, [config.allowedIntegrations])
}, [mergedAllowedIntegrations])
const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
@@ -57,13 +95,14 @@ export function usePermissionConfig(): PermissionConfigResult {
const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
if (config.allowedIntegrations === null) return blocks
if (mergedAllowedIntegrations === null) return blocks
return blocks.filter(
(block) =>
block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type)
block.type === 'start_trigger' ||
mergedAllowedIntegrations.includes(block.type.toLowerCase())
)
}
}, [config.allowedIntegrations])
}, [mergedAllowedIntegrations])
const filterProviders = useMemo(() => {
return (providerIds: string[]): string[] => {
@@ -77,9 +116,14 @@ export function usePermissionConfig(): PermissionConfigResult {
return featureFlagDisabled || config.disableInvitations
}, [config.disableInvitations])
const mergedConfig = useMemo(
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
[config, mergedAllowedIntegrations]
)
return useMemo(
() => ({
config,
config: mergedConfig,
isLoading,
isInPermissionGroup,
filterBlocks,
@@ -89,7 +133,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isInvitationsDisabled,
}),
[
config,
mergedConfig,
isLoading,
isInPermissionGroup,
filterBlocks,

View File

@@ -2,6 +2,7 @@ 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'
@@ -349,16 +350,14 @@ async function processBlockMetadata(
userId?: string
): Promise<AgentContext | null> {
try {
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
}
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
}
// 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> = {}
@@ -466,7 +465,6 @@ 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,
@@ -518,7 +516,6 @@ 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,

View File

@@ -6,6 +6,7 @@ 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'
@@ -439,9 +440,10 @@ export const getBlockConfigServerTool: BaseServerTool<
}
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) {
throw new Error(`Block "${blockType}" is not available`)
}

View File

@@ -6,6 +6,7 @@ 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'
@@ -59,9 +60,10 @@ export const getBlockOptionsServerTool: BaseServerTool<
}
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
throw new Error(`Block "${blockId}" is not available`)
}

View File

@@ -1,6 +1,7 @@
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'
@@ -17,7 +18,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
logger.debug('Executing get_blocks_and_tools')
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
type BlockListItem = {
type: string
@@ -30,7 +32,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
Object.entries(blockRegistry)
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return false
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase()))
return false
return true
})
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {

View File

@@ -3,6 +3,7 @@ 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'
@@ -112,11 +113,12 @@ 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
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
const result: Record<string, CopilotBlockMetadata> = {}
for (const blockId of blockIds || []) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
logger.debug('Block not allowed by permission group', { blockId })
continue
}
@@ -420,7 +422,6 @@ 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)
}

View File

@@ -1,6 +1,7 @@
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'
@@ -22,13 +23,15 @@ export const getTriggerBlocksServerTool: BaseServerTool<
logger.debug('Executing get_trigger_blocks')
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
const triggerBlockIds: string[] = []
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase()))
return
if (blockConfig.category === 'triggers') {
triggerBlockIds.push(blockType)

View File

@@ -657,7 +657,7 @@ export function isBlockTypeAllowed(
if (!permissionConfig || permissionConfig.allowedIntegrations === null) {
return true
}
return permissionConfig.allowedIntegrations.includes(blockType)
return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase())
}
/**

View File

@@ -93,6 +93,8 @@ 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

View File

@@ -123,6 +123,47 @@ 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
*/

View File

@@ -0,0 +1,163 @@
/**
* @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()
})
})
})

View File

@@ -0,0 +1,69 @@
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)
}
}

View File

@@ -10,6 +10,7 @@ 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,
@@ -93,6 +94,10 @@ class McpService {
return null
}
if (!isMcpDomainAllowed(server.url || undefined)) {
return null
}
return {
id: server.id,
name: server.name,
@@ -123,19 +128,21 @@ 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(),
}))
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))
}
/**

View File

@@ -324,20 +324,17 @@ 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

View File

@@ -193,6 +193,10 @@ 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