diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index ebabb4df975..fbefbb33e7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { QueryClient } from '@tanstack/react-query' -import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { + getWorkspaceHostContextContract, + listWorkspacesContract, + type WorkspaceHostContext, +} from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { getUserProfile } from '@/lib/users/queries' @@ -40,7 +44,11 @@ export function prefetchWorkspaceHostContext( ): Promise { return queryClient.fetchQuery({ queryKey: workspaceHostKeys.detail(workspaceId), - queryFn: () => getWorkspaceHostContextForViewer(workspaceId, userId), + /** Parsed through the response schema so the seed matches a client fetch, as the list seed does. */ + queryFn: async () => { + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) + return hostContext && getWorkspaceHostContextContract.response.schema.parse(hostContext) + }, staleTime: WORKSPACE_HOST_CONTEXT_STALE_TIME, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.hosted.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.hosted.test.ts new file mode 100644 index 00000000000..179f8018b23 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.hosted.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + * + * Deployment-dependent settings routing, pinned on the hosted side. Separate from + * `navigation.test.ts` because the catalog it asserts against is a module-scope + * constant, so `isHosted` has to differ per file rather than per test. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + isHosted: true, +})) + +import { resolveWorkspaceNavigation } from '@/components/settings/navigation' +import { + allNavigationItems, + resolveSettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' + +const ENTITLEMENTS = { + byok: true, + credentialGroups: true, + inbox: true, + customBlocks: true, + forks: true, + sandboxes: true, +} as const + +describe('self-host section on a hosted deployment', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * A 404 renders from Next's `__next_error__` document, where no + * `NEXT_PUBLIC_*` constant is readable — so the segment must resolve. + */ + it('resolves the segment so the page gate can redirect instead of 404ing', () => { + expect(resolveSettingsSection('self-host')).toEqual({ + id: 'self-host', + meta: { + title: 'Self hosting', + description: 'Manage this deployment from the Sim managed service.', + docsLink: undefined, + }, + }) + }) + + it('keeps the section in the catalog, since availability is not the route’s call', () => { + expect(allNavigationItems.some(({ id }) => id === 'self-host')).toBe(true) + }) + + it('excludes the section from workspace navigation, which is what triggers the redirect', () => { + const navigation = resolveWorkspaceNavigation({ + permission: 'admin', + permissionConfig: {}, + entitlements: { ...ENTITLEMENTS }, + }) + + expect(navigation.some(({ id }) => id === 'self-host')).toBe(false) + }) + + it('leaves a genuinely unknown segment unresolved, so it still 404s', () => { + expect(resolveSettingsSection('not-a-section')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 57de4bd4a04..91a094e30ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -1,5 +1,5 @@ import { - buildUnifiedSettingsNavigation, + buildUnifiedSettingsCatalog, SETTINGS_NAVIGATION_BILLING_ENABLED, toSettingsHeaderMeta, type UnifiedNavigationSection, @@ -23,7 +23,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/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 60c26715a5b..a2144716b20 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 @@ -18,14 +18,16 @@ 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 { + isBillingEnabled as isBillingEnabledAtModuleInit, + isHosted as isHostedAtModuleInit, +} from '@/lib/core/config/env-flags' 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 { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' @@ -123,6 +125,13 @@ export function SettingsSidebar({ const { data: session } = useSession() const hostContext = useWorkspaceHostContext() + /** + * Server-resolved, not the `NEXT_PUBLIC_*` module constants: those read false on + * the `__next_error__` 404 document, which renders Sim Cloud's sidebar as + * self-hosted. Constants are the fallback for a host context predating the field. + */ + const isHosted = hostContext.deployment?.isHosted ?? isHostedAtModuleInit + const isBillingEnabled = hostContext.deployment?.billingEnabled ?? isBillingEnabledAtModuleInit const { data: generalSettings } = useGeneralSettings() const { data: inboxConfig } = useInboxConfig(workspaceId) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({ @@ -148,10 +157,14 @@ export function SettingsSidebar({ if (isHosted) return null if (!userId || isLoadingSSO) return null return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false - }, [userId, ssoProvidersData?.providers, isLoadingSSO]) + }, [isHosted, userId, ssoProvidersData?.providers, isLoadingSSO]) const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.requiresSelfHosted && isHosted) { + return false + } + if (item.requiresDesktopSurface && !desktopSurfaces[item.requiresDesktopSurface]) { return false } @@ -248,6 +261,8 @@ export function SettingsSidebar({ return true }) }, [ + isHosted, + isBillingEnabled, hasTeamPlan, hasEnterprisePlan, isEnterprisePlan, diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index df3a5f0d5cd..c43b6ffd184 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, - buildUnifiedSettingsNavigation, + buildUnifiedSettingsCatalog, canMutateWorkspaceSettingsSection, getAccountSettingsHref, getWorkspaceSettingsHref, @@ -37,12 +37,12 @@ afterAll(() => { describe('settings navigation boundaries', () => { it('keeps Custom Blocks opt-in on self-hosted deployments', () => { expect( - buildUnifiedSettingsNavigation().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride + buildUnifiedSettingsCatalog().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride ).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', @@ -103,7 +103,7 @@ 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') + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('sandboxes') expect( resolveWorkspaceNavigation({ permission: 'admin', @@ -123,13 +123,13 @@ 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. The catalog keeps it either way — see the test below. */ it('shows the Self-host section only on a self-hosted deployment', () => { setEnvFlags({ isHosted: false }) - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('self-host') + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('self-host') expect( resolveWorkspaceNavigation({ permission: 'admin', @@ -146,10 +146,14 @@ describe('settings navigation boundaries', () => { ).toContain('self-host') }) - it('drops the Self-host section on hosted Sim', () => { + /** + * The catalog keeps the section on hosted Sim so the route can tell an + * unavailable section from an unknown one and redirect instead of 404ing. + */ + it('drops the Self-host section from the hosted workspace gate but keeps it in the catalog', () => { setEnvFlags({ isHosted: true }) - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('self-host') + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('self-host') expect( resolveWorkspaceNavigation({ permission: 'admin', @@ -172,7 +176,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') @@ -199,7 +203,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() ) @@ -245,7 +249,7 @@ describe('settings navigation boundaries', () => { }) it('labels the members section consistently', () => { - const unifiedOrganization = buildUnifiedSettingsNavigation().find( + const unifiedOrganization = buildUnifiedSettingsCatalog().find( ({ id }) => id === 'organization' ) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index dcb92ef1beb..4290ffd5a0e 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -823,12 +823,19 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, ] -export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { +/** + * Every unified section this build knows how to render, including ones the + * current deployment does not offer. + * + * Route resolution reads this rather than the filtered navigation so an + * unavailable section stays a *known* segment the page gate can redirect to + * General. Resolving it to nothing answers 404 instead — and a 404 document + * breaks every `NEXT_PUBLIC_*` read (see `deployment` in + * `@/lib/api/contracts/workspaces`). + */ +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 [ { diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index beac7ce329f..a6fcdb0accb 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -274,6 +274,23 @@ export const workspaceHostContextSchema = z.object({ credentialGroups: z.boolean(), }) .optional(), + /** + * Deployment shape, resolved per request from `process.env` on the server. + * + * The client-side `NEXT_PUBLIC_*` module constants cannot be trusted for this: + * they are frozen at module init, and on Next's `__next_error__` document — the + * shell every 404 renders from — the root layout never runs, so `window.__ENV` + * is unassigned and every read comes back undefined. + * + * Optional for rolling compatibility; consumers fall back to those constants, + * which are correct on every document that runs the root layout. + */ + deployment: z + .object({ + isHosted: z.boolean(), + billingEnabled: z.boolean(), + }) + .optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/billing/workspace-permissions.ts b/apps/sim/lib/billing/workspace-permissions.ts index 41c57c574eb..c3b1f913d56 100644 --- a/apps/sim/lib/billing/workspace-permissions.ts +++ b/apps/sim/lib/billing/workspace-permissions.ts @@ -32,7 +32,9 @@ export function canViewWorkspaceBillingSettings( hostContext: WorkspaceHostContext, viewerUserId?: string | null ): boolean { - return isBillingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) + // Constant reads false on the `__next_error__` 404 document; see `deployment` in the contract. + const billingEnabled = hostContext.deployment?.billingEnabled ?? isBillingEnabled + return billingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) } /** diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 0c66496eb27..f95b0d31226 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 { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -48,6 +49,10 @@ async function resolveWorkspaceHostContextForViewer( features: { credentialGroups: credentialGroupsAvailable, }, + deployment: { + isHosted, + billingEnabled: isBillingEnabled, + }, } }