diff --git a/.claude/rules/global.md b/.claude/rules/global.md index afd2290e37d..8ae6e0e874c 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -68,6 +68,9 @@ const clone = structuredClone(obj) const filtered = filterUndefined(obj) ``` +## Deployment flags in the browser +Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. + ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/CLAUDE.md b/CLAUDE.md index 773e3bccf51..cc25ad704c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ You are a professional software engineer. All code must follow best practices: a - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline +- **Deployment flags in the browser**: client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context instead. Server code keeps reading `env-flags` - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` - **Type-checking**: Run `bun run type-check` (per workspace) or `bunx turbo run type-check` (all of them). Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, but it is what makes a bare `tsc` resolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that `@typescript/typescript6` pulls in transitively. `bun run check:native-typecheck` enforces this diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index 9b21c25ed31..38e7ef2038a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger' import type { BatchInvitationResult } from '@/lib/api/contracts/invitations' import { useSession } from '@/lib/auth/auth-client' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { quickValidateEmail } from '@/lib/messaging/email/validation' import type { PermissionType } from '@/lib/workspaces/permissions/utils' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -141,6 +141,7 @@ export function InviteModal({ } const { data: session } = useSession() + const { billingEnabled } = useDeploymentShape() const isOrganizationInvite = Boolean(organizationId) const sendInvitations = useSendWorkspaceInvitations() @@ -190,7 +191,7 @@ export function InviteModal({ hostContext.viewer.isHostOrganizationAdmin const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', { - enabled: open && isBillingEnabled && canViewOrganizationBilling, + enabled: open && billingEnabled && canViewOrganizationBilling, }) const totalSeats = organizationBillingData?.data?.totalSeats ?? 0 diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx index 8eea65cc85f..e188eae0f80 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx @@ -10,8 +10,9 @@ import { type ReactElement, type ReactNode, } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -287,7 +288,9 @@ vi.mock('@/components/ui', () => ({ ), })) -vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: true })) +/** SSO is a deployment feature, read through the deployment shape at render time. */ +beforeAll(() => setEnvFlags({ isSsoEnabled: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => null, })) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index d2ba8aeda37..298f5906f5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -18,7 +18,7 @@ import { Check, Link, Send } from '@sim/emcn/icons' import { generateShortId } from '@sim/utils/id' import { GeneratedPasswordInput } from '@/components/ui' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { validateAllowlistEntry } from '@/lib/messaging/email/validation' import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -75,6 +75,7 @@ export function ShareModal({ const { config: permissionConfig } = usePermissionConfig() const upsertShare = useUpsertFileShare() const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) + const { features } = useDeploymentShape() const shareReadReady = isFetchedAfterMount && !isShareError const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null) @@ -91,7 +92,7 @@ export function ShareModal({ const isAuthTypeAllowed = (mode: ShareAuthType) => allowedAuthTypes === null || allowedAuthTypes.includes(mode) - const ssoEnabled = isSsoEnabled || savedAccessMode === 'sso' + const ssoEnabled = features.sso || savedAccessMode === 'sso' const candidateAuthTypes: ShareAuthType[] = [ 'public', 'password', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx index 0785ff9a53e..1accd4478d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx @@ -9,13 +9,14 @@ import { useSession } from '@/lib/auth/auth-client' import { formatCredits } from '@/lib/billing/credits/conversion' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace' import { useWorkspaceCreditAvailability } from '@/hooks/queries/workspace-usage' export function CreditsChip() { - if (!isBillingEnabled) return null + const { billingEnabled } = useDeploymentShape() + if (!billingEnabled) return null return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index a543f294b12..6fd2a7ea41a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -21,7 +21,7 @@ import { useSession } from '@/lib/auth/auth-client' import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' @@ -2990,16 +2990,17 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { getSettingsHref } = useSettingsNavigation() + const { hosted } = useDeploymentShape() const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' // Self-hosted plan and limit both live on the hosted account, so local // workspace billing roles say nothing about who may change them. - const href = isHosted + const href = hosted ? getSettingsHref({ section: 'billing' }) : data.action === 'upgrade_plan' ? buildHostedUpgradeUrl() : HOSTED_BILLING_SETTINGS_URL - const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id) + const canManageBilling = !hosted || canManageWorkspaceBilling(hostContext, session?.user?.id) const unavailableMessage = hostContext.hostOrganizationId ? 'Contact an organization admin to manage this workspace’s usage limits.' : 'Only the workspace owner can manage this workspace’s usage limits.' @@ -3032,13 +3033,13 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {canManageBilling ? ( {buttonLabel} - {isHosted ? : } + {hosted ? : } ) : (

{unavailableMessage}

diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 075c690f70f..d04cd899859 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,7 +6,7 @@ import { ArrowLeft, Plus } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { blockTypeToIconMap, type Integration, @@ -69,6 +69,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration const suggestedSkills = getSuggestedSkillsForBlock(integration.type) const oauthService = resolveOAuthServiceForIntegration(integration) const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig() + const { chatEnabled } = useDeploymentShape() const availability = integrationAvailability.get(integration.type.toLowerCase()) const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true) const [oauthOpen, setOAuthOpen] = useState(false) @@ -197,7 +198,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) : ( Unavailable ) - ) : isChatEnabled ? ( + ) : chatEnabled ? ( Add to Sim @@ -279,7 +280,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration {/* Every template hands its prompt to Chat, so the section has no destination without it. */} - {isChatEnabled && matchingTemplates.length > 0 && ( + {chatEnabled && matchingTemplates.length > 0 && ( - {isChatEnabled && ( + {chatEnabled && ( (null) @@ -451,7 +452,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP * mothership-triggered logs are excluded — `isLikelyExecution` already encodes * "has an executionId and isn't a mothership run". */ - const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution + const canTroubleshoot = chatEnabled && log.status === 'failed' && isLikelyExecution /** * Hands the failed run to Chat. When a chat is already mounted (e.g. the run diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx new file mode 100644 index 00000000000..b509c374f67 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseWorkspaceHostContextQuery } = vi.hoisted(() => ({ + mockUseWorkspaceHostContextQuery: vi.fn(), +})) + +vi.mock('@/hooks/queries/workspace-host', () => ({ + useWorkspaceHostContextQuery: mockUseWorkspaceHostContextQuery, +})) + +vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({ + WorkspaceAccessDenied: () => , +})) + +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, +} from '@/lib/core/config/deployment-shape' +import { + useWorkspaceHostContext, + WorkspaceHostProvider, +} from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const HOST_CONTEXT: WorkspaceHostContext = { + workspace: { + id: 'workspace-1', + name: 'Workspace', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }, + hostOrganizationId: 'org-1', + ownerBilling: { + plan: 'team', + status: 'active', + isPaid: true, + isPro: false, + isTeam: true, + isEnterprise: false, + isOrgScoped: true, + organizationId: 'org-1', + billingInterval: 'month', + billingBlocked: false, + billingBlockedReason: null, + }, + viewer: { + permission: 'admin', + isHostOrganizationMember: true, + isHostOrganizationAdmin: true, + }, + deployment: { + ...resolveDeploymentShape(), + hosted: true, + billingEnabled: true, + }, +} + +/** Reads the getter during render, the way block conditions do. */ +function GetterReader() { + return {String(getDeploymentShape().hosted)} +} + +function ContextReader() { + const { deployment } = useWorkspaceHostContext() + return {String(deployment?.billingEnabled)} +} + +let host: HTMLDivElement +let root: Root + +function renderProvider(initialContext: WorkspaceHostContext) { + act(() => + root.render( + + + + + ) + ) +} + +function textOf(testId: string): string | undefined { + return host.querySelector(`[data-testid="${testId}"]`)?.textContent ?? undefined +} + +beforeEach(() => { + resetDeploymentShape() + mockUseWorkspaceHostContextQuery.mockReturnValue({ data: undefined, error: null }) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +describe('WorkspaceHostProvider', () => { + it('seeds the server deployment shape before workspace children render', () => { + renderProvider(HOST_CONTEXT) + + expect(textOf('getter')).toBe('true') + expect(textOf('context')).toBe('true') + expect(getDeploymentShape()).toBe(HOST_CONTEXT.deployment) + }) + + it('follows a host context that arrives after mount over the initial seed', () => { + renderProvider(HOST_CONTEXT) + expect(textOf('context')).toBe('true') + expect(getDeploymentShape().billingEnabled).toBe(true) + + mockUseWorkspaceHostContextQuery.mockReturnValue({ + data: { + ...HOST_CONTEXT, + deployment: { ...HOST_CONTEXT.deployment!, billingEnabled: false }, + }, + error: null, + }) + renderProvider(HOST_CONTEXT) + + expect(textOf('context')).toBe('false') + expect(getDeploymentShape().billingEnabled).toBe(false) + }) + + it('keeps the env fallback for a host context that predates deployment projection', () => { + const { deployment: _legacy, ...legacyContext } = HOST_CONTEXT + + renderProvider(legacyContext) + + expect(textOf('getter')).toBe('false') + expect(textOf('context')).toBe('undefined') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx index 9aa14b79562..b4ff6a1c872 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx @@ -1,13 +1,28 @@ 'use client' -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, type ReactNode, useContext, useEffect, useState } from 'react' import { isApiClientError } from '@/lib/api/client/errors' -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { seedDeploymentShape } from '@/lib/core/config/deployment-shape' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' const WorkspaceHostContextValue = createContext(null) +/** + * Seeds from the provider's own render, ahead of any child, so the first workspace + * paint already reads the server value; the effect then follows the host context as + * it refetches. The lazy initializer is React's once-per-mount hook for work that must + * precede children. Lives here rather than with the reader because block definitions + * import the reader into React Server Component graphs, where React hooks are rejected. + */ +function useSeedDeploymentShape(shape: DeploymentShape | undefined): void { + useState(() => seedDeploymentShape(shape)) + useEffect(() => { + seedDeploymentShape(shape) + }, [shape]) +} + interface WorkspaceHostProviderProps { children: ReactNode workspaceId: string @@ -16,8 +31,10 @@ interface WorkspaceHostProviderProps { /** * Provides route-derived workspace host identity and entitlements to workspace - * UI. A later 403 (for example after access is revoked) replaces the workspace - * tree with an explicit denial instead of navigating to another workspace. + * UI, and seeds the server-resolved deployment shape for readers outside React + * before any workspace child renders. A later 403 (for example after access is + * revoked) replaces the workspace tree with an explicit denial instead of + * navigating to another workspace. */ export function WorkspaceHostProvider({ children, @@ -25,13 +42,15 @@ export function WorkspaceHostProvider({ initialContext, }: WorkspaceHostProviderProps) { const { data, error } = useWorkspaceHostContextQuery(workspaceId) + const context = data ?? initialContext + useSeedDeploymentShape(context.deployment) if (isApiClientError(error) && error.status === 403) { return } return ( - + {children} ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 8295fa0b230..7407f7af1fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -4,13 +4,13 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { captureEvent } from '@/lib/posthog/client' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { getSettingsSectionMeta, - isBillingEnabled, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -131,13 +131,14 @@ interface SettingsPageProps { export function SettingsPage({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const hostContext = useWorkspaceHostContext() + const { billingEnabled } = useDeploymentShape() const posthog = usePostHog() const isAdminRole = session?.user?.role === 'admin' const normalizedSection: SettingsSection = (section as string) === 'subscription' ? 'billing' : section const effectiveSection = - !isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization') + !billingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization') ? 'general' : normalizedSection === 'admin' && !sessionLoading && !isAdminRole ? 'general' @@ -183,7 +184,7 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'apikeys' && } - {isBillingEnabled && effectiveSection === 'billing' && ( + {billingEnabled && effectiveSection === 'billing' && ( )} {effectiveSection === 'teammates' && } - {isBillingEnabled && effectiveSection === 'organization' && organizationId && ( + {billingEnabled && effectiveSection === 'organization' && organizationId && ( ({ canMutateWorkspaceSettingsSection: () => mocks.canManageWorkspace.current, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) +/** Hosted-only scope switching; read through the deployment shape at render time. */ +beforeAll(() => setEnvFlags({ isHosted: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => mocks.hostContext.current, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index b4bf221de6f..b7b3e19d43f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -46,7 +46,7 @@ import { } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { type BYOKProviderId, MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -400,10 +400,11 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const hostContext = useWorkspaceHostContext() const workspacePermissions = useUserPermissionsContext() + const { hosted } = useDeploymentShape() const canManageWorkspace = canMutateWorkspaceSettingsSection('byok', workspacePermissions) const hostOrganizationId = hostContext.hostOrganizationId const canSelectOrganization = Boolean( - isHosted && hostOrganizationId && hostContext.viewer.isHostOrganizationAdmin + hosted && hostOrganizationId && hostContext.viewer.isHostOrganizationAdmin ) const [requestedScope, setRequestedScope] = useQueryState(byokScopeParam.key, { ...byokScopeParam.parser, @@ -415,7 +416,7 @@ export function BYOK() { const isOrganizationScope = effectiveScope === 'organization' const organizationQueryId = isOrganizationScope ? (hostOrganizationId ?? undefined) : undefined const inheritedStatusWorkspaceId = - !isOrganizationScope && isHosted && hostOrganizationId ? workspaceId : undefined + !isOrganizationScope && hosted && hostOrganizationId ? workspaceId : undefined const workspaceKeys = useBYOKKeys(workspaceId) const organizationKeys = useOrganizationBYOKKeys(organizationQueryId, { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx index c61567d7aad..765f10a50a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx @@ -3,7 +3,7 @@ import { ArrowLeft, Label, Switch } from '@sim/emcn' import { requestJson } from '@/lib/api/client/request' import { telemetryContract } from '@/lib/api/contracts/telemetry' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -25,6 +25,7 @@ interface PrivacyViewProps { export function PrivacyView({ onBack }: PrivacyViewProps) { const { data: settings } = useGeneralSettings() const updateSetting = useUpdateGeneralSetting() + const { hosted } = useDeploymentShape() const handleTelemetryToggle = async (checked: boolean) => { if (checked === settings?.telemetryEnabled || updateSetting.isPending) return @@ -65,7 +66,7 @@ export function PrivacyView({ onBack }: PrivacyViewProps) { - {isHosted && } + {hosted && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index d42d9798ec5..03d0d5de409 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -24,7 +24,7 @@ import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { signOut, useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' import { DeleteAccountModal } from '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal' @@ -80,6 +80,7 @@ export function General() { const router = useRouter() const brandConfig = useBrandConfig() const { data: session } = useSession() + const { hosted } = useDeploymentShape() const { data: profile, isLoading: isProfileLoading } = useUserProfile() const updateProfile = useUpdateUserProfile() @@ -276,7 +277,7 @@ export function General() { } const actions: SettingsAction[] = [ - ...(isHosted + ...(hosted ? [ { id: 'home-page', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index bc624c67293..cc8b0dbe8f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -9,7 +9,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' @@ -193,6 +193,7 @@ export function RecentlyDeleted() { const router = useRouter() const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() + const { chatEnabled } = useDeploymentShape() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates( recentlyDeletedParsers, @@ -254,7 +255,7 @@ export function RecentlyDeleted() { // query's loading/error state feeds the whole panel's. const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived', - enabled: queryPlan.chats && isChatEnabled, + enabled: queryPlan.chats && chatEnabled, }) const restoreWorkflow = useRestoreWorkflow() @@ -274,7 +275,7 @@ export function RecentlyDeleted() { queryPlan.tableFolders ? tableFoldersQuery : null, queryPlan.files ? filesQuery : null, queryPlan.workspaceFolders ? workspaceFoldersQuery : null, - queryPlan.chats && isChatEnabled ? chatsQuery : null, + queryPlan.chats && chatEnabled ? chatsQuery : null, ] const isLoading = activeQueryStates.some((query) => query?.isLoading) const error = activeQueryStates.find((query) => query?.error)?.error diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index b8ba236cf22..de8294ee6c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -15,7 +15,7 @@ import { import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { WorkspacePermission } from '@/lib/api/contracts/workspaces' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { MemberRow, @@ -83,6 +83,7 @@ export function Teammates() { const workspaceId = (params?.workspaceId as string) || '' const [searchTerm, setSearchTerm] = useSettingsSearch() + const { billingEnabled } = useDeploymentShape() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const { data: permissions, isPending: permissionsLoading } = @@ -121,7 +122,7 @@ export function Teammates() { const handleInvite = () => { if (isInvitationsDisabled) { - if (isBillingEnabled) router.push(upgradeHref) + if (billingEnabled) router.push(upgradeHref) return } setIsInviteModalOpen(true) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 57de4bd4a04..8427439f40a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -1,6 +1,5 @@ import { - buildUnifiedSettingsNavigation, - SETTINGS_NAVIGATION_BILLING_ENABLED, + buildUnifiedSettingsCatalog, toSettingsHeaderMeta, type UnifiedNavigationSection, type UnifiedSettingsNavigationItem, @@ -14,8 +13,6 @@ export type NavigationSection = UnifiedNavigationSection export type NavigationItem = UnifiedSettingsNavigationItem -export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED - export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, { key: 'workspace', title: 'Workspace' }, @@ -23,7 +20,8 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'platform', title: 'Platform' }, ] -export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation() +/** Unfiltered; the sidebar applies deployment and entitlement visibility from the host context. */ +export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsCatalog() /** * Catalog entries indexed by id. Every routed navigation resolves a section, so the diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 1fab391fee1..0ca5b49384f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -20,7 +20,7 @@ import { Check, TriangleAlert } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GeneratedPasswordInput } from '@/components/ui' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' import { validateAllowlistEntry } from '@/lib/messaging/email/validation' import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' @@ -696,6 +696,7 @@ function AuthSelector({ error, }: AuthSelectorProps) { const revealPasswordMutation = useRevealChatPassword() + const { features } = useDeploymentShape() /** * Editing or regenerating the password clears a failed reveal. The mutation @@ -711,7 +712,7 @@ function AuthSelector({ const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes const ssoAvailable = - isSsoEnabled || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false) + features.sso || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false) const baseAuthOptions: AuthType[] = ssoAvailable ? ['public', 'password', 'email', 'sso'] : ['public', 'password', 'email'] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 3d43ad468b6..7b40f491164 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Combobox, type ComboboxOption, cn } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { useReactFlow } from '@xyflow/react' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import type { SelectorKey } from '@/lib/selectors/manifest' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' @@ -127,6 +128,13 @@ export const ComboBox = memo(function ComboBox({ : undefined ) + /** + * Option builders such as the model list and the Function block's languages read the + * deployment shape outside React, so the list is keyed on the subscribed shape as well: + * a host context that lands after mount (an app version rolling out the field) must + * re-evaluate them rather than leave the env fallback's list in place. + */ + const deploymentShape = useDeploymentShape() const staticOptions = useMemo(() => { const opts = typeof options === 'function' @@ -138,7 +146,7 @@ export const ComboBox = memo(function ComboBox({ } return opts - }, [options, blockValues, subBlockId, isModelUsable]) + }, [options, blockValues, subBlockId, isModelUsable, deploymentShape]) const [selectorSearch, setSelectorSearch] = useState('') const debouncedSelectorSearch = useDebounce(selectorSearch.trim(), SEARCH_DEBOUNCE_MS) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts index 8b0ead03c46..2517562fea2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts @@ -1,4 +1,4 @@ -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' interface UseUsageLimitsOptions { @@ -9,7 +9,8 @@ interface UseUsageLimitsOptions { * Exposes the routed workspace's payer/member execution gate. */ export function useUsageLimits({ workspaceId }: UseUsageLimitsOptions) { - const { data, isLoading } = useWorkspaceUsageGate(isBillingEnabled ? workspaceId : undefined) + const { billingEnabled } = useDeploymentShape() + const { data, isLoading } = useWorkspaceUsageGate(billingEnabled ? workspaceId : undefined) return { usageExceeded: data?.isExceeded ?? false, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 261460b2a29..7b77fb5678b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -41,7 +41,7 @@ import { import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -138,6 +138,7 @@ export const Panel = memo(function Panel() { const routeWorkflowId = params.workflowId as string | undefined const posthog = usePostHog() + const { chatEnabled } = useDeploymentShape() const posthogRef = useRef(posthog) const panelRef = useRef(null) @@ -178,7 +179,7 @@ export const Panel = memo(function Panel() { * `hidden`, so a persisted `activeTab: 'copilot'` would hide all three and * paint an empty panel — resolve it to the toolbar instead. */ - const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot + const isCopilotTabAvailable = chatEnabled && !permissionConfig.hideCopilot const activeTab: PanelTab = storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx index f5b94af5817..e9b2b01e5ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx @@ -2,7 +2,7 @@ import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import type { ContextMenuPosition, TerminalFilters, @@ -40,6 +40,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onClearConsole, onFixInCopilot, }: LogRowContextMenuProps) { + const { chatEnabled } = useDeploymentShape() const hasRunId = entry?.executionId != null const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false @@ -74,7 +75,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ )} {/* Fix in Chat - only for error rows */} - {isChatEnabled && entry && !entry.success && ( + {chatEnabled && entry && !entry.success && ( <> { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 3adcaed1d64..a9f1ca6513e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -33,7 +33,7 @@ import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/shared' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -640,6 +640,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const contentRef = useRef(null) const params = useParams() + const { chatEnabled } = useDeploymentShape() const workspaceId = params.workspaceId as string const { @@ -1287,7 +1288,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ }} sunsetStatus={sunset?.status} sunsetTooltip={sunset?.tooltip} - canFixSunset={canEditWorkflow && isChatEnabled} + canFixSunset={canEditWorkflow && chatEnabled} onFixSunset={onFixSunset} shouldShowScheduleBadge={shouldShowScheduleBadge} scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 4024fa40810..3227d420c59 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -34,10 +34,6 @@ vi.mock('posthog-js/react', () => ({ usePostHog: () => ({}), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isChatEnabled: true, -})) - vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) 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 433fd0e97e1..6dfc70e6bbc 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 @@ -41,7 +41,7 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { getFolderPathNames } from '@/lib/folders/tree' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -154,6 +154,7 @@ function SearchModalContent({ }: SearchModalContentProps) { const params = useParams() const router = useRouter() + const { chatEnabled } = useDeploymentShape() const workspaceId = params.workspaceId as string const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) @@ -384,7 +385,7 @@ function SearchModalContent({ }, } ) - if (isChatEnabled) { + if (chatEnabled) { list.push({ id: 'new-chat', name: 'New chat', @@ -667,6 +668,7 @@ function SearchModalContent({ workspaceId, canEdit, canAdmin, + chatEnabled, pageContext, onCreateWorkflow, onCreateFolder, @@ -709,14 +711,19 @@ function SearchModalContent({ * way back the ask row unmounts while cmdk still remembers it as selected, * so Home re-anchors the selection once the result rows are back. */ - const handleSearchKeyDown = useCallback((event: ReactKeyboardEvent) => { - if (event.key !== 'Tab' || !isChatEnabled) return - event.preventDefault() - setAskMode((mode) => !mode) - requestAnimationFrame(() => { - inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) - }) - }, []) + const handleSearchKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== 'Tab' || !chatEnabled) return + event.preventDefault() + setAskMode((mode) => !mode) + requestAnimationFrame(() => { + inputRef.current?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }) + ) + }) + }, + [chatEnabled] + ) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1383,7 +1390,7 @@ function SearchModalContent({ {askMode ? '⇥ Search' : '⇥ Ask Sim'} 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 6e2be871da3..84d6954032a 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 @@ -12,20 +12,22 @@ import { import { ChevronLeft } from '@sim/emcn/icons' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' -import type { DesktopSettingsSurface } from '@/components/settings/navigation' -import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' +import { + type DesktopSettingsSurface, + isSelfHostedOverrideEnabled, + ORGANIZATION_PLANE_UNIFIED_SECTIONS, +} from '@/components/settings/navigation' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { hasBrowserAgent, hasDesktopSettings, hasTerminal } from '@/lib/desktop' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { allNavigationItems, - isBillingEnabled, sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' @@ -105,10 +107,12 @@ export function SettingsSidebar({ const { data: session } = useSession() const hostContext = useWorkspaceHostContext() + const deployment = useDeploymentShape() + const { hosted, billingEnabled } = deployment const { data: generalSettings } = useGeneralSettings() const { data: inboxConfig } = useInboxConfig(workspaceId) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({ - enabled: !isHosted, + enabled: !hosted, }) const { config: permissionConfig } = usePermissionConfig() @@ -127,18 +131,22 @@ export function SettingsSidebar({ const isSuperUser = session?.user?.role === 'admin' const isSSOProviderOwner = useMemo(() => { - if (isHosted) return null + if (hosted) return null if (!userId || isLoadingSSO) return null return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false - }, [userId, ssoProvidersData?.providers, isLoadingSSO]) + }, [hosted, userId, ssoProvidersData?.providers, isLoadingSSO]) const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.requiresSelfHosted && hosted) { + return false + } + if (item.requiresDesktopSurface && !desktopSurfaces[item.requiresDesktopSurface]) { return false } - if (item.hideWhenBillingDisabled && !isBillingEnabled) { + if (item.hideWhenBillingDisabled && !billingEnabled) { return false } @@ -178,7 +186,7 @@ export function SettingsSidebar({ return false } - if (item.selfHostedOverride && !isHosted) { + if (isSelfHostedOverrideEnabled(item.selfHostedOverride, deployment)) { /** * Org-plane sections route through the organization gate in * `settings/[section]/page.tsx` (host organization + org-admin viewer), @@ -213,7 +221,7 @@ export function SettingsSidebar({ return false } - if (item.requiresHosted && !isHosted) { + if (item.requiresHosted && !hosted) { return false } @@ -230,6 +238,9 @@ export function SettingsSidebar({ return true }) }, [ + deployment, + hosted, + billingEnabled, hasTeamPlan, hasEnterprisePlan, isEnterprisePlan, @@ -369,7 +380,10 @@ export function SettingsSidebar({ const active = activeSection === item.id const section = item.id as SettingsSection const href = getSettingsHref({ section }) - const selfHostedUnlocked = Boolean(item.selfHostedOverride && !isHosted) + const selfHostedUnlocked = isSelfHostedOverrideEnabled( + item.selfHostedOverride, + deployment + ) const isLocked = !selfHostedUnlocked && item.requiresMax && diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx index c72aeec6c1b..0684acb4c24 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const desktopMocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -31,7 +32,9 @@ vi.mock('@/lib/auth/auth-client', () => ({ vi.mock('@/lib/billing/workspace-permissions', () => ({ canViewWorkspaceBillingSettings: () => true, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) +/** Billing routes the invitations-disabled row to Subscription; read at render time. */ +beforeAll(() => setEnvFlags({ isBillingEnabled: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/lib/workspaces/colors', () => ({ getUserColor: () => '#000000' })) vi.mock('@/hooks/use-workspace-invite-policy', () => ({ useWorkspaceInvitePolicy: () => ({ isInvitationsDisabled: false }), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index bd9c180d979..612a927149b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -22,7 +22,7 @@ import { SlackIcon } from '@/components/icons' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getDesktopUpdates } from '@/lib/desktop' import { getUserColor } from '@/lib/workspaces/colors' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -133,6 +133,7 @@ export function SidebarFooter({ const { data: profile } = useUserProfile() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() + const { billingEnabled } = useDeploymentShape() const { isInvitationsDisabled } = useWorkspaceInvitePolicy(workspaceId) const updateState = useDesktopUpdateState() @@ -169,7 +170,7 @@ export function SidebarFooter({ */ const resolveMenuDestination = (section: SettingsSection): SettingsSection | null => { if (section === 'teammates' && isInvitationsDisabled) { - return isBillingEnabled ? 'billing' : null + return billingEnabled ? 'billing' : null } return section } 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 f188a591562..e69b89f8653 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 @@ -25,7 +25,7 @@ import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -160,6 +160,7 @@ function WorkspaceHeaderImpl({ onExpandSidebar, }: WorkspaceHeaderProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const { billingEnabled } = useDeploymentShape() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const [isViewInvitationsOpen, setIsViewInvitationsOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -840,7 +841,7 @@ function WorkspaceHeaderImpl({ onClick={() => { setIsWorkspaceMenuOpen(false) if (isInvitationsDisabled) { - if (isBillingEnabled) navigateToSettings({ section: 'billing' }) + if (billingEnabled) navigateToSettings({ section: 'billing' }) return } setIsInviteModalOpen(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index d8574684642..530e5a3dc6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -38,7 +38,8 @@ import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' @@ -422,6 +423,7 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() const { workspace: routeWorkspace } = useWorkspaceHostContext() + const { hosted, chatEnabled } = useDeploymentShape() const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() const { config: permissionConfig, @@ -780,13 +782,13 @@ export const Sidebar = memo(function Sidebar({ [ { id: 'home', - label: isChatEnabled ? 'New chat' : 'New workflow', - icon: isChatEnabled ? Home : Plus, - href: isChatEnabled ? `/workspace/${workspaceId}/home` : undefined, - onClick: isChatEnabled ? undefined : createWorkflow, + label: chatEnabled ? 'New chat' : 'New workflow', + icon: chatEnabled ? Home : Plus, + href: chatEnabled ? `/workspace/${workspaceId}/home` : undefined, + onClick: chatEnabled ? undefined : createWorkflow, // Creation navigates optimistically, so a read-only member would land // on a workflow the server declined to create. - hidden: !isChatEnabled && !permissionsLoading && !canEdit, + hidden: !chatEnabled && !permissionsLoading && !canEdit, }, { id: 'integrations', @@ -802,7 +804,14 @@ export const Sidebar = memo(function Sidebar({ hidden: permissionConfig.hideIntegrationsTab, }, ].filter((item) => !item.hidden), - [workspaceId, createWorkflow, canEdit, permissionsLoading, permissionConfig.hideIntegrationsTab] + [ + workspaceId, + createWorkflow, + canEdit, + permissionsLoading, + permissionConfig.hideIntegrationsTab, + chatEnabled, + ] ) const workspaceNavItems = useMemo( @@ -866,7 +875,7 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedChats = EMPTY_CHATS, isLoading: chatsLoading } = useMothershipChats( workspaceId, - { enabled: isChatEnabled } + { enabled: chatEnabled } ) useMothershipChatEvents(workspaceId) @@ -1491,7 +1500,7 @@ export const Sidebar = memo(function Sidebar({ )} >
- {isChatEnabled && ( + {chatEnabled && (
- {(isHosted || isStatusNoticePreviewEnabled) && !isCollapsed ? ( + {(hosted || isStatusNoticePreviewEnabled) && !isCollapsed ? (
diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts index ebf685b687c..36c3e9319ad 100644 --- a/apps/sim/blocks/blocks/function.ts +++ b/apps/sim/blocks/blocks/function.ts @@ -1,5 +1,5 @@ import { CodeIcon } from '@/components/icons' -import { isSandboxesEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' import { SANDBOX_OUTPUT_DIR } from '@/lib/execution/remote-sandbox/sandbox-paths' import type { BlockConfig } from '@/blocks/types' @@ -37,7 +37,7 @@ export const FunctionBlock: BlockConfig = { options: () => [ { label: getLanguageDisplayName(CodeLanguage.JavaScript), id: CodeLanguage.JavaScript }, { label: getLanguageDisplayName(CodeLanguage.Python), id: CodeLanguage.Python }, - ...(isSandboxesEnabled + ...(getDeploymentShape().features.sandboxes ? [{ label: getLanguageDisplayName(CodeLanguage.Shell), id: CodeLanguage.Shell }] : []), ], diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 4351c4cc396..dca2389b503 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,11 +1,7 @@ import { toError } from '@sim/utils/errors' import { SimAutoIcon } from '@/components/icons' -import { - isAzureConfigured, - isCohereConfigured, - isHosted, - isOllamaConfigured, -} from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isOllamaConfigured } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' import type { SubBlockConfig } from '@/blocks/types' @@ -89,7 +85,7 @@ export function getModelOptions() { // Hosted-only automatic model. Deliberately LAST in the list (limited // visibility for the initial release): available to anyone who scrolls or // searches for it, but never the first thing the dropdown offers. - if (isHosted) { + if (getDeploymentShape().hosted) { options.push({ label: 'Auto', id: SIM_AUTO_MODEL_ID, icon: SimAutoIcon }) } @@ -145,12 +141,13 @@ function shouldRequireApiKeyForModel(model: string): boolean { const normalizedModel = model.trim().toLowerCase() if (!normalizedModel) return false + const { hosted, azureConfigured } = getDeploymentShape() // On hosted Sim the auto pseudo-model resolves server-side to a hosted pool // model. On self-hosted it exists only via imported workflows and always // falls back to the default Anthropic model, so the key field must show. - if (isAutoModel(normalizedModel)) return !isHosted + if (isAutoModel(normalizedModel)) return !hosted - if (isHosted) { + if (hosted) { const hostedModels = getHostedModels() if (hostedModels.some((m) => m.toLowerCase() === normalizedModel)) return false } @@ -159,7 +156,7 @@ function shouldRequireApiKeyForModel(model: string): boolean { return false } if ( - isAzureConfigured && + azureConfigured && (normalizedModel.startsWith('azure/') || normalizedModel.startsWith('azure-openai/') || normalizedModel.startsWith('azure-anthropic/') || @@ -263,7 +260,8 @@ export function getApiKeyCondition() { */ export function getCohereRerankerApiKeyCondition() { return () => { - if (isHosted || isCohereConfigured) { + const { hosted, cohereConfigured } = getDeploymentShape() + if (hosted || cohereConfigured) { return { field: 'operation', value: '__never_show__' } } return { diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 7bbb4e0aa2c..026da4f483f 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -2,17 +2,18 @@ * @vitest-environment node */ import { createElement } from 'react' -import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' import { renderToStaticMarkup } from 'react-dom/server' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, - buildUnifiedSettingsNavigation, + buildUnifiedSettingsCatalog, canMutateWorkspaceSettingsSection, getAccountSettingsHref, + getOrganizationSettingsFeatures, getWorkspaceSettingsHref, isOrganizationSettingsSectionAvailable, + isSelfHostedOverrideEnabled, ORGANIZATION_PLANE_UNIFIED_SECTIONS, parseSettingsPathSection, resolveOrganizationSectionAccess, @@ -24,26 +25,65 @@ import { WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' - -beforeEach(() => { - setEnv({}) - resetEnvFlagsMock() -}) - -afterAll(() => { - resetEnvMock() - resetEnvFlagsMock() -}) +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' + +const SELF_HOSTED: DeploymentShape = { + hosted: false, + billingEnabled: false, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: true, + sandboxes: false, + sessionPolicies: true, + sso: false, + usageMonitoring: false, + whitelabeling: true, + }, +} + +const HOSTED: DeploymentShape = { ...SELF_HOSTED, hosted: true, billingEnabled: true } + +const ALL_ENTITLEMENTS = { + byok: true, + credentialGroups: true, + customBlocks: true, + forks: true, + inbox: true, + sandboxes: true, +} describe('settings navigation boundaries', () => { it('keeps Custom Blocks opt-in on self-hosted deployments', () => { + const customBlocks = buildUnifiedSettingsCatalog().find(({ id }) => id === 'custom-blocks') + + expect(customBlocks?.selfHostedOverride).toBe('customBlocks') + expect(isSelfHostedOverrideEnabled(customBlocks?.selfHostedOverride, SELF_HOSTED)).toBe(false) expect( - buildUnifiedSettingsNavigation().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride - ).toBe(false) + isSelfHostedOverrideEnabled(customBlocks?.selfHostedOverride, { + ...SELF_HOSTED, + features: { ...SELF_HOSTED.features, customBlocks: true }, + }) + ).toBe(true) + }) + + it('resolves self-hosted overrides against the deployment shape, never on Sim Cloud', () => { + expect(isSelfHostedOverrideEnabled(undefined, SELF_HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('always', SELF_HOSTED)).toBe(true) + expect(isSelfHostedOverrideEnabled('always', HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('sessionPolicies', SELF_HOSTED)).toBe(true) + expect(isSelfHostedOverrideEnabled('sessionPolicies', HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('sso', SELF_HOSTED)).toBe(false) }) it('preserves the order of all four settings catalogs', () => { - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([ + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toEqual([ 'general', 'desktop', 'browser', @@ -101,22 +141,14 @@ describe('settings navigation boundaries', () => { ]) }) - it('keeps the Sandboxes section in the legacy self-hosted defaults', () => { - setEnv({ NEXT_PUBLIC_SANDBOXES_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: undefined }) - - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('sandboxes') + it('keeps the Sandboxes section in the catalog and the workspace navigation', () => { + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('sandboxes') expect( resolveWorkspaceNavigation({ permission: 'admin', permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }).map(({ id }) => id) ).toContain('sandboxes') }) @@ -124,47 +156,62 @@ describe('settings navigation boundaries', () => { /** * The Self-host section links out to the managed service that issues this * deployment's Chat keys. On Sim Cloud that surface is reached from the - * account plane instead, so the section must not exist there at all — in the - * sidebar catalog or in the workspace-plane gate the route consults. + * account plane instead, so the workspace-plane gate the route consults must + * drop it there. */ it('shows the Self-host section only on a self-hosted deployment', () => { - setEnvFlags({ isHosted: false }) - - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('self-host') - expect( + const navigate = (hosted: boolean) => resolveWorkspaceNavigation({ permission: 'admin', permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted, }).map(({ id }) => id) - ).toContain('self-host') + + expect(navigate(false)).toContain('self-host') + expect(navigate(true)).not.toContain('self-host') }) - it('drops the Self-host section on hosted Sim', () => { - setEnvFlags({ isHosted: true }) + /** + * The catalog keeps every section regardless of deployment so the route can + * tell an unavailable section from an unknown one and redirect to General + * instead of answering 404 — the sidebar applies deployment visibility itself. + */ + it('keeps deployment-gated sections in the catalog', () => { + const ids = buildUnifiedSettingsCatalog().map(({ id }) => id) + + expect(ids).toContain('self-host') + expect(ids).toContain('byok') + expect( + buildUnifiedSettingsCatalog().find(({ id }) => id === 'self-host')?.requiresSelfHosted + ).toBe(true) + }) - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('self-host') + it('derives organization settings features from the deployment shape', () => { expect( - resolveWorkspaceNavigation({ - permission: 'admin', - permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, - }).map(({ id }) => id) - ).not.toContain('self-host') + getOrganizationSettingsFeatures(true, { + ...SELF_HOSTED, + features: { ...SELF_HOSTED.features, sso: true, usageMonitoring: true }, + }) + ).toEqual({ + billingEnabled: false, + hasEnterprisePlan: true, + hosted: false, + selfHosted: { + 'access-control': false, + 'audit-logs': false, + sso: true, + sessions: true, + 'data-retention': false, + 'data-drains': false, + usage: true, + whitelabeling: true, + }, + }) + expect(getOrganizationSettingsFeatures(false, HOSTED)).toMatchObject({ + billingEnabled: true, + hosted: true, + }) }) /** @@ -173,7 +220,7 @@ describe('settings navigation boundaries', () => { * one colored item in a monochrome icon column. */ it('marks the Self hosting section with a currentColor line icon', () => { - const selfHost = buildUnifiedSettingsNavigation().find(({ id }) => id === 'self-host') + const selfHost = buildUnifiedSettingsCatalog().find(({ id }) => id === 'self-host') const markup = renderToStaticMarkup(createElement(selfHost!.icon, {})) expect(selfHost?.label).toBe('Self hosting') @@ -200,7 +247,7 @@ describe('settings navigation boundaries', () => { expect(new Set(selfHostIds).size).toBe(selfHostIds.length) expect(new Set(workspaceIds).size).toBe(workspaceIds.length) expect([...unifiedIds].sort()).toEqual( - buildUnifiedSettingsNavigation() + buildUnifiedSettingsCatalog() .map(({ id }) => id) .sort() ) @@ -265,7 +312,7 @@ describe('settings navigation boundaries', () => { }) it('labels the members section consistently', () => { - const unifiedOrganization = buildUnifiedSettingsNavigation().find( + const unifiedOrganization = buildUnifiedSettingsCatalog().find( ({ id }) => id === 'organization' ) @@ -452,14 +499,8 @@ describe('settings navigation boundaries', () => { const items = resolveWorkspaceNavigation({ permission, permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - customBlocks: true, - forks: true, - inbox: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }) expect(items.map(({ id }) => id)).toEqual(visible) @@ -477,14 +518,8 @@ describe('settings navigation boundaries', () => { disableMcpTools: true, disableCustomTools: true, }, - entitlements: { - byok: true, - credentialGroups: true, - customBlocks: true, - forks: true, - inbox: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }) expect(items.map(({ id }) => id)).toEqual([ diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index bfeecdb187b..20773e2f2d7 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -29,21 +29,7 @@ import { import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { CodeIcon, McpIcon } from '@/components/icons' import type { SettingsHeaderMeta } from '@/components/settings/settings-header' -import { getEnv, isTruthy } from '@/lib/core/config/env' -import { - isAccessControlEnabled, - isAuditLogsEnabled, - isCustomBlocksEnabled, - isDataDrainsEnabled, - isDataRetentionEnabled, - isHosted, - isInboxEnabled, - isSandboxesEnabled, - isSessionPoliciesEnabled, - isSsoEnabled, - isUsageMonitoringEnabled, - isWhitelabelingEnabled, -} from '@/lib/core/config/env-flags' +import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces' export type SettingsPlane = 'account' | 'selfhost' | 'workspace' @@ -157,7 +143,8 @@ export interface UnifiedSettingsNavigationItem { * where the same surface is reached from the managed service instead. */ requiresSelfHosted?: boolean - selfHostedOverride?: boolean + /** See {@link SelfHostedOverride}; resolved against the deployment shape at filter time. */ + selfHostedOverride?: SelfHostedOverride requiresSuperUser?: boolean requiresAdminRole?: boolean requiresDesktopSurface?: DesktopSettingsSurface @@ -213,29 +200,26 @@ export interface SettingsSectionRegistryEntry { } /** - * Which enterprise sections a self-hosted deployment may show. - * - * These read the same resolved flags the server gates use, so a section is - * visible exactly when its API would accept the request. Reading the raw - * `NEXT_PUBLIC_*` vars here instead is what previously let nav and server - * disagree — a feature could be reachable but hidden, or listed but rejected. - * + * How a section unlocks on a self-hosted deployment: `'always'` unconditionally, or + * when the named enterprise feature resolves on for the deployment. Named rather than + * read here so the catalog stays a constant and the sidebar and the server gate resolve + * the same server-provided shape — see {@link isSelfHostedOverrideEnabled}. That is what + * keeps nav and server agreeing: a section is visible exactly when its API would accept + * the request. */ -const SETTINGS_SELF_HOSTED_OVERRIDES = { - accessControl: isAccessControlEnabled, - auditLogs: isAuditLogsEnabled, - customBlocks: isCustomBlocksEnabled, - dataDrains: isDataDrainsEnabled, - dataRetention: isDataRetentionEnabled, - inbox: isInboxEnabled, - sandboxes: isSandboxesEnabled, - sessionPolicies: isSessionPoliciesEnabled, - sso: isSsoEnabled, - usageMonitoring: isUsageMonitoringEnabled, - whitelabeling: isWhitelabelingEnabled, -} as const +export type SelfHostedOverride = 'always' | keyof DeploymentFeatures -export const SETTINGS_NAVIGATION_BILLING_ENABLED = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) +/** + * Whether a section's self-hosted override unlocks it on this deployment. Always false + * on Sim Cloud, where subscription plans decide entitlement instead. + */ +export function isSelfHostedOverrideEnabled( + override: SelfHostedOverride | undefined, + deployment: DeploymentShape +): boolean { + if (override === undefined || deployment.hosted) return false + return override === 'always' || deployment.features[override] +} type SettingsHrefSearchParams = Pick @@ -403,7 +387,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 4, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, + selfHostedOverride: 'accessControl', organizationSection: 'access-control', }, }, @@ -418,7 +402,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 5, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, + selfHostedOverride: 'auditLogs', organizationSection: 'audit-logs', }, }, @@ -516,7 +500,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] */ requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, + selfHostedOverride: 'usageMonitoring', organizationSection: 'usage', }, }, @@ -543,7 +527,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 9, requiresEnterprise: true, allowNonOrgAdmin: true, - selfHostedOverride: true, + selfHostedOverride: 'always', }, planes: { workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, @@ -636,7 +620,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'workspace', order: 8, requiresMax: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, + selfHostedOverride: 'sandboxes', showWhenLocked: true, }, planes: { @@ -665,7 +649,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 5, requiresMax: true, requiresHosted: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox, + selfHostedOverride: 'inbox', showWhenLocked: true, }, planes: { @@ -710,7 +694,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 7, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + selfHostedOverride: 'sso', organizationSection: 'sso', }, }, @@ -725,7 +709,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 8, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, + selfHostedOverride: 'sessionPolicies', organizationSection: 'sessions', }, }, @@ -741,7 +725,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 9, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, + selfHostedOverride: 'dataRetention', organizationSection: 'data-retention', }, }, @@ -756,7 +740,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 10, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, + selfHostedOverride: 'dataDrains', organizationSection: 'data-drains', }, }, @@ -771,7 +755,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 6, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, + selfHostedOverride: 'whitelabeling', organizationSection: 'whitelabeling', }, }, @@ -787,7 +771,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.customBlocks, + selfHostedOverride: 'customBlocks', }, planes: { workspace: { id: 'custom-blocks', group: 'enterprise', order: 11 }, @@ -823,12 +807,16 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, ] -export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { +/** + * Every unified section this build can render, including ones the current deployment + * does not offer. Deployment filtering (`requiresHosted`, `requiresSelfHosted`, billing) + * belongs to the sidebar and the section gate, which read the server-resolved shape. + * Keeping an unavailable section in the catalog is what lets the route treat it as a + * known segment and redirect to General rather than answer 404. + */ +export function buildUnifiedSettingsCatalog(): UnifiedSettingsNavigationItem[] { return SETTINGS_SECTION_REGISTRY.flatMap(({ label, icon, docsLink, unified }) => { if (!unified) return [] - // Dropped here so the sidebar, the route's `parseSection` gate, and section - // metadata all agree that the section does not exist on Sim Cloud. - if (unified.requiresSelfHosted && isHosted) return [] const { group, ...item } = unified return [ { @@ -944,21 +932,23 @@ export interface OrganizationSettingsFeatures { } export function getOrganizationSettingsFeatures( - hasEnterprisePlan: boolean + hasEnterprisePlan: boolean, + deployment: DeploymentShape ): OrganizationSettingsFeatures { + const { features } = deployment return { - billingEnabled: SETTINGS_NAVIGATION_BILLING_ENABLED, + billingEnabled: deployment.billingEnabled, hasEnterprisePlan, - hosted: isHosted, + hosted: deployment.hosted, selfHosted: { - 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, - 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, - sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, - 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, - 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, - usage: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, - whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, + 'access-control': features.accessControl, + 'audit-logs': features.auditLogs, + sso: features.sso, + sessions: features.sessionPolicies, + 'data-retention': features.dataRetention, + 'data-drains': features.dataDrains, + usage: features.usageMonitoring, + whitelabeling: features.whitelabeling, }, } } @@ -1025,6 +1015,8 @@ interface ResolveWorkspaceNavigationOptions { permission: PermissionType permissionConfig: WorkspacePermissionConfig entitlements: WorkspaceSettingsEntitlements + /** Sim Cloud drops the Self hosting section, which the managed service owns there. */ + hosted: boolean } export interface ResolvedWorkspaceNavigationItem @@ -1068,6 +1060,7 @@ export function resolveWorkspaceNavigation({ permission, permissionConfig, entitlements, + hosted, }: ResolveWorkspaceNavigationOptions): ResolvedWorkspaceNavigationItem[] { return WORKSPACE_SETTINGS_ITEMS.flatMap((item) => { const permissionConfigKey = WORKSPACE_PERMISSION_CONFIG_KEYS[item.id] @@ -1082,7 +1075,7 @@ export function resolveWorkspaceNavigation({ if (item.id === 'byok' && !entitlements.byok) return [] if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] // Absent on Sim Cloud, where the managed service owns these settings. - if (item.id === 'self-host' && isHosted) return [] + if (item.id === 'self-host' && hosted) return [] const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id] const locked = lockedBy !== undefined && !entitlements[lockedBy] diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 82a9302611c..5a2f5ccbf68 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -17,7 +17,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, isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { SIDEBAR_WIDTH } from '@/stores/constants' interface StandaloneSettingsShellBaseProps { @@ -39,19 +39,20 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSettingsBeforeUnload() const pathname = usePathname() + const { hosted, billingEnabled } = useDeploymentShape() const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { - if (item.id === 'billing' && !isBillingEnabled) return false + if (item.id === 'billing' && !billingEnabled) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => { - if (item.id === 'billing' && !isBillingEnabled) return false + if (item.id === 'billing' && !billingEnabled) return false // Chat keys are issued by the managed service, so there are none to list on - // a self-hosted deployment — useCopilotKeys is `enabled: isHosted` for the + // a self-hosted deployment — useCopilotKeys is `enabled: hosted` for the // same reason. Self-hosters manage their keys on sim.ai. - if (item.id === 'chat-keys' && !isHosted) return false + if (item.id === 'chat-keys' && !hosted) return false return true }) const selfHostSection = parseSettingsPathSection({ diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx index 4057b4db8d5..21d9a202e93 100644 --- a/apps/sim/ee/access-control/components/access-control.test.tsx +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -26,7 +26,6 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) -vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, })) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index adf546a7847..f7a341b2852 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -18,7 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { isAccessControlEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { groupIdParam, groupIdUrlKeys, @@ -56,6 +56,7 @@ interface AccessControlProps { export function AccessControl({ isOrganizationAdmin, organizationId }: AccessControlProps) { const params = useParams() + const { features } = useDeploymentShape() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined /** @@ -74,7 +75,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon isPending: organizationBillingLoading, error: organizationBillingError, } = useOrganizationBilling(organizationId, { - enabled: !isAccessControlEnabled && !userPermissionConfig?.entitled, + enabled: !features.accessControl && !userPermissionConfig?.entitled, }) const currentUserIsOrgAdmin = isOrganizationAdmin @@ -92,12 +93,12 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon * set show the section and then refuse to manage it. */ const isEntitled = - isAccessControlEnabled || + features.accessControl || !!userPermissionConfig?.entitled || isEnterprise(organizationBillingData?.data?.subscriptionPlan) const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId const organizationEntitlementLoading = - !isAccessControlEnabled && !userPermissionConfig?.entitled && organizationBillingLoading + !features.accessControl && !userPermissionConfig?.entitled && organizationBillingLoading const isLoading = (workspaceId ? entitlementLoading : false) || diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index f342d9748f6..985015f27e6 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -24,7 +24,7 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization' import type { RetentionOverride } from '@/lib/api/contracts/primitives' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { type CustomPiiPattern, emptyPiiStages, @@ -987,6 +987,7 @@ function DataRetentionForm({ initialData: data, orgId, workspaces }: DataRetenti export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSettingsProps) { const { data, isLoading } = useOrganizationRetention(orgId) const { data: workspaces = [] } = useWorkspacesQuery(Boolean(orgId)) + const { billingEnabled } = useDeploymentShape() if (isLoading) { return ( @@ -1009,7 +1010,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe return Failed to load data retention settings. } - if (isBillingEnabled && !data.isEnterprise) { + if (billingEnabled && !data.isEnterprise) { return ( Data retention is available on Enterprise plans only. ) diff --git a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx index ecdb4acbcd7..648fae039b4 100644 --- a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx +++ b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx @@ -18,7 +18,7 @@ import { type UsageBreakdownDimension, } from '@/lib/api/contracts/organization-usage' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { isAuditLogsEnabled, isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { ManageCreditsModal, type ManageCreditsTarget, @@ -100,6 +100,7 @@ export function UsageMonitoring({ auditLogsHref: auditLogsBaseHref, }: UsageMonitoringProps) { const router = useRouter() + const { hosted, features } = useDeploymentShape() const { window, tab, workspace, expanded, preset, startDate, endDate, periodLabel, setState } = useUsageWindow() const [datePickerOpen, setDatePickerOpen] = useState(false) @@ -125,7 +126,7 @@ export function UsageMonitoring({ * `requiresHosted` with no self-hosted override — so without this the menu would * offer an action that could only fail. */ - const canManageCredits = tab === 'member' && isHosted + const canManageCredits = tab === 'member' && hosted const summary = useOrganizationUsageSummary(organizationId, window) /** @@ -210,9 +211,7 @@ export function UsageMonitoring({ * periods, so there is no honest mapping for `current-period`. */ const auditLogsHref = - isHosted || isAuditLogsEnabled - ? serializeAuditLogFilters(auditLogsBaseHref, { workspace }) - : null + hosted || features.auditLogs ? serializeAuditLogFilters(auditLogsBaseHref, { workspace }) : null /** * The drill-down is the same window, in more detail. Without the params it read its diff --git a/apps/sim/ee/session-policy/components/session-policy-settings.tsx b/apps/sim/ee/session-policy/components/session-policy-settings.tsx index 8301c938054..dcd9ac0ca53 100644 --- a/apps/sim/ee/session-policy/components/session-policy-settings.tsx +++ b/apps/sim/ee/session-policy/components/session-policy-settings.tsx @@ -9,7 +9,7 @@ import { MIN_IDLE_TIMEOUT_HOURS, MIN_SESSION_LIFETIME_HOURS, } from '@/lib/api/contracts/organization' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -63,6 +63,7 @@ interface SessionPolicyFormProps extends SessionPolicySettingsProps { function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormProps) { const updatePolicy = useUpdateOrganizationSessionPolicy() + const { billingEnabled } = useDeploymentShape() const revokeSessions = useRevokeOrganizationSessions() const initialMaxSessionHours = initialData.configured.maxSessionHours?.toString() ?? '' @@ -151,7 +152,7 @@ function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormPro }), ] - if (isBillingEnabled && !initialData.isEnterprise) { + if (billingEnabled && !initialData.isEnterprise) { return ( diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index cc171a75f90..630d3edc260 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -24,7 +24,7 @@ import type { SettingsAction } from '@/components/settings/settings-header' import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' import { useSession } from '@/lib/auth/auth-client' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -233,6 +233,7 @@ export function SSO({ organizationId }: SSOProps) { function OrganizationSsoSettings({ organizationId }: SSOProps) { const { data: session } = useSession() + const { billingEnabled } = useDeploymentShape() const { data: organizationBillingData, isLoading: isLoadingOrganizationBilling, @@ -257,7 +258,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { const hasEnterprisePlan = isEnterprise(organizationBillingData?.data?.subscriptionPlan) const isSSOProviderOwner = - !isBillingEnabled && userId ? providers.some((p) => p.userId === userId) : null + !billingEnabled && userId ? providers.some((p) => p.userId === userId) : null const configureSSOMutation = useConfigureSSO() @@ -289,13 +290,13 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { useSettingsUnsavedGuard({ isDirty: hasChanges }) - if (isLoadingProviders || (isBillingEnabled && isLoadingOrganizationBilling)) { + if (isLoadingProviders || (billingEnabled && isLoadingOrganizationBilling)) { return null } const providersLoadingError = providersData === undefined ? providersError : null const organizationBillingLoadingError = - isBillingEnabled && organizationBillingData === undefined ? organizationBillingError : null + billingEnabled && organizationBillingData === undefined ? organizationBillingError : null const loadingError = providersLoadingError ?? organizationBillingLoadingError if (loadingError) { return ( @@ -314,7 +315,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { ) } - if (isBillingEnabled) { + if (billingEnabled) { if (!hasEnterprisePlan) { return ( diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx index 7e8f9280f5d..cf1ec994bb1 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx @@ -2,18 +2,17 @@ * @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockUseOrganizationBilling } = vi.hoisted(() => ({ mockUseOrganizationBilling: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, - isOrganizationsEnabled: false, - isSsoEnabled: false, -})) +/** Billing on, so plan entitlement gates the page; read through the deployment shape. */ +beforeAll(() => setEnvFlags({ isBillingEnabled: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/components/settings/save-discard-actions', () => ({ saveDiscardActions: () => [], })) diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index d81a178fff5..1358af3a0a8 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -10,7 +10,7 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import { isEnterprise } from '@/lib/billing/plan-helpers' import { HEX_COLOR_REGEX } from '@/lib/branding' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL, @@ -457,16 +457,17 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel } export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSettingsProps) { + const { billingEnabled } = useDeploymentShape() const { data: organizationBillingData, isPending: organizationBillingLoading, error: organizationBillingError, - } = useOrganizationBilling(orgId, { enabled: isBillingEnabled }) + } = useOrganizationBilling(orgId, { enabled: billingEnabled }) const { data: workspaces } = useWorkspacesQuery(true) const uploadWorkspaceId = workspaces?.find((workspace) => workspace.organizationId === orgId)?.id const { data: savedSettings, error: settingsError, isLoading } = useWhitelabelSettings(orgId) - if (isLoading || (isBillingEnabled && organizationBillingLoading)) { + if (isLoading || (billingEnabled && organizationBillingLoading)) { return ( {getErrorMessage(organizationBillingError, 'Failed to load organization billing')} @@ -496,7 +497,7 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe ) } - if (isBillingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { + if (billingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { return ( Whitelabeling is available on Enterprise plans only. ) diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index a34970473cc..8d88b195827 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -9,7 +9,7 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -311,6 +311,7 @@ export function Forks() { const workspaceId = params.workspaceId as string const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext() + const { billingEnabled } = useDeploymentShape() const { available: forkingAvailable, isLoading: availabilityLoading } = useForkingAvailability(workspaceId) const canUseForking = forkingAvailable && canAdmin @@ -560,7 +561,7 @@ export function Forks() { sourceWorkspaceName={workspaceName || 'Workspace'} canFork={canFork} onUpgrade={() => { - if (isBillingEnabled) navigateToSettings({ section: 'billing' }) + if (billingEnabled) navigateToSettings({ section: 'billing' }) }} /> diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index a6bc9273520..04b41893991 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -8,7 +8,7 @@ import { generateCopilotApiKeyContract, listCopilotApiKeysContract, } from '@/lib/api/contracts' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' const logger = createLogger('CopilotKeysQuery') @@ -39,10 +39,11 @@ async function fetchCopilotKeys(signal?: AbortSignal): Promise { * Hook to fetch Copilot API keys */ export function useCopilotKeys() { + const { hosted } = useDeploymentShape() return useQuery({ queryKey: copilotKeysKeys.keys(), queryFn: ({ signal }) => fetchCopilotKeys(signal), - enabled: isHosted, + enabled: hosted, staleTime: COPILOT_KEY_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index 02079c3a3a9..2d1a9d91717 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' import { createRotatingEventSource } from '@/lib/events/rotating-event-source' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' @@ -164,9 +164,10 @@ export function resyncMothershipChatCaches( */ export function useMothershipChatEvents(workspaceId: string | undefined) { const queryClient = useQueryClient() + const { chatEnabled } = useDeploymentShape() useEffect(() => { - if (!workspaceId || !isChatEnabled) return + if (!workspaceId || !chatEnabled) return const isResubscribe = everSubscribed.has(workspaceId) everSubscribed.add(workspaceId) @@ -194,5 +195,5 @@ export function useMothershipChatEvents(workspaceId: string | undefined) { return () => { connection.close() } - }, [workspaceId, queryClient]) + }, [workspaceId, queryClient, chatEnabled]) } diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 883c9375c0c..318562ae4bf 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -241,6 +241,46 @@ export const workspaceOwnerBillingSchema = z.object({ export type WorkspaceOwnerBilling = z.output +/** + * Enterprise features as this deployment's configuration resolves them (see + * `enterpriseFeatureEnabled` in `@/lib/core/config/env-flags`). The browser consults + * these only off-hosted, where no subscription plan exists to decide entitlement. + */ +export const deploymentFeaturesSchema = z.object({ + accessControl: z.boolean(), + auditLogs: z.boolean(), + customBlocks: z.boolean(), + dataDrains: z.boolean(), + dataRetention: z.boolean(), + inbox: z.boolean(), + sandboxes: z.boolean(), + sessionPolicies: z.boolean(), + sso: z.boolean(), + usageMonitoring: z.boolean(), + whitelabeling: z.boolean(), +}) + +export type DeploymentFeatures = z.output + +/** + * The deployment's shape, resolved on the server per request. Browser code reads it + * from the workspace host context rather than from the `NEXT_PUBLIC_*` module + * constants: those freeze at module init, and a document that never ran the root + * layout — Next's bare `__next_error__` 404 shell, or `global-error` — leaves every + * one of them unset for the life of the tab, including after the app recovers in + * place. See `@/lib/core/config/deployment-shape`. + */ +export const deploymentShapeSchema = z.object({ + hosted: z.boolean(), + billingEnabled: z.boolean(), + chatEnabled: z.boolean(), + azureConfigured: z.boolean(), + cohereConfigured: z.boolean(), + features: deploymentFeaturesSchema, +}) + +export type DeploymentShape = z.output + export const workspaceHostContextSchema = z.object({ workspace: z.object({ id: nonEmptyIdSchema, @@ -266,6 +306,8 @@ export const workspaceHostContextSchema = z.object({ knowledgeMemberAccess: z.boolean().optional(), }) .optional(), + /** Optional for rolling compatibility with app versions that predate deployment projection. */ + deployment: deploymentShapeSchema.optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/billing/workspace-permissions.test.ts b/apps/sim/lib/billing/workspace-permissions.test.ts index 8c841347827..585ac6c4cec 100644 --- a/apps/sim/lib/billing/workspace-permissions.test.ts +++ b/apps/sim/lib/billing/workspace-permissions.test.ts @@ -1,10 +1,12 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { canManageWorkspaceBilling, + canViewWorkspaceBillingSettings, getWorkspaceUsageLimitAction, } from '@/lib/billing/workspace-permissions' @@ -36,6 +38,62 @@ const HOST_CONTEXT: WorkspaceHostContext = { }, } +const DEPLOYMENT: DeploymentShape = { + hosted: true, + billingEnabled: true, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: false, + sandboxes: false, + sessionPolicies: false, + sso: false, + usageMonitoring: false, + whitelabeling: false, + }, +} + +const HOST_ADMIN_CONTEXT: WorkspaceHostContext = { + ...HOST_CONTEXT, + viewer: { ...HOST_CONTEXT.viewer, isHostOrganizationAdmin: true }, +} + +describe('canViewWorkspaceBillingSettings', () => { + afterEach(resetEnvFlagsMock) + + it('reads billing availability from the host context deployment shape', () => { + expect( + canViewWorkspaceBillingSettings({ ...HOST_ADMIN_CONTEXT, deployment: DEPLOYMENT }, 'admin-b') + ).toBe(true) + expect( + canViewWorkspaceBillingSettings( + { ...HOST_ADMIN_CONTEXT, deployment: { ...DEPLOYMENT, billingEnabled: false } }, + 'admin-b' + ) + ).toBe(false) + }) + + it('falls back to the deployment reader for a host context that predates the field', () => { + expect(canViewWorkspaceBillingSettings(HOST_ADMIN_CONTEXT, 'admin-b')).toBe(false) + + setEnvFlags({ isBillingEnabled: true }) + + expect(canViewWorkspaceBillingSettings(HOST_ADMIN_CONTEXT, 'admin-b')).toBe(true) + }) + + it('still requires authority over the payer', () => { + expect( + canViewWorkspaceBillingSettings({ ...HOST_CONTEXT, deployment: DEPLOYMENT }, 'viewer') + ).toBe(false) + }) +}) + describe('canManageWorkspaceBilling', () => { it('does not treat an external workspace admin as a host billing admin', () => { expect(canManageWorkspaceBilling(HOST_CONTEXT, 'external-a')).toBe(false) diff --git a/apps/sim/lib/billing/workspace-permissions.ts b/apps/sim/lib/billing/workspace-permissions.ts index 41c57c574eb..0c3a7ead280 100644 --- a/apps/sim/lib/billing/workspace-permissions.ts +++ b/apps/sim/lib/billing/workspace-permissions.ts @@ -1,5 +1,5 @@ import type { WorkspaceHostContext, WorkspaceUsageGate } from '@/lib/api/contracts/workspaces' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' export type WorkspaceUsageLimitAction = | { type: 'manage-billing'; message: null } @@ -27,12 +27,17 @@ export function canManageWorkspaceBilling( * organization-hosted workspace that is every member who is not an org admin. * Menus that link to Billing drop the entry when this is false rather than * offering a destination the server will refuse. + * + * Billing availability comes from the host context's server-resolved deployment + * shape; the reader covers a context served by an app version that predates it. */ export function canViewWorkspaceBillingSettings( hostContext: WorkspaceHostContext, viewerUserId?: string | null ): boolean { - return isBillingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) + const billingEnabled = + hostContext.deployment?.billingEnabled ?? getDeploymentShape().billingEnabled + return billingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) } /** diff --git a/apps/sim/lib/core/config/deployment-shape.dom.test.tsx b/apps/sim/lib/core/config/deployment-shape.dom.test.tsx new file mode 100644 index 00000000000..3f59605c34f --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.dom.test.tsx @@ -0,0 +1,136 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai"} + * + * A document that never ran the root layout: no `window.__ENV`, no + * `data-public-env` attribute, so every `NEXT_PUBLIC_*` read is unset and the env + * fallback resolves to self-hosted. That is the state a tab keeps after recovering + * in place from Next's bare 404 shell or `global-error`. + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', '') + vi.stubEnv('NEXT_PUBLIC_BILLING_ENABLED', '') + vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', '') + document.documentElement.id = '__next_error__' +}) + +vi.unmock('@/lib/core/config/env') +vi.unmock('@/lib/core/config/env-flags') +vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: () => [] })) +vi.mock('@/providers/utils', () => ({ getProviderFromModel: () => 'openai' })) + +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, + seedDeploymentShape, + useDeploymentShape, +} from '@/lib/core/config/deployment-shape' +import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { getApiKeyCondition } from '@/blocks/utils' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const HOSTED_MODELS = ['gpt-5.6-sol', 'claude-sonnet-5'] + +const HOSTED: DeploymentShape = { + ...resolveDeploymentShape(), + hosted: true, + billingEnabled: true, +} + +function apiKeyFieldShown(model: string): boolean { + return evaluateSubBlockCondition(getApiKeyCondition(), { model }) +} + +function HookReader() { + const { hosted, billingEnabled } = useDeploymentShape() + return {`${hosted}/${billingEnabled}`} +} + +let host: HTMLDivElement +let root: Root + +function render(ui: ReactNode) { + act(() => root.render(ui)) +} + +function textOf(testId: string): string | undefined { + return host.querySelector(`[data-testid="${testId}"]`)?.textContent ?? undefined +} + +beforeEach(() => { + resetDeploymentShape() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE) + Reflect.deleteProperty(window, '__ENV') +}) + +describe('env fallback on a document without the root layout', () => { + it('resolves as self-hosted and shows API key fields for hosted models', () => { + expect(window.__ENV).toBeUndefined() + expect(document.documentElement.getAttribute(PUBLIC_ENV_ATTRIBUTE)).toBeNull() + expect(resolveDeploymentShape().hosted).toBe(false) + expect(getDeploymentShape().hosted).toBe(false) + + for (const model of HOSTED_MODELS) { + expect(apiKeyFieldShown(model)).toBe(true) + } + }) +}) + +describe('seeded server shape', () => { + it('wins over the env fallback for readers outside React', () => { + seedDeploymentShape(HOSTED) + + expect(getDeploymentShape()).toBe(HOSTED) + for (const model of HOSTED_MODELS) { + expect(apiKeyFieldShown(model)).toBe(false) + } + expect(apiKeyFieldShown('custom/model')).toBe(true) + }) + + it('keeps the seeded object when an equal shape is seeded again', () => { + seedDeploymentShape(HOSTED) + seedDeploymentShape({ ...HOSTED, features: { ...HOSTED.features } }) + + expect(getDeploymentShape()).toBe(HOSTED) + }) + + it('is ignored when the server predates deployment projection', () => { + seedDeploymentShape(undefined) + + expect(getDeploymentShape().hosted).toBe(false) + }) + + it('returns to the env fallback after a reset', () => { + seedDeploymentShape(HOSTED) + resetDeploymentShape() + + expect(getDeploymentShape().hosted).toBe(false) + }) +}) + +describe('useDeploymentShape', () => { + it('follows the seeded shape and falls back to the env fallback otherwise', () => { + render() + expect(textOf('hook')).toBe('false/false') + + act(() => seedDeploymentShape(HOSTED)) + + expect(textOf('hook')).toBe('true/true') + }) +}) diff --git a/apps/sim/lib/core/config/deployment-shape.test.ts b/apps/sim/lib/core/config/deployment-shape.test.ts new file mode 100644 index 00000000000..a01adc99bd8 --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { + getDeploymentShape, + resolveDeploymentShape, + seedDeploymentShape, +} from '@/lib/core/config/deployment-shape' + +afterEach(resetEnvFlagsMock) + +describe('resolveDeploymentShape', () => { + it('packages the resolved env flags', () => { + setEnvFlags({ + isHosted: true, + isBillingEnabled: true, + isChatEnabled: false, + isAzureConfigured: true, + isSsoEnabled: true, + isSandboxesEnabled: true, + }) + + expect(resolveDeploymentShape()).toEqual({ + hosted: true, + billingEnabled: true, + chatEnabled: false, + azureConfigured: true, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: true, + sandboxes: true, + sessionPolicies: true, + sso: true, + usageMonitoring: false, + whitelabeling: true, + }, + }) + }) + + it('reads the flags at call time rather than at module init', () => { + expect(resolveDeploymentShape().hosted).toBe(false) + + setEnvFlags({ isHosted: true }) + + expect(resolveDeploymentShape().hosted).toBe(true) + }) +}) + +describe('getDeploymentShape on the server', () => { + it('answers from the env flags and ignores seeding, which is browser-only', () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) + + expect(getDeploymentShape().hosted).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/config/deployment-shape.ts b/apps/sim/lib/core/config/deployment-shape.ts new file mode 100644 index 00000000000..a00429c6fce --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.ts @@ -0,0 +1,158 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces' +import { + isAccessControlEnabled, + isAuditLogsEnabled, + isAzureConfigured, + isBillingEnabled, + isChatEnabled, + isCohereConfigured, + isCustomBlocksEnabled, + isDataDrainsEnabled, + isDataRetentionEnabled, + isHosted, + isInboxEnabled, + isSandboxesEnabled, + isSessionPoliciesEnabled, + isSsoEnabled, + isUsageMonitoringEnabled, + isWhitelabelingEnabled, +} from '@/lib/core/config/env-flags' + +/** + * One reader for the deployment's shape: hosted or self-hosted, whether billing and + * Chat run, which provider credentials the deployment supplies, and which enterprise + * features its configuration turns on. + * + * Server code reads the `env-flags` constants directly and this module only packages + * them. Browser code must not. Those constants are computed once from the + * `NEXT_PUBLIC_*` transport the root layout emits, and a document that never ran the + * root layout — Next's bare `__next_error__` 404 shell, or `global-error` after the + * root layout threw — leaves every one of them unset for the life of the tab, even + * after `retry()` or a client-side navigation recovers the app in place. Sim Cloud then + * renders as self-hosted: API Key fields on hosted models, no Auto model, no billing. + * + * Workspace surfaces therefore read the shape the workspace host context carries, + * resolved on the server per request and seeded here by the host provider before any + * workspace child renders. The constants remain the fallback only outside a workspace, + * where the root layout always runs. + * + * Block definitions import this module, which puts it in React Server Component graphs + * (the block registry is loaded by auth and workflow lifecycle code), so it must not + * import React hooks itself; the seeding hook lives with the client-side host provider. + */ + +interface DeploymentShapeState { + /** Server-resolved shape from the workspace host context; `null` until a workspace mounts. */ + seeded: DeploymentShape | null + seed: (shape: DeploymentShape) => void + reset: () => void +} + +const useDeploymentShapeStore = create()( + devtools( + (set) => ({ + seeded: null, + seed: (shape) => set({ seeded: shape }), + reset: () => set({ seeded: null }), + }), + { name: 'deployment-shape-store' } + ) +) + +/** + * The browser's env fallback, built once per document. The env constants it packages are + * themselves frozen at module init, so caching changes nothing semantically, and it gives + * {@link useDeploymentShape} a stable reference that memo dependencies can key on. + */ +let browserEnvFallback: DeploymentShape | null = null + +function browserFallbackShape(): DeploymentShape { + browserEnvFallback ??= resolveDeploymentShape() + return browserEnvFallback +} + +/** + * The shape this runtime's own configuration resolves to. On the server that is the + * deployment's truth, and what the workspace host context projects. In the browser it + * is the `NEXT_PUBLIC_*` fallback: right on every document that ran the root layout, + * and the only source outside a workspace. + */ +export function resolveDeploymentShape(): DeploymentShape { + return { + hosted: isHosted, + billingEnabled: isBillingEnabled, + chatEnabled: isChatEnabled, + azureConfigured: isAzureConfigured, + cohereConfigured: isCohereConfigured, + features: { + accessControl: isAccessControlEnabled, + auditLogs: isAuditLogsEnabled, + customBlocks: isCustomBlocksEnabled, + dataDrains: isDataDrainsEnabled, + dataRetention: isDataRetentionEnabled, + inbox: isInboxEnabled, + sandboxes: isSandboxesEnabled, + sessionPolicies: isSessionPoliciesEnabled, + sso: isSsoEnabled, + usageMonitoring: isUsageMonitoringEnabled, + whitelabeling: isWhitelabelingEnabled, + }, + } +} + +function isSameDeploymentShape(seeded: DeploymentShape | null, next: DeploymentShape): boolean { + if (seeded === null) return false + if ( + seeded.hosted !== next.hosted || + seeded.billingEnabled !== next.billingEnabled || + seeded.chatEnabled !== next.chatEnabled || + seeded.azureConfigured !== next.azureConfigured || + seeded.cohereConfigured !== next.cohereConfigured + ) { + return false + } + const featureKeys = Object.keys(next.features) as (keyof DeploymentFeatures)[] + return featureKeys.every((key) => seeded.features[key] === next.features[key]) +} + +/** + * Installs the server-resolved shape for browser readers. A no-op on the server, where + * a module-level store would leak across requests, and when the shape is unchanged, so + * a host-context refetch or a sibling workspace never notifies subscribers for nothing. + */ +export function seedDeploymentShape(shape: DeploymentShape | undefined): void { + if (typeof window === 'undefined' || !shape) return + const { seeded, seed } = useDeploymentShapeStore.getState() + if (isSameDeploymentShape(seeded, shape)) return + seed(shape) +} + +/** Drops the seeded shape and the cached fallback. For tests; the app never unseeds on purpose. */ +export function resetDeploymentShape(): void { + browserEnvFallback = null + useDeploymentShapeStore.getState().reset() +} + +/** + * The deployment shape for code that runs outside React, such as block `condition` + * functions and sub-block visibility. Server callers get the resolved truth; browser + * callers get the seeded server value inside a workspace, and the `NEXT_PUBLIC_*` + * fallback elsewhere. + */ +export function getDeploymentShape(): DeploymentShape { + if (typeof window === 'undefined') return resolveDeploymentShape() + return useDeploymentShapeStore.getState().seeded ?? browserFallbackShape() +} + +/** + * {@link getDeploymentShape} for components, subscribed to the seeded value. Returns the + * same object until the shape actually changes, so it is safe as a memo dependency for + * option lists and other derived values that read the shape outside React. + */ +export function useDeploymentShape(): DeploymentShape { + const seeded = useDeploymentShapeStore((state) => state.seeded) + if (seeded) return seeded + return typeof window === 'undefined' ? resolveDeploymentShape() : browserFallbackShape() +} diff --git a/apps/sim/lib/core/config/env-flags.dom.test.ts b/apps/sim/lib/core/config/env-flags.dom.test.ts deleted file mode 100644 index 3511aaccf2b..00000000000 --- a/apps/sim/lib/core/config/env-flags.dom.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @vitest-environment jsdom - * @vitest-environment-options {"url":"https://www.sim.ai"} - */ -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.hoisted(() => { - vi.stubEnv('NEXT_PUBLIC_APP_URL', '') - vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'false') - vi.stubEnv('NODE_ENV', 'production') - document.documentElement.id = '__next_error__' -}) - -vi.unmock('@/lib/core/config/env') -vi.unmock('@/lib/core/config/env-flags') -vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: () => [] })) -vi.mock('@/providers/utils', () => ({ getProviderFromModel: () => 'openai' })) - -import { getEnv, PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' -import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' -import { getApiKeyCondition } from '@/blocks/utils' -import { getHostedModels } from '@/providers/models' - -describe('hosted detection during client recovery', () => { - afterEach(() => { - document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE) - Reflect.deleteProperty(window, '__ENV') - }) - - it('hides hosted model keys before the recovered layout installs runtime configuration', () => { - expect(window.__ENV).toBeUndefined() - expect(document.documentElement.getAttribute(PUBLIC_ENV_ATTRIBUTE)).toBeNull() - expect(isHosted).toBe(true) - - for (const model of ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-2.5-pro']) { - expect(getHostedModels()).toContain(model) - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model })).toBe(false) - } - - document.documentElement.setAttribute( - PUBLIC_ENV_ATTRIBUTE, - JSON.stringify({ NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' }) - ) - - expect(getEnv('NEXT_PUBLIC_APP_URL')).toBe('https://www.sim.ai') - expect(isHosted).toBe(true) - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'gpt-5.6-sol' })).toBe(false) - }) - - it('still requires keys for models outside the hosted catalog', () => { - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'custom/model' })).toBe(true) - }) -}) diff --git a/apps/sim/lib/core/config/env-flags.test.ts b/apps/sim/lib/core/config/env-flags.test.ts index fa30e64738e..ca854b6c79d 100644 --- a/apps/sim/lib/core/config/env-flags.test.ts +++ b/apps/sim/lib/core/config/env-flags.test.ts @@ -7,7 +7,6 @@ vi.hoisted(() => { vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://self-hosted.example') vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'true') vi.stubEnv('NODE_ENV', 'production') - vi.stubGlobal('window', { location: { hostname: 'www.sim.ai' } }) }) vi.unmock('@/lib/core/config/env') @@ -15,8 +14,8 @@ vi.unmock('@/lib/core/config/env-flags') import { isHosted, isProd } from '@/lib/core/config/env-flags' -describe('configured hosted detection', () => { - it('preserves a configured self-hosted URL and ignores the development override in production', () => { +describe('hosted detection', () => { + it('follows the configured URL and ignores the development override in production', () => { expect(isProd).toBe(true) expect(isHosted).toBe(false) }) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index d90b2aa1e84..9fe590a0a8a 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -35,16 +35,18 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application. * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). - * The browser hostname remains available when an error document boots without - * the root layout's runtime environment, before client rendering recovers it. - * A valid configured URL takes precedence; server detection stays env-only. + * + * Workspace surfaces in the browser read `hosted` from the deployment shape the + * workspace host context carries (`@/lib/core/config/deployment-shape`), not this + * constant: it is computed once from the `NEXT_PUBLIC_*` transport the root layout + * emits, which a `global-error` or bare 404 document never provides. */ const appUrl = getEnv('NEXT_PUBLIC_APP_URL') -let appHostname = typeof window === 'undefined' ? '' : window.location.hostname +let appHostname = '' try { - if (appUrl) appHostname = new URL(appUrl).hostname + appHostname = appUrl ? new URL(appUrl).hostname : '' } catch { - /** Keep the document hostname when the configured URL cannot be parsed. */ + /** An unparseable configured URL reads as self-hosted. */ } /** * Local-development escape hatch for exercising hosted-only paths (the sim-auto diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 31f967a3b38..8a77dc285cd 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -24,11 +24,17 @@ import { z } from 'zod' * hydration. So on a warm cache both module bodies and the first commit can run * before the parser has reached the assignment. * - * On a normally rendered document, the attribute is parsed before bootstrap - * scripts can execute. Next's server-rendering error document omits the root - * layout, so client recovery only installs the attribute when that layout - * mounts. Hosted detection also uses the browser hostname during this gap. - * `window.__ENV` stays the public global and the preferred read. + * An attribute has no such ordering problem. `` is the first tag in the + * document — ~490 bytes ahead of the first bootstrap script — so + * `document.documentElement` already carries this value by the time *any* + * script, framework or application, is able to execute. This is the race-free + * transport; `window.__ENV` stays the public global and the preferred read. + * + * Neither transport exists on a document that never ran the root layout (Next's + * bare `__next_error__` 404 shell, or `global-error`), so a tab that continues + * from one in place keeps reading an unset env. Deployment flags therefore reach + * workspace surfaces through the server-resolved host context instead — see + * `@/lib/core/config/deployment-shape`. */ export const PUBLIC_ENV_ATTRIBUTE = 'data-public-env' diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 14d53c9fa57..ddb6cb05375 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -6,6 +6,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ canOpenOrganizationSettingsSection: vi.fn(), checkWorkspaceAccess: vi.fn(), + deploymentShape: { + hosted: true, + billingEnabled: true, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: false, + sandboxes: false, + sessionPolicies: false, + sso: false, + usageMonitoring: false, + whitelabeling: false, + }, + }, + getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), getWorkspaceOwnerSubscriptionAccess: vi.fn(), isCredentialGroupsAvailable: vi.fn(), isCustomBlocksEligibleForOrganization: vi.fn(), @@ -18,7 +39,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/components/settings/navigation', () => ({ - getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), + getOrganizationSettingsFeatures: mocks.getOrganizationSettingsFeatures, isOrganizationSettingsSectionAvailable: mocks.isOrganizationSettingsSectionAvailable, resolveWorkspaceNavigation: mocks.resolveWorkspaceNavigation, UNIFIED_TO_ORGANIZATION_SECTION: { @@ -45,7 +66,9 @@ vi.mock('@/lib/billing/core/subscription', () => ({ vi.mock('@/lib/credential-groups/availability', () => ({ isCredentialGroupsAvailable: mocks.isCredentialGroupsAvailable, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, isHosted: true })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ + getDeploymentShape: () => mocks.deploymentShape, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, })) @@ -192,6 +215,20 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) + it('passes the server-resolved deployment shape to both navigation gates', async () => { + await authorize('secrets') + expect(mocks.resolveWorkspaceNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + hosted: true, + entitlements: expect.objectContaining({ byok: true }), + }) + ) + + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) + expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith(true, mocks.deploymentShape) + }) + it('resolves the exact entitlement source only for gated workspace sections', async () => { mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'credential-groups' }]) await authorize('credential-groups') diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 28227608393..7127cba0fce 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -10,7 +10,7 @@ import { } from '@/components/settings/navigation' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' @@ -62,11 +62,13 @@ async function canOpenWorkspaceSection( : false, ]) + const deployment = getDeploymentShape() const navigation = resolveWorkspaceNavigation({ permission, permissionConfig: accessControl?.config ?? {}, + hosted: deployment.hosted, entitlements: { - byok: isHosted, + byok: deployment.hosted, credentialGroups: credentialGroupsAvailable, inbox: true, customBlocks: customBlocksAvailable, @@ -86,7 +88,11 @@ async function canOpenOrganizationSection( ): Promise { const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[input.section] if (!organizationSection) return true - if (!isBillingEnabled && (input.section === 'billing' || input.section === 'organization')) { + const deployment = getDeploymentShape() + if ( + !deployment.billingEnabled && + (input.section === 'billing' || input.section === 'organization') + ) { return false } if (!workspace.organizationId) { @@ -104,7 +110,7 @@ async function canOpenOrganizationSection( canOpenSection && isOrganizationSettingsSectionAvailable( organizationSection, - getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization) + getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization, deployment) ) ) } diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 03656549c9c..2e9ed82e8ff 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -1,5 +1,5 @@ +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' @@ -627,7 +627,7 @@ export function isSubBlockHidden( subBlock: SubBlockConfig, options?: { hosted?: boolean } ): boolean { - const hosted = options?.hosted ?? isHosted + const hosted = options?.hosted ?? getDeploymentShape().hosted if (subBlock.hideWhenHosted && hosted) return true if (subBlock.hideWhenEnvSet && anyEnvSet(subBlock.hideWhenEnvSet)) return true return false diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 19cc0e1cfe8..db13e2865cf 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, })) +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' const OWNER_BILLING = { @@ -91,6 +92,7 @@ describe('getWorkspaceHostContextForViewer', () => { }, }) ) + expect(context?.deployment).toEqual(resolveDeploymentShape()) }) it('keeps an external collaborator authorized only by their workspace grant', async () => { diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 78cd140507f..10b4877459c 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -1,6 +1,7 @@ import { cache } from 'react' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' @@ -54,6 +55,7 @@ async function resolveWorkspaceHostContextForViewer( credentialGroups: credentialGroupsAvailable, knowledgeMemberAccess: knowledgeMemberAccessAvailable, }, + deployment: resolveDeploymentShape(), } } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index a21a933534c..2bb1713c2a2 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -8,7 +8,7 @@ import { type AgentStreamToolTerminalStatus, settleRunningToolCallList, } from '@/components/agent-stream/tool-call-lifecycle' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { redactApiKeys } from '@/lib/core/security/redaction' import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -317,7 +317,7 @@ const notifyBlockError = ({ toast.error(displayName, { description: errorMessage, - action: isChatEnabled + action: getDeploymentShape().chatEnabled ? { label: 'Fix in Chat', onClick: () => sendMothershipMessage(copilotMessage),