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 } /**