-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(custom-block): deploy a workflow as a reusable org-scoped block #5407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TheodoreSpeaks
wants to merge
6
commits into
staging
Choose a base branch
from
feat/custom-block
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+19,642
−54
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d96f6a1
feat(custom-block): deploy a workflow as a reusable org-scoped block
TheodoreSpeaks 7c6b2f0
fix(custom-block): reseed deploy form, guard duplicate publish, run c…
TheodoreSpeaks 4551c4c
Merge remote-tracking branch 'origin/staging' into feat/custom-block
TheodoreSpeaks 11bca6b
test(custom-block): isolate custom-block rows fetch in execution-core…
TheodoreSpeaks 3be6122
fix(custom-block): allow cross-workspace exec, org-scope authority, k…
TheodoreSpeaks dfe5c6f
feat(custom-block): run child under source owner's identity, workspac…
TheodoreSpeaks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }) | ||
| 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 | ||
| } | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
42 changes: 42 additions & 0 deletions
42
apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
49 changes: 49 additions & 0 deletions
49
apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } | ||
| ) | ||
| ) | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }, [data]) | ||
|
|
||
| return null | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
workspaceIdin the request body only.publishCustomBlockverifies 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)
apps/sim/lib/workflows/custom-blocks/operations.ts#L204-L208Reviewed by Cursor Bugbot for commit 7c6b2f0. Configure here.