Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d96f6a1
feat(custom-block): deploy a workflow as a reusable org-scoped block
TheodoreSpeaks Jul 4, 2026
7c6b2f0
fix(custom-block): reseed deploy form, guard duplicate publish, run c…
TheodoreSpeaks Jul 4, 2026
4551c4c
Merge remote-tracking branch 'origin/staging' into feat/custom-block
TheodoreSpeaks Jul 4, 2026
11bca6b
test(custom-block): isolate custom-block rows fetch in execution-core…
TheodoreSpeaks Jul 4, 2026
3be6122
fix(custom-block): allow cross-workspace exec, org-scope authority, k…
TheodoreSpeaks Jul 4, 2026
dfe5c6f
feat(custom-block): run child under source owner's identity, workspac…
TheodoreSpeaks Jul 4, 2026
e120029
fix(custom-block): bind publish authz to the source workflow's workspace
TheodoreSpeaks Jul 4, 2026
2cdfb69
fix(custom-block): gate edit/delete on source-workspace admin, not or…
TheodoreSpeaks Jul 4, 2026
eebefa9
Merge remote-tracking branch 'origin/staging' into feat/custom-block
TheodoreSpeaks Jul 4, 2026
6f8ed0a
chore(custom-block): rebaseline route count to 887 after staging merge
TheodoreSpeaks Jul 4, 2026
dfe3166
fix(custom-block): sanitize failure output so it can't leak source wo…
TheodoreSpeaks Jul 4, 2026
892fd0e
fix(custom-block): derive inputs and curated outputs from deployed st…
TheodoreSpeaks Jul 4, 2026
8c1e003
fix(custom-block): hide disabled blocks from the toolbar palette too
TheodoreSpeaks Jul 4, 2026
0b5da48
fix(custom-block): bill nested + failed-run hosted cost; expose real …
TheodoreSpeaks Jul 4, 2026
3e1e453
fix(custom-block): enforce enterprise + flag gate at every consumptio…
TheodoreSpeaks Jul 4, 2026
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
94 changes: 94 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,94 @@
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,
getCustomBlockManageContext,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CustomBlockAPI')

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

/**
* Confirm the caller can manage (edit/delete) the block: admin of the block's
* SOURCE workflow's workspace — matching who could publish it. Org admins/owners
* hold admin on every org workspace, so they pass too; a workspace admin from a
* different workspace does not, so they cannot alter another workspace's block or
* its exposed outputs.
*/
async function authorizeManage(userId: string, id: string) {
const ctx = await getCustomBlockManageContext(id)
if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }

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

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 })
})
130 changes: 130 additions & 0 deletions apps/sim/app/api/custom-blocks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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,
workspaceId,
workflowId,
userId,
name,
description,
iconUrl,
exposedOutputs,
})
Comment thread
cursor[bot] marked this conversation as resolved.
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