diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index a0e7129090..6cf5cceee4 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -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> diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index b933326cea..5a4f2219f3 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -145,4 +145,4 @@ "zep", "zoom" ] -} \ No newline at end of file +} diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index e82221c835..f0e00be97b 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -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 }> } diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 294a9b2f88..8c07d3b3fd 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -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)`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index e4d968c13c..6472b324ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -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)}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index 132545bda6..1dbda8f218 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -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>({}) + const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key') const [isSaving, setIsSaving] = useState(false) + const [saveError, setSaveError] = useState(null) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const initialDescriptionRef = useRef('') const initialParamDescriptionsRef = useRef>({}) + 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 /> + {!isPublicApiDisabled && ( +
+ + setAccessMode(val as 'api_key' | 'public')} + > + API Key + Public + +

+ {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.'} +

+
+ )} + {inputFormat.length > 0 && (