diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx
index 71f10cbea0e..29e0ebb241f 100644
--- a/apps/sim/app/account/settings/[section]/page.tsx
+++ b/apps/sim/app/account/settings/[section]/page.tsx
@@ -10,7 +10,7 @@ import {
parseSettingsPathSection,
} from '@/components/settings/navigation'
import { getSession } from '@/lib/auth'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
interface AccountSettingsSectionPageProps {
@@ -46,6 +46,7 @@ export default async function AccountSettingsSectionPage({
})
if (!parsed) notFound()
if (parsed === 'billing' && !isBillingEnabled) redirect(getAccountSettingsHref('general'))
+ if (parsed === 'copilot' && !isHosted) redirect(getAccountSettingsHref('general'))
if (parsed === 'admin' || parsed === 'mothership') {
const isSuperUser = await isPlatformAdmin(session.user.id)
if (!isSuperUser) notFound()
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
index e0afc85422e..59f9d1ccca2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
@@ -93,7 +93,13 @@ export function ToolCallItem({
return (
{BlockIcon && (
-
+ // Size via inline style: a custom block's image icon carries a trailing
+ // `size-full` that defeats size *classes* (it fills tiled surfaces), so a
+ // class-only size renders the uploaded icon at natural size here.
+
)}
{isExecuting ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index beac3a100cb..ec1dc3e630f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -97,6 +97,7 @@ export function SearchModal({
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
+ const currentWorkflowId = params.workflowId as string | undefined
const inputRef = useRef(null)
const [mounted, setMounted] = useState(false)
const { navigateToSettings } = useSettingsNavigation()
@@ -581,23 +582,25 @@ export function SearchModal({
*/
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
+ // A custom block is hidden on its own source workflow's canvas — placing it
+ // there recurses (same exclusion as the toolbar).
return filterAndCap(
- blocks,
+ blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId),
(b) => b.name,
deferredSearch,
(b) => b.searchValue
)
- }, [isOnWorkflowPage, blocks, deferredSearch])
+ }, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId])
const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(
- tools,
+ tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId),
(t) => t.name,
deferredSearch,
(t) => t.searchValue
)
- }, [isOnWorkflowPage, tools, deferredSearch])
+ }, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId])
const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index 50cd965a646..3519b23a036 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
+import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
@@ -120,6 +121,15 @@ export function SettingsSidebar({
}
if (item.selfHostedOverride && !isHosted) {
+ /**
+ * Org-plane sections route through the organization gate in
+ * `settings/[section]/page.tsx` (host organization + org-admin viewer),
+ * which 404s other viewers — mirror it here so the item never links to
+ * a dead page.
+ */
+ if (ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && !isOrgAdminOrOwner) {
+ return false
+ }
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index abd4a77a566..3de09fe074e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -1,6 +1,6 @@
'use client'
-import { memo, useEffect, useRef, useState } from 'react'
+import { memo, type ReactElement, useEffect, useRef, useState } from 'react'
import {
ChevronDown,
Chip,
@@ -14,6 +14,7 @@ import {
Plus,
Send,
Skeleton,
+ Tooltip,
} from '@sim/emcn'
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
@@ -33,6 +34,27 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
const logger = createLogger('WorkspaceHeader')
+interface DisabledReasonTooltipProps {
+ reason: string | null
+ children: ReactElement
+}
+
+/**
+ * Wraps a menu item in a tooltip explaining why the action is unavailable.
+ * Renders the child as-is when there is no reason to show.
+ */
+function DisabledReasonTooltip({ reason, children }: DisabledReasonTooltipProps) {
+ if (!reason) return children
+ return (
+
+ {children}
+
+ {reason}
+
+
+ )
+}
+
interface WorkspaceHeaderProps {
/** The active workspace object */
activeWorkspace?: { name: string } | null
@@ -548,62 +570,67 @@ function WorkspaceHeaderImpl({
+
+ {
+ e.stopPropagation()
+ if (!canCreateWorkspace) return
+ setIsWorkspaceMenuOpen(false)
+ setIsCreateModalOpen(true)
+ }}
+ disabled={isCreatingWorkspace}
+ aria-disabled={!canCreateWorkspace || undefined}
+ fullWidth
+ flush
+ className={cn(
+ 'select-none',
+ !canCreateWorkspace &&
+ 'cursor-not-allowed opacity-60 hover-hover:bg-transparent'
+ )}
+ >
+ New workspace
+
+
+
+
+
+
{
- e.stopPropagation()
+ leftIcon={Send}
+ onClick={() => {
setIsWorkspaceMenuOpen(false)
- if (!canCreateWorkspace) {
+ if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
- setIsCreateModalOpen(true)
+ setIsInviteModalOpen(true)
}}
- disabled={isCreatingWorkspace}
- title={createWorkspaceDisabledReason ?? undefined}
fullWidth
flush
- className='w-full select-none disabled:pointer-events-none disabled:opacity-50'
+ className='select-none'
>
- New workspace
+ Invite teammates
-
-
-
- {
- setIsWorkspaceMenuOpen(false)
- if (isInvitationsDisabled) {
- if (isBillingEnabled) navigateToSettings({ section: 'billing' })
- return
- }
- setIsInviteModalOpen(true)
- }}
- title={inviteDisabledReason ?? undefined}
- fullWidth
- flush
- className='w-full select-none'
- >
- Invite teammates
-
- {
- setIsWorkspaceMenuOpen(false)
- if (isInvitationsDisabled) {
- if (isBillingEnabled) navigateToSettings({ section: 'billing' })
- return
- }
- navigateToSettings({ section: 'teammates' })
- }}
- title={inviteDisabledReason ?? undefined}
- fullWidth
- flush
- className='w-full select-none'
- >
- Manage workspace
-
+
+
+ {
+ setIsWorkspaceMenuOpen(false)
+ if (isInvitationsDisabled) {
+ if (isBillingEnabled) navigateToSettings({ section: 'billing' })
+ return
+ }
+ navigateToSettings({ section: 'teammates' })
+ }}
+ fullWidth
+ flush
+ className='select-none'
+ >
+ Manage workspace
+
+
>
)}
diff --git a/apps/sim/blocks/custom/build-config.test.ts b/apps/sim/blocks/custom/build-config.test.ts
index 68aa7b5a457..83122e6a6d3 100644
--- a/apps/sim/blocks/custom/build-config.test.ts
+++ b/apps/sim/blocks/custom/build-config.test.ts
@@ -8,6 +8,7 @@ import {
CUSTOM_BLOCK_TILE_COLOR,
type CustomBlockRow,
isCustomBlockType,
+ isReservedOutputName,
} from '@/blocks/custom/build-config'
import type { BlockIcon } from '@/blocks/types'
@@ -33,6 +34,18 @@ describe('isCustomBlockType', () => {
})
})
+describe('isReservedOutputName', () => {
+ it('rejects the system output fields case-insensitively', () => {
+ expect(isReservedOutputName('cost')).toBe(true)
+ expect(isReservedOutputName('Cost')).toBe(true)
+ expect(isReservedOutputName(' success ')).toBe(true)
+ expect(isReservedOutputName('error')).toBe(true)
+ expect(isReservedOutputName('result')).toBe(false)
+ expect(isReservedOutputName('cost_2')).toBe(false)
+ expect(isReservedOutputName('summary')).toBe(false)
+ })
+})
+
describe('buildCustomBlockConfig', () => {
const fields: WorkflowInputField[] = [
{ name: 'title', type: 'string' },
@@ -47,6 +60,7 @@ describe('buildCustomBlockConfig', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(config.type).toBe('custom_block_abc123')
expect(config.name).toBe('Invoice Parser')
+ expect(config.sourceWorkflowId).toBe('wf-1')
expect(config.category).toBe('tools')
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
expect(config.hideFromToolbar).toBeUndefined()
diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts
index 62cc473e7c9..e8f02973d5e 100644
--- a/apps/sim/blocks/custom/build-config.ts
+++ b/apps/sim/blocks/custom/build-config.ts
@@ -62,6 +62,20 @@ export const RESERVED_PARAMS = new Set([
'advancedMode',
])
+/**
+ * Output names the block projects itself (`success`/`error` from `buildOutputs`,
+ * `cost` from the executor's billing aggregation). A user-named exposed output
+ * must never shadow these — an output literally named `cost` would clobber the
+ * billed cost. `result` is deliberately NOT reserved: it only exists as a system
+ * field when no outputs are curated, which cannot co-occur with a named output.
+ */
+export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost'])
+
+/** Whether an exposed-output name collides with a system output field. */
+export function isReservedOutputName(name: string): boolean {
+ return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase())
+}
+
/** Map a Start input field type to the editor sub-block type used to collect it. */
function subBlockTypeForField(fieldType: string): SubBlockType {
switch (fieldType) {
@@ -122,6 +136,7 @@ export function buildCustomBlockConfig(
type: row.type,
name: row.name,
description: row.description,
+ sourceWorkflowId: row.workflowId,
category: 'tools',
longDescription:
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index d063f43d38a..4bd7b82d0bf 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -468,6 +468,12 @@ export interface BlockConfig {
}
}
hideFromToolbar?: boolean
+ /**
+ * For published custom blocks only: the bound source workflow's id. Discovery
+ * surfaces use it to hide a workflow's own block on that workflow's canvas
+ * (placing it would recurse).
+ */
+ sourceWorkflowId?: string
/**
* Marks an unreleased block. Preview blocks are hidden from every discovery
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —
diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts
index a803bdf8c26..f70ab7b06ee 100644
--- a/apps/sim/blocks/utils.ts
+++ b/apps/sim/blocks/utils.ts
@@ -159,6 +159,17 @@ function getProviderFromStore(model: string): string | null {
return null
}
+/**
+ * Whether an Ollama instance is available. `isOllamaConfigured` reads the
+ * server-only `OLLAMA_URL` env var, which is always undefined in the browser —
+ * there the providers store (populated from the server's model list, which is
+ * non-empty only when Ollama is configured) is the signal.
+ */
+function isOllamaAvailable(): boolean {
+ if (isOllamaConfigured) return true
+ return useProvidersStore.getState().providers.ollama.models.length > 0
+}
+
function buildModelVisibilityCondition(model: string, shouldShow: boolean) {
if (!model) {
return { field: 'model', value: '__no_model_selected__' }
@@ -197,7 +208,7 @@ function shouldRequireApiKeyForModel(model: string): boolean {
return false
if (storeProvider) return true
- if (isOllamaConfigured) {
+ if (isOllamaAvailable()) {
if (normalizedModel.includes('/')) return true
if (normalizedModel in getBaseModelProviders()) return true
return false
diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts
index 69d2b9d5029..2348ced7a97 100644
--- a/apps/sim/components/settings/navigation.test.ts
+++ b/apps/sim/components/settings/navigation.test.ts
@@ -11,6 +11,7 @@ import {
getOrganizationSettingsHref,
getWorkspaceSettingsHref,
isOrganizationSettingsSectionAvailable,
+ ORGANIZATION_PLANE_UNIFIED_SECTIONS,
ORGANIZATION_SETTINGS_ITEMS,
ORGANIZATION_SETTINGS_PATH_ALIASES,
parseSettingsPathSection,
@@ -109,6 +110,19 @@ describe('settings navigation boundaries', () => {
expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort())
})
+ it('derives the organization-plane unified sections from the registry', () => {
+ expect([...ORGANIZATION_PLANE_UNIFIED_SECTIONS].sort()).toEqual([
+ 'access-control',
+ 'audit-logs',
+ 'billing',
+ 'data-drains',
+ 'data-retention',
+ 'organization',
+ 'sso',
+ 'whitelabeling',
+ ])
+ })
+
it('shares labels, icons, and docs links across projections', () => {
const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso')
const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso')
diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts
index 2c3446e9203..c03e332de9e 100644
--- a/apps/sim/components/settings/navigation.ts
+++ b/apps/sim/components/settings/navigation.ts
@@ -671,6 +671,18 @@ export const ORGANIZATION_SETTINGS_ITEMS: SettingsNavigationItem[] =
buildPlaneSettingsItems('workspace')
+/**
+ * Unified sections that resolve to organization-plane settings. The workspace
+ * settings section page routes these through the organization gate (host
+ * organization present + org-admin viewer), so workspace-plane navigation must
+ * apply the same requirement before surfacing them.
+ */
+export const ORGANIZATION_PLANE_UNIFIED_SECTIONS: ReadonlySet = new Set(
+ SETTINGS_SECTION_REGISTRY.flatMap((entry) =>
+ entry.planes?.organization ? [entry.unified.id] : []
+ )
+)
+
export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage'
interface ResolveOrganizationSectionAccessOptions {
diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx
index 70eead4cb3e..43911876480 100644
--- a/apps/sim/components/settings/standalone-settings-shell.tsx
+++ b/apps/sim/components/settings/standalone-settings-shell.tsx
@@ -21,7 +21,7 @@ import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settin
import { SettingsSectionProvider } from '@/components/settings/settings-panel'
import { SettingsSidebar } from '@/components/settings/settings-sidebar'
import { useSettingsBeforeUnload } from '@/components/settings/use-settings-before-unload'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
interface StandaloneSettingsShellBaseProps {
children: ReactNode
@@ -52,6 +52,7 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
const organizationFeatures = getOrganizationSettingsFeatures(hasEnterprisePlan)
const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => {
if (item.id === 'billing' && !isBillingEnabled) return false
+ if (item.id === 'copilot' && !isHosted) return false
if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false
return true
})
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
index 7eb676229d2..f60a85b95ce 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -32,7 +32,11 @@ import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/compo
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
-import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-config'
+import {
+ type CustomBlockInput,
+ type CustomBlockOutput,
+ isReservedOutputName,
+} from '@/blocks/custom/build-config'
import { SettingRow } from '@/ee/components/setting-row'
import {
useCustomBlocks,
@@ -56,12 +60,12 @@ const decodeOutput = (value: string) => {
: { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) }
}
-/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */
+/** Derive a unique, friendly output name from a dot-path, avoiding collisions and reserved names. */
function deriveOutputName(path: string, taken: Set): string {
const base = (path.split('.').pop() || path).replace(/[^a-zA-Z0-9_]/g, '_')
let name = base
let n = 2
- while (taken.has(name)) name = `${base}_${n++}`
+ while (taken.has(name) || isReservedOutputName(name)) name = `${base}_${n++}`
taken.add(name)
return name
}
@@ -360,6 +364,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setError('Output names must be unique')
return
}
+ const reserved = exposedOutputs.find((o) => isReservedOutputName(o.name))
+ if (reserved) {
+ setError(`"${reserved.name}" is a reserved output name (success, error, cost)`)
+ return
+ }
// Only the placeholder and required flag are authored; the field set/name/type
// are always derived from the deployed Start. Persist only non-empty overrides.
const inputPlaceholders = visibleInputs
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
index db21354265f..3f13cead423 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
@@ -518,6 +518,45 @@ describe('WorkflowBlockHandler', () => {
})
})
})
+
+ describe('projectCustomBlockOutput', () => {
+ const childResult = {
+ success: true,
+ output: { data: 'whole result' },
+ logs: [{ blockId: 'b1', success: true, output: { data: { x: 42 }, price: 999 } }],
+ }
+
+ it('maps each curated output to its named field plus system fields', () => {
+ const result = (handler as any).projectCustomBlockOutput(
+ childResult,
+ [{ blockId: 'b1', path: 'data.x', name: 'answer' }],
+ 0.5
+ )
+
+ expect(result).toEqual({ answer: 42, success: true, cost: { total: 0.5 } })
+ })
+
+ it('never lets an exposed output named cost clobber the billed cost', () => {
+ const result = (handler as any).projectCustomBlockOutput(
+ childResult,
+ [{ blockId: 'b1', path: 'price', name: 'cost' }],
+ 0.5
+ )
+
+ expect(result.cost).toEqual({ total: 0.5 })
+ expect(result.success).toBe(true)
+ })
+
+ it('exposes the whole child result when no outputs are curated', () => {
+ const result = (handler as any).projectCustomBlockOutput(childResult, [], 0.5)
+
+ expect(result).toEqual({
+ success: true,
+ result: { data: 'whole result' },
+ cost: { total: 0.5 },
+ })
+ })
+ })
})
describe('remapCustomBlockInputKeys', () => {
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 7656e008044..406dea79b8f 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -888,14 +888,15 @@ export class WorkflowBlockHandler implements BlockHandler {
return { success: true, result: executionResult.output ?? {}, ...cost }
}
const logs = executionResult.logs ?? []
- const output: Record = { success: true, ...cost }
+ const output: Record = {}
for (const { blockId, path, name } of exposedOutputs) {
const log =
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
[...logs].reverse().find((l) => l.blockId === blockId)
output[name] = log ? getValueAtPath(log.output, path) : undefined
}
- return output as BlockOutput
+ // System fields spread last — pre-validation rows may still name an output cost/success.
+ return { ...output, success: true, ...cost } as BlockOutput
}
private mapChildOutputToParent(
diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts
index e7629109324..77da3a0213b 100644
--- a/apps/sim/lib/api/contracts/custom-blocks.ts
+++ b/apps/sim/lib/api/contracts/custom-blocks.ts
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
+import { isReservedOutputName } from '@/blocks/custom/build-config'
const inputFieldSchema = z.object({
/** Stable per-field id — preserved so client block configs key sub-blocks on it
@@ -36,6 +37,21 @@ const exposedOutputSchema = z.object({
name: z.string().min(1).max(60),
})
+/**
+ * Publish/update variant: rejects reserved system output names (`success`,
+ * `error`, `cost`) that would shadow the block's own projected fields. The
+ * read schema stays lenient so rows that predate this validation still parse.
+ */
+const exposedOutputWriteSchema = exposedOutputSchema.extend({
+ name: z
+ .string()
+ .min(1)
+ .max(60)
+ .refine((name) => !isReservedOutputName(name), {
+ message: 'Output name is reserved (success, error, cost)',
+ }),
+})
+
export const customBlockSchema = z.object({
id: z.string(),
organizationId: z.string(),
@@ -87,7 +103,7 @@ export const publishCustomBlockBodySchema = z.object({
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
/** Curated outputs; omit/empty to expose the child's whole result. */
- exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
+ exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
})
export type PublishCustomBlockBody = z.input
@@ -104,7 +120,7 @@ export const updateCustomBlockBodySchema = z
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
iconUrl: iconUrlSchema.nullable().optional(),
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
- exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
+ exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts
index 64a0d7cdb33..2df2aeae735 100644
--- a/apps/sim/lib/core/config/env-flags.ts
+++ b/apps/sim/lib/core/config/env-flags.ts
@@ -46,9 +46,18 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy(
export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED)
/**
- * Is billing enforcement enabled
+ * Is billing enforcement enabled.
+ *
+ * Server code reads `BILLING_ENABLED`. Server-only vars never reach browser
+ * bundles, so client evaluation reads the `NEXT_PUBLIC_BILLING_ENABLED` twin
+ * (via `window.__ENV`, populated by ``) — reading
+ * `env.BILLING_ENABLED` in client code is always `undefined`. Deployments must
+ * set both vars together.
*/
-export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
+export const isBillingEnabled =
+ typeof window === 'undefined'
+ ? isTruthy(env.BILLING_ENABLED)
+ : isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Block free-plan accounts from programmatic workflow execution (API key, public
@@ -154,18 +163,32 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
/**
- * Is access control (permission groups) enabled via env var override
- * This bypasses plan requirements for self-hosted deployments
+ * Is access control (permission groups) enabled via env var override.
+ * This bypasses plan requirements for self-hosted deployments.
+ *
+ * Server code reads `ACCESS_CONTROL_ENABLED`; the browser reads the
+ * `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` twin (see {@link isBillingEnabled}).
*/
-export const isAccessControlEnabled = isTruthy(env.ACCESS_CONTROL_ENABLED)
+export const isAccessControlEnabled =
+ typeof window === 'undefined'
+ ? isTruthy(env.ACCESS_CONTROL_ENABLED)
+ : isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
/**
- * Is organizations enabled
+ * Is organizations enabled.
* True if billing is enabled (orgs come with billing), OR explicitly enabled via env var,
- * OR if access control is enabled (access control requires organizations)
+ * OR if access control is enabled (access control requires organizations).
+ *
+ * Each term resolves through its `NEXT_PUBLIC_*` twin in the browser (see
+ * {@link isBillingEnabled}), so client code — e.g. the better-auth
+ * `organizationClient` plugin registration — sees the same value as the server.
*/
export const isOrganizationsEnabled =
- isBillingEnabled || isTruthy(env.ORGANIZATIONS_ENABLED) || isAccessControlEnabled
+ isBillingEnabled ||
+ (typeof window === 'undefined'
+ ? isTruthy(env.ORGANIZATIONS_ENABLED)
+ : isTruthy(getEnv('NEXT_PUBLIC_ORGANIZATIONS_ENABLED'))) ||
+ isAccessControlEnabled
/**
* Is inbox (Sim Mailer) enabled via env var override
diff --git a/apps/sim/lib/workflows/custom-blocks/operations.test.ts b/apps/sim/lib/workflows/custom-blocks/operations.test.ts
new file mode 100644
index 00000000000..a16a5726b40
--- /dev/null
+++ b/apps/sim/lib/workflows/custom-blocks/operations.test.ts
@@ -0,0 +1,67 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/billing/core/subscription', () => ({
+ isOrganizationOnEnterprisePlan: vi.fn(),
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/input-format', () => ({
+ extractInputFieldsFromBlocks: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ loadDeployedWorkflowState: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getWorkspaceWithOwner: vi.fn(),
+}))
+
+import {
+ CustomBlockValidationError,
+ publishCustomBlock,
+ updateCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+
+const publishParams = {
+ organizationId: 'org-1',
+ workspaceId: 'ws-1',
+ workflowId: 'wf-1',
+ userId: 'user-1',
+ name: 'Enrich Lead',
+ description: '',
+}
+
+describe('reserved exposed-output names', () => {
+ it('publishCustomBlock rejects an output named cost', async () => {
+ await expect(
+ publishCustomBlock({
+ ...publishParams,
+ exposedOutputs: [{ blockId: 'b1', path: 'price', name: 'cost' }],
+ })
+ ).rejects.toThrow(CustomBlockValidationError)
+ })
+
+ it('publishCustomBlock rejects reserved names case-insensitively', async () => {
+ await expect(
+ publishCustomBlock({
+ ...publishParams,
+ exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'Success' }],
+ })
+ ).rejects.toThrow('"Success" is a reserved output name (success, error, cost)')
+ })
+
+ it('updateCustomBlock rejects a reserved output name', async () => {
+ await expect(
+ updateCustomBlock('cb-1', {
+ exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'error' }],
+ })
+ ).rejects.toThrow(CustomBlockValidationError)
+ })
+})
diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts
index 643867dd611..ad3ff18bd1a 100644
--- a/apps/sim/lib/workflows/custom-blocks/operations.ts
+++ b/apps/sim/lib/workflows/custom-blocks/operations.ts
@@ -15,7 +15,7 @@ import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/wor
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { CustomBlockOutput, CustomBlockRow } from '@/blocks/custom/build-config'
-import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config'
+import { CUSTOM_BLOCK_TYPE_PREFIX, isReservedOutputName } from '@/blocks/custom/build-config'
const logger = createLogger('CustomBlocksOperations')
@@ -362,6 +362,19 @@ export class CustomBlockValidationError extends Error {
}
}
+/**
+ * Reject exposed outputs whose name shadows a system output field. Authoritative
+ * check: also covers callers that bypass the HTTP contract (copilot handler).
+ */
+function assertNoReservedOutputNames(exposedOutputs: CustomBlockOutput[] | undefined): void {
+ const reserved = exposedOutputs?.find((o) => isReservedOutputName(o.name))
+ if (reserved) {
+ throw new CustomBlockValidationError(
+ `"${reserved.name}" is a reserved output name (success, error, cost)`
+ )
+ }
+}
+
/**
* Publish a deployed workflow as an org-wide custom block. The source workflow
* must live in `workspaceId` — the workspace the caller was verified to admin —
@@ -393,6 +406,8 @@ export async function publishCustomBlock(params: {
exposedOutputs,
} = params
+ assertNoReservedOutputNames(exposedOutputs)
+
const [wf] = await db
.select({
id: workflow.id,
@@ -487,6 +502,7 @@ export async function updateCustomBlock(
exposedOutputs?: CustomBlockOutput[]
}
): Promise {
+ if (updates.exposedOutputs !== undefined) assertNoReservedOutputNames(updates.exposedOutputs)
const patch: Partial = { updatedAt: new Date() }
if (updates.name !== undefined) patch.name = updates.name
if (updates.description !== undefined) patch.description = updates.description
diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts
index 65a84b2d627..efce810b781 100644
--- a/apps/sim/stores/modals/search/store.ts
+++ b/apps/sim/stores/modals/search/store.ts
@@ -105,6 +105,7 @@ export const useSearchModalStore = create()(
bgColor: block.bgColor || '#6B7280',
type: block.type,
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
+ sourceWorkflowId: block.sourceWorkflowId,
}
if (block.category === 'blocks' && block.type !== 'starter') {
diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts
index b8315e8156e..32146b9472b 100644
--- a/apps/sim/stores/modals/search/types.ts
+++ b/apps/sim/stores/modals/search/types.ts
@@ -12,6 +12,8 @@ export interface SearchBlockItem {
type: string
config?: BlockConfig
searchValue?: string
+ /** Custom blocks only: bound source workflow id — hidden on that workflow's canvas. */
+ sourceWorkflowId?: string
}
/**