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
+}
+
+function ContextReader() {
+ const { deployment } = useWorkspaceHostContext()
+ return
+}
+
+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
+}
+
+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),