feat(public-api): add env var and permission group controls to disable public API access (#3317)

Add DISABLE_PUBLIC_API / NEXT_PUBLIC_DISABLE_PUBLIC_API environment variables
and disablePublicApi permission group config option to allow self-hosted
deployments and enterprise admins to globally disable the public API toggle.

When disabled: the Access toggle is hidden in the Edit API Info modal,
the execute route blocks unauthenticated public access (401), and the
public-api PATCH route rejects enabling public API (403).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-02-23 23:03:03 -08:00
committed by GitHub
parent fe34d23a98
commit d4a014f423
19 changed files with 12746 additions and 40 deletions

View File

@@ -37,8 +37,8 @@ import {
EyeIcon,
FirecrawlIcon,
FirefliesIcon,
GitLabIcon,
GithubIcon,
GitLabIcon,
GmailIcon,
GongIcon,
GoogleBooksIcon,
@@ -71,9 +71,9 @@ import {
LinearIcon,
LinkedInIcon,
LinkupIcon,
MailServerIcon,
MailchimpIcon,
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftDataverseIcon,
MicrosoftExcelIcon,
@@ -106,8 +106,6 @@ import {
ResendIcon,
RevenueCatIcon,
S3Icon,
SQSIcon,
STTIcon,
SalesforceIcon,
SearchIcon,
SendgridIcon,
@@ -119,17 +117,19 @@ import {
SimilarwebIcon,
SlackIcon,
SmtpIcon,
SQSIcon,
SshIcon,
STTIcon,
StagehandIcon,
StripeIcon,
SupabaseIcon,
TTSIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
TinybirdIcon,
TranslateIcon,
TrelloIcon,
TTSIcon,
TwilioIcon,
TypeformIcon,
UpstashIcon,
@@ -140,11 +140,11 @@ import {
WhatsAppIcon,
WikipediaIcon,
WordpressIcon,
xIcon,
YouTubeIcon,
ZendeskIcon,
ZepIcon,
ZoomIcon,
xIcon,
} from '@/components/icons'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>

View File

@@ -145,4 +145,4 @@
"zep",
"zoom"
]
}
}

View File

@@ -51,6 +51,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
deployedAt: null,
apiKey: null,
needsRedeployment: false,
isPublicApi: workflowData.isPublicApi ?? false,
})
}
@@ -98,6 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
needsRedeployment,
isPublicApi: workflowData.isPublicApi ?? false,
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
@@ -301,6 +303,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const body = await request.json()
const { isPublicApi } = body
if (typeof isPublicApi !== 'boolean') {
return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400)
}
if (isPublicApi) {
const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import(
'@/ee/access-control/utils/permission-check'
)
try {
await validatePublicApiAllowed(session?.user?.id)
} catch (err) {
if (err instanceof PublicApiNotAllowedError) {
return createErrorResponse('Public API access is disabled', 403)
}
throw err
}
}
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
return createSuccessResponse({ isPublicApi })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error })
return createErrorResponse(message, 500)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }

View File

@@ -254,10 +254,49 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
let userId: string
let isPublicApiAccess = false
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const hasExplicitCredentials =
req.headers.has('x-api-key') || req.headers.get('authorization')?.startsWith('Bearer ')
if (hasExplicitCredentials) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { db: dbClient, workflow: workflowTable } = await import('@sim/db')
const { eq } = await import('drizzle-orm')
const [wf] = await dbClient
.select({
isPublicApi: workflowTable.isPublicApi,
isDeployed: workflowTable.isDeployed,
userId: workflowTable.userId,
})
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!wf?.isPublicApi || !wf.isDeployed) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
if (isPublicApiDisabled) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
const ownerConfig = await getUserPermissionConfig(wf.userId)
if (ownerConfig?.disablePublicApi) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
userId = wf.userId
isPublicApiAccess = true
} else {
userId = auth.userId
}
const userId = auth.userId
let body: any = {}
try {
@@ -284,7 +323,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual'
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
const {
selectedOutputs,
@@ -305,7 +344,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
| { startBlockId: string; sourceSnapshot: SerializableExecutionState }
| undefined
if (rawRunFromBlock) {
if (rawRunFromBlock.sourceSnapshot) {
if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
// Public API callers cannot inject arbitrary block state via sourceSnapshot.
// They must use executionId to resume from a server-stored execution state.
resolvedRunFromBlock = {
startBlockId: rawRunFromBlock.startBlockId,
sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
@@ -341,7 +382,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
? (() => {
const {
selectedOutputs,
@@ -360,7 +401,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})()
: validatedInput
const shouldUseDraftState = useDraftState ?? auth.authType === 'session'
// Public API callers must not inject arbitrary workflow state overrides (code injection risk).
// stopAfterBlockId and runFromBlock are safe — they control execution flow within the deployed state.
const sanitizedWorkflowStateOverride = isPublicApiAccess ? undefined : workflowStateOverride
// Public API callers always execute the deployed state, never the draft.
const shouldUseDraftState = isPublicApiAccess
? false
: (useDraftState ?? auth.authType === 'session')
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
@@ -533,7 +581,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
const effectiveWorkflowStateOverride = workflowStateOverride || cachedWorkflowData || undefined
const effectiveWorkflowStateOverride =
sanitizedWorkflowStateOverride || cachedWorkflowData || undefined
if (!enableSSE) {
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)

View File

@@ -21,6 +21,7 @@ interface WorkflowDeploymentInfo {
endpoint: string
exampleCommand: string
needsRedeployment: boolean
isPublicApi?: boolean
}
interface ApiDeployProps {
@@ -107,12 +108,12 @@ export function ApiDeploy({
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getPayloadObject()
const isPublic = info.isPublicApi
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -123,8 +124,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
)
@@ -135,8 +135,7 @@ print(response.json())`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -148,8 +147,7 @@ console.log(data);`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -166,12 +164,12 @@ console.log(data);`
if (!info) return ''
const endpoint = getBaseEndpoint()
const payload = getStreamPayloadObject()
const isPublic = info.isPublicApi
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -182,8 +180,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
stream=True
@@ -197,8 +194,7 @@ for line in response.iter_lines():
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -216,8 +212,7 @@ while (true) {
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json"
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
},
body: JSON.stringify(${JSON.stringify(payload)})
});
@@ -241,14 +236,14 @@ while (true) {
const endpoint = getBaseEndpoint()
const baseUrl = endpoint.split('/api/workflows/')[0]
const payload = getPayloadObject()
const isPublic = info.isPublicApi
switch (asyncExampleType) {
case 'execute':
switch (language) {
case 'curl':
return `curl -X POST \\
-H "X-API-Key: $SIM_API_KEY" \\
-H "Content-Type: application/json" \\
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
-H "X-Execution-Mode: async" \\
-d '${JSON.stringify(payload)}' \\
${endpoint}`
@@ -260,8 +255,7 @@ import requests
response = requests.post(
"${endpoint}",
headers={
"X-API-Key": os.environ.get("SIM_API_KEY"),
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
@@ -274,8 +268,7 @@ print(job) # Contains jobId and executionId`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})
@@ -288,8 +281,7 @@ console.log(job); // Contains jobId and executionId`
return `const response = await fetch("${endpoint}", {
method: "POST",
headers: {
"X-API-Key": process.env.SIM_API_KEY,
"Content-Type": "application/json",
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
"X-Execution-Mode": "async"
},
body: JSON.stringify(${JSON.stringify(payload)})

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
ButtonGroup,
ButtonGroupItem,
Input,
Label,
Modal,
@@ -16,6 +18,8 @@ import {
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,13 +44,20 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
const updatePublicApiMutation = useUpdatePublicApi()
const { isPublicApiDisabled } = usePermissionConfig()
const [description, setDescription] = useState('')
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key')
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const initialDescriptionRef = useRef('')
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
const initialAccessModeRef = useRef<'api_key' | 'public'>('api_key')
const starterBlockId = useMemo(() => {
for (const [blockId, block] of Object.entries(blocks)) {
@@ -71,6 +82,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
return normalizeInputFormatValue(blockValue) as NormalizedField[]
}, [starterBlockId, subBlockValues, blocks])
const accessModeInitializedRef = useRef(false)
useEffect(() => {
if (open) {
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
@@ -92,11 +105,24 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
setParamDescriptions(descriptions)
initialParamDescriptionsRef.current = { ...descriptions }
setSaveError(null)
accessModeInitializedRef.current = false
}
}, [open, workflowMetadata, inputFormat])
useEffect(() => {
if (open && deploymentData && !accessModeInitializedRef.current) {
const initialAccess = deploymentData.isPublicApi ? 'public' : 'api_key'
setAccessMode(initialAccess)
initialAccessModeRef.current = initialAccess
accessModeInitializedRef.current = true
}
}, [open, deploymentData])
const hasChanges = useMemo(() => {
if (description.trim() !== initialDescriptionRef.current.trim()) return true
if (accessMode !== initialAccessModeRef.current) return true
for (const field of inputFormat) {
const currentValue = (paramDescriptions[field.name] || '').trim()
@@ -105,7 +131,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
return false
}, [description, paramDescriptions, inputFormat])
}, [description, paramDescriptions, inputFormat, accessMode])
const handleParamDescriptionChange = (fieldName: string, value: string) => {
setParamDescriptions((prev) => ({
@@ -126,6 +152,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
setParamDescriptions({ ...initialParamDescriptionsRef.current })
setAccessMode(initialAccessModeRef.current)
onOpenChange(false)
}, [onOpenChange])
@@ -138,7 +165,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
setIsSaving(true)
setSaveError(null)
try {
if (accessMode !== initialAccessModeRef.current) {
await updatePublicApiMutation.mutateAsync({
workflowId,
isPublicApi: accessMode === 'public',
})
}
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
}
@@ -152,6 +187,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
}
onOpenChange(false)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update access settings'
setSaveError(message)
} finally {
setIsSaving(false)
}
@@ -165,6 +203,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
paramDescriptions,
setValue,
onOpenChange,
accessMode,
updatePublicApiMutation,
])
return (
@@ -187,6 +227,26 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
/>
</div>
{!isPublicApiDisabled && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Access
</Label>
<ButtonGroup
value={accessMode}
onValueChange={(val) => setAccessMode(val as 'api_key' | 'public')}
>
<ButtonGroupItem value='api_key'>API Key</ButtonGroupItem>
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
</ButtonGroup>
<p className='mt-1 text-[12px] text-[var(--text-secondary)]'>
{accessMode === 'public'
? 'Anyone can call this API without authentication. You will be billed for all usage.'
: 'Requires a valid API key to call this endpoint.'}
</p>
</div>
)}
{inputFormat.length > 0 && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
@@ -227,6 +287,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
)}
</ModalBody>
<ModalFooter>
{saveError && (
<p className='mr-auto text-[12px] text-[var(--text-error)]'>{saveError}</p>
)}
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>

View File

@@ -70,6 +70,7 @@ interface WorkflowDeploymentInfoUI {
endpoint: string
exampleCommand: string
needsRedeployment: boolean
isPublicApi: boolean
}
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
@@ -117,7 +118,7 @@ export function DeployModal({
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig } = usePermissionConfig()
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
@@ -214,9 +215,11 @@ export function DeployModal({
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: deploymentInfoData.needsRedeployment,
isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false),
}
}, [
deploymentInfoData,
isPublicApiDisabled,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,

View File

@@ -391,6 +391,12 @@ export function AccessControl() {
category: 'Collaboration',
configKey: 'disableInvitations' as const,
},
{
id: 'disable-public-api',
label: 'Public API',
category: 'Features',
configKey: 'disablePublicApi' as const,
},
],
[]
)
@@ -966,6 +972,7 @@ export function AccessControl() {
!editingConfig?.disableSkills &&
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations &&
!editingConfig?.disablePublicApi &&
!editingConfig?.hideDeployApi &&
!editingConfig?.hideDeployMcp &&
!editingConfig?.hideDeployA2a &&
@@ -987,6 +994,7 @@ export function AccessControl() {
disableSkills: allVisible,
hideTraceSpans: allVisible,
disableInvitations: allVisible,
disablePublicApi: allVisible,
hideDeployApi: allVisible,
hideDeployMcp: allVisible,
hideDeployA2a: allVisible,
@@ -1009,6 +1017,7 @@ export function AccessControl() {
!editingConfig?.disableSkills &&
!editingConfig?.hideTraceSpans &&
!editingConfig?.disableInvitations &&
!editingConfig?.disablePublicApi &&
!editingConfig?.hideDeployApi &&
!editingConfig?.hideDeployMcp &&
!editingConfig?.hideDeployA2a &&

View File

@@ -66,6 +66,13 @@ export class InvitationsNotAllowedError extends Error {
}
}
export class PublicApiNotAllowedError extends Error {
constructor() {
super('Public API access is not allowed based on your permission group settings')
this.name = 'PublicApiNotAllowedError'
}
}
/**
* Merges the env allowlist into a permission config.
* If `config` is null and no env allowlist is set, returns null.
@@ -296,3 +303,30 @@ export async function validateInvitationsAllowed(userId: string | undefined): Pr
throw new InvitationsNotAllowedError()
}
}
/**
* Validates if the user is allowed to enable public API access.
* Also checks the global feature flag.
*/
export async function validatePublicApiAllowed(userId: string | undefined): Promise<void> {
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
if (isPublicApiDisabled) {
logger.warn('Public API blocked by feature flag')
throw new PublicApiNotAllowedError()
}
if (!userId) {
return
}
const config = await getUserPermissionConfig(userId)
if (!config) {
return
}
if (config.disablePublicApi) {
logger.warn('Public API blocked by permission group', { userId })
throw new PublicApiNotAllowedError()
}
}

View File

@@ -32,6 +32,7 @@ export interface WorkflowDeploymentInfo {
deployedAt: string | null
apiKey: string | null
needsRedeployment: boolean
isPublicApi: boolean
}
/**
@@ -50,6 +51,7 @@ async function fetchDeploymentInfo(workflowId: string): Promise<WorkflowDeployme
deployedAt: data.deployedAt ?? null,
apiKey: data.apiKey ?? null,
needsRedeployment: data.needsRedeployment ?? false,
isPublicApi: data.isPublicApi ?? false,
}
}
@@ -614,3 +616,49 @@ export function useActivateDeploymentVersion() {
},
})
}
/**
* Variables for updating public API access
*/
interface UpdatePublicApiVariables {
workflowId: string
isPublicApi: boolean
}
/**
* Mutation hook for toggling a workflow's public API access.
* Invalidates deployment info query on success.
*/
export function useUpdatePublicApi() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workflowId, isPublicApi }: UpdatePublicApiVariables) => {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPublicApi }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to update public API setting')
}
return response.json()
},
onSuccess: (_, variables) => {
logger.info('Public API setting updated', {
workflowId: variables.workflowId,
isPublicApi: variables.isPublicApi,
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to update public API setting', { error })
},
})
}

View File

@@ -20,6 +20,7 @@ export interface PermissionConfigResult {
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
isInvitationsDisabled: boolean
isPublicApiDisabled: boolean
}
interface AllowedIntegrationsResponse {
@@ -116,6 +117,11 @@ export function usePermissionConfig(): PermissionConfigResult {
return featureFlagDisabled || config.disableInvitations
}, [config.disableInvitations])
const isPublicApiDisabled = useMemo(() => {
const featureFlagDisabled = isTruthy(getEnv('NEXT_PUBLIC_DISABLE_PUBLIC_API'))
return featureFlagDisabled || config.disablePublicApi
}, [config.disablePublicApi])
const mergedConfig = useMemo(
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
[config, mergedAllowedIntegrations]
@@ -131,6 +137,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
}),
[
mergedConfig,
@@ -141,6 +148,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
]
)
}

View File

@@ -297,6 +297,7 @@ export const env = createEnv({
// Invitations - for self-hosted deployments
DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments)
// Development Tools
REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only)
@@ -382,6 +383,7 @@ export const env = createEnv({
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
},
@@ -413,6 +415,7 @@ export const env = createEnv({
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,

View File

@@ -111,6 +111,12 @@ export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
*/
export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS)
/**
* Is public API access disabled globally
* When true, the public API toggle is hidden and public API access is blocked
*/
export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API)
/**
* Is React Grab enabled for UI element debugging
* When true and in development mode, enables React Grab for copying UI element context to clipboard

View File

@@ -14,6 +14,7 @@ export interface PermissionGroupConfig {
disableSkills: boolean
hideTemplates: boolean
disableInvitations: boolean
disablePublicApi: boolean
// Deploy Modal Tabs
hideDeployApi: boolean
hideDeployMcp: boolean
@@ -37,6 +38,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
disableSkills: false,
hideTemplates: false,
disableInvitations: false,
disablePublicApi: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
@@ -67,6 +69,7 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false,
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,

View File

@@ -202,6 +202,10 @@ app:
DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally
NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements
# Public API Access Control
DISABLE_PUBLIC_API: "" # Set to "true" to disable public API toggle globally
NEXT_PUBLIC_DISABLE_PUBLIC_API: "" # Set to "true" to hide public API toggle in UI
# SSO Configuration (Enterprise Single Sign-On)
# Set to "true" AFTER running the SSO registration script
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow" ADD COLUMN "is_public_api" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1107,6 +1107,13 @@
"when": 1771830035621,
"tag": "0158_amazing_hulk",
"breakpoints": true
},
{
"idx": 159,
"version": "7",
"when": 1771897746619,
"tag": "0159_magical_marten_broadcloak",
"breakpoints": true
}
]
}

View File

@@ -154,6 +154,7 @@ export const workflow = pgTable(
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedAt: timestamp('deployed_at'),
isPublicApi: boolean('is_public_api').notNull().default(false),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),