-
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
Merged
+19,802
−54
Merged
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 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 e120029
fix(custom-block): bind publish authz to the source workflow's workspace
TheodoreSpeaks 2cdfb69
fix(custom-block): gate edit/delete on source-workspace admin, not or…
TheodoreSpeaks eebefa9
Merge remote-tracking branch 'origin/staging' into feat/custom-block
TheodoreSpeaks 6f8ed0a
chore(custom-block): rebaseline route count to 887 after staging merge
TheodoreSpeaks dfe3166
fix(custom-block): sanitize failure output so it can't leak source wo…
TheodoreSpeaks 892fd0e
fix(custom-block): derive inputs and curated outputs from deployed st…
TheodoreSpeaks 8c1e003
fix(custom-block): hide disabled blocks from the toolbar palette too
TheodoreSpeaks 0b5da48
fix(custom-block): bill nested + failed-run hosted cost; expose real …
TheodoreSpeaks 3e1e453
fix(custom-block): enforce enterprise + flag gate at every consumptio…
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,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 }) | ||
| }) |
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,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, | ||
| }) | ||
| 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.
Uh oh!
There was an error while loading. Please reload this page.