Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/sim/app/api/custom-blocks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
deleteCustomBlockContract,
updateCustomBlockContract,
} from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
CustomBlockValidationError,
deleteCustomBlock,
getCustomBlockById,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CustomBlockAPI')

type RouteContext = { params: Promise<{ id: string }> }

/** Load the block and confirm the caller is an admin/owner of its organization. */
async function authorizeManage(userId: string, id: string) {
const block = await getCustomBlockById(id)
if (!block) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }

if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: block.organizationId }))) {
return {
error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
}
}
if (!(await isOrganizationAdminOrOwner(userId, block.organizationId))) {
return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) }
}
return { block }
}

export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(updateCustomBlockContract, request, context)
if (!parsed.success) return parsed.response

const { id } = parsed.data.params
const authz = await authorizeManage(session.user.id, id)
if (authz.error) return authz.error

const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body
try {
await updateCustomBlock(id, {
name,
description,
enabled,
iconUrl,
exposedOutputs,
})
return NextResponse.json({ success: true as const })
} catch (error) {
if (error instanceof CustomBlockValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Failed to update custom block', { id, error: getErrorMessage(error) })
throw error
}
})

export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(deleteCustomBlockContract, request, context)
if (!parsed.success) return parsed.response

const { id } = parsed.data.params
const authz = await authorizeManage(session.user.id, id)
if (authz.error) return authz.error

await deleteCustomBlock(id)
return NextResponse.json({ success: true as const })
})
129 changes: 129 additions & 0 deletions apps/sim/app/api/custom-blocks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
listCustomBlocksContract,
publishCustomBlockContract,
} from '@/lib/api/contracts/custom-blocks'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
CustomBlockValidationError,
type CustomBlockWithInputs,
listCustomBlocksWithInputs,
publishCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
import {
checkWorkspaceAccess,
getWorkspaceWithOwner,
hasWorkspaceAdminAccess,
} from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CustomBlocksAPI')

/** Wire shape for a custom block. Keeps the icon field name explicit for the client. */
function toWire(block: CustomBlockWithInputs) {
return {
id: block.id,
organizationId: block.organizationId,
workflowId: block.workflowId,
type: block.type,
name: block.name,
description: block.description,
iconUrl: block.iconUrl,
enabled: block.enabled,
inputFields: block.inputFields,
exposedOutputs: block.exposedOutputs,
}
}

export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(listCustomBlocksContract, request, {})
if (!parsed.success) return parsed.response

const userId = session.user.id
const { workspaceId } = parsed.data.query

const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}

const organizationId = access.workspace?.organizationId
if (!organizationId) {
return NextResponse.json({ enabled: false, customBlocks: [] })
}

if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
return NextResponse.json({ enabled: false, customBlocks: [] })
}

const enabled = await isOrganizationOnEnterprisePlan(organizationId)
const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : []
return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) })
})

export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(publishCustomBlockContract, request, {})
if (!parsed.success) return parsed.response

const userId = session.user.id
const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body

if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) {
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
}

const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws?.organizationId) {
return NextResponse.json(
{ error: 'Publishing a block requires the workspace to belong to an organization' },
{ status: 400 }
)
}
const organizationId = ws.organizationId

if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 })
}

if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
return NextResponse.json(
{ error: 'Deploy as block requires an enterprise plan' },
{ status: 403 }
)
}

try {
const block = await publishCustomBlock({
organizationId,
workflowId,
userId,
name,
description,
iconUrl,
exposedOutputs,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Publish without source workspace admin

Medium Severity

Publishing checks workspace admin on the workspaceId in the request body only. publishCustomBlock verifies the target workflow belongs to that workspace’s organization, not that the workflow lives in that workspace or that the caller admins the workflow’s home workspace.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7c6b2f0. Configure here.

return NextResponse.json({ customBlock: toWire(block) })
} catch (error) {
if (error instanceof CustomBlockValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Failed to publish custom block', { error: getErrorMessage(error) })
throw error
}
})
19 changes: 13 additions & 6 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
Expand All @@ -72,6 +73,7 @@ import {
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
Expand Down Expand Up @@ -859,12 +861,17 @@ async function handleExecutePost(
variables: deployedVariables,
}

const serializedWorkflow = new Serializer().serializeWorkflow(
workflowData.blocks,
workflowData.edges,
workflowData.loops,
workflowData.parallels,
false
// Custom blocks resolve only inside the org overlay; wrap this pre-execution
// serialize (used for input file-field discovery) the same way the core does.
const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
new Serializer().serializeWorkflow(
workflowData.blocks,
workflowData.edges,
workflowData.loops,
workflowData.parallels,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
false
)
)

const executionContext = {
Expand Down
42 changes: 42 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import { useState } from 'react'
import { cn } from '@sim/emcn'

interface DropZoneProps {
onDrop: (e: React.DragEvent) => void
children: React.ReactNode
className?: string
}

/** File drop target with a dashed accent overlay while dragging. Shared by the
* whitelabeling settings and the deploy-as-block icon upload. */
export function DropZone({ onDrop, children, className }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)

return (
<div
className={cn('relative', className)}
onDragOver={(e) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault()
setIsDragging(true)
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragging(false)
}
}}
onDrop={(e) => {
setIsDragging(false)
onDrop(e)
}}
>
{children}
{isDragging && (
<div className='pointer-events-none absolute inset-0 z-10 rounded-lg border-[1.5px] border-[var(--brand-accent)] border-dashed bg-[color-mix(in_srgb,var(--brand-accent)_8%,transparent)]' />
)}
</div>
)
}
2 changes: 2 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
Expand Down Expand Up @@ -43,6 +44,7 @@ export default async function WorkspaceLayout({
<ToastProvider>
<SettingsLoader />
<ProviderModelsLoader />
<CustomBlocksLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'

const DEFAULT_TREE_PANE_WIDTH = 240
Expand Down Expand Up @@ -667,7 +668,10 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
const endedAt = parseTime(span.endTime)

const metaEntries: { label: string; value: string }[] = []
metaEntries.push({ label: 'Type', value: span.type })
metaEntries.push({
label: 'Type',
value: isCustomBlockType(span.type) ? 'custom block' : span.type,
})
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client'

import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay'
import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'

/**
* Hydrates the client custom-block registry overlay from the active workspace's
* org custom blocks. Mounted once in the workspace layout so every surface that
* resolves blocks synchronously — the canvas, the block palette, copilot mentions,
* and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on
* workspace switch (the query key changes) and on any publish/edit/unpublish.
*/
export function CustomBlocksLoader() {
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const { data } = useCustomBlocks(workspaceId)

useEffect(() => {
hydrateClientCustomBlocks(
// Only enabled blocks are resolvable/executable server-side, so the client
// overlay (toolbar, canvas, palette) must exclude disabled ones too — else
// the block is offered but every run fails.
(data ?? [])
.filter((block) => block.enabled)
.map((block) =>
buildCustomBlockConfig(
{
type: block.type,
name: block.name,
description: block.description,
workflowId: block.workflowId,
exposedOutputs: block.exposedOutputs,
},
block.inputFields,
{
icon: getCustomBlockIcon(block.iconUrl),
bgColor: block.iconUrl ? 'transparent' : undefined,
}
)
)
)
Comment thread
cursor[bot] marked this conversation as resolved.
}, [data])

return null
}
Loading
Loading