Skip to content

Commit b20e65a

Browse files
fix(settings): resolve deployment shape from the server, not module env
The settings sidebar decided which sections exist from `NEXT_PUBLIC_*`-derived module constants. Those are frozen at module init, and a 404 renders from Next's `__next_error__` document, which never runs the root layout — so `window.__ENV` is unassigned and every read comes back undefined. On any 404 page `isHosted` and `isBillingEnabled` both read false, and Sim Cloud's sidebar rendered as a self-hosted deployment: "Self hosting" appeared while the eleven `requiresHosted` sections plus Subscription vanished. Read both from the server-resolved workspace host context instead, with the module constants kept as a fallback for a context that predates the field. `/settings/self-host` also no longer 404s on hosted — the catalog keeps every section this build can render, so the page gate redirects it to General like any other unavailable section, and only a genuinely unknown segment 404s.
1 parent ea6a6c2 commit b20e65a

9 files changed

Lines changed: 151 additions & 25 deletions

File tree

apps/sim/app/workspace/[workspaceId]/prefetch.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import type { QueryClient } from '@tanstack/react-query'
4-
import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
4+
import {
5+
getWorkspaceHostContextContract,
6+
listWorkspacesContract,
7+
type WorkspaceHostContext,
8+
} from '@/lib/api/contracts/workspaces'
59
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
610
import { isChatEnabled } from '@/lib/core/config/env-flags'
711
import { getUserProfile } from '@/lib/users/queries'
@@ -40,7 +44,11 @@ export function prefetchWorkspaceHostContext(
4044
): Promise<WorkspaceHostContext | null> {
4145
return queryClient.fetchQuery({
4246
queryKey: workspaceHostKeys.detail(workspaceId),
43-
queryFn: () => getWorkspaceHostContextForViewer(workspaceId, userId),
47+
/** Parsed through the response schema so the seed matches a client fetch, as the list seed does. */
48+
queryFn: async () => {
49+
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId)
50+
return hostContext && getWorkspaceHostContextContract.response.schema.parse(hostContext)
51+
},
4452
staleTime: WORKSPACE_HOST_CONTEXT_STALE_TIME,
4553
})
4654
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Deployment-dependent settings routing, pinned on the hosted side. Separate from
5+
* `navigation.test.ts` because the catalog it asserts against is a module-scope
6+
* constant, so `isHosted` has to differ per file rather than per test.
7+
*/
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
11+
...((await importOriginal()) as Record<string, unknown>),
12+
isHosted: true,
13+
}))
14+
15+
import { resolveWorkspaceNavigation } from '@/components/settings/navigation'
16+
import {
17+
allNavigationItems,
18+
resolveSettingsSection,
19+
} from '@/app/workspace/[workspaceId]/settings/navigation'
20+
21+
const ENTITLEMENTS = {
22+
byok: true,
23+
credentialGroups: true,
24+
inbox: true,
25+
customBlocks: true,
26+
forks: true,
27+
sandboxes: true,
28+
} as const
29+
30+
describe('self-host section on a hosted deployment', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
})
34+
35+
/**
36+
* A 404 renders from Next's `__next_error__` document, where no
37+
* `NEXT_PUBLIC_*` constant is readable — so the segment must resolve.
38+
*/
39+
it('resolves the segment so the page gate can redirect instead of 404ing', () => {
40+
expect(resolveSettingsSection('self-host')).toEqual({
41+
id: 'self-host',
42+
meta: {
43+
title: 'Self hosting',
44+
description: 'Manage this deployment from the Sim managed service.',
45+
docsLink: undefined,
46+
},
47+
})
48+
})
49+
50+
it('keeps the section in the catalog, since availability is not the route’s call', () => {
51+
expect(allNavigationItems.some(({ id }) => id === 'self-host')).toBe(true)
52+
})
53+
54+
it('excludes the section from workspace navigation, which is what triggers the redirect', () => {
55+
const navigation = resolveWorkspaceNavigation({
56+
permission: 'admin',
57+
permissionConfig: {},
58+
entitlements: { ...ENTITLEMENTS },
59+
})
60+
61+
expect(navigation.some(({ id }) => id === 'self-host')).toBe(false)
62+
})
63+
64+
it('leaves a genuinely unknown segment unresolved, so it still 404s', () => {
65+
expect(resolveSettingsSection('not-a-section')).toBeNull()
66+
})
67+
})

apps/sim/app/workspace/[workspaceId]/settings/navigation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
buildUnifiedSettingsNavigation,
2+
buildUnifiedSettingsCatalog,
33
SETTINGS_NAVIGATION_BILLING_ENABLED,
44
toSettingsHeaderMeta,
55
type UnifiedNavigationSection,
@@ -23,7 +23,8 @@ export const sectionConfig: { key: NavigationSection; title: string }[] = [
2323
{ key: 'platform', title: 'Platform' },
2424
]
2525

26-
export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsNavigation()
26+
/** Unfiltered — the sidebar applies deployment and entitlement visibility from the host context. */
27+
export const allNavigationItems: NavigationItem[] = buildUnifiedSettingsCatalog()
2728

2829
/**
2930
* Catalog entries indexed by id. Every routed navigation resolves a section, so the

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@ import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
1717
import { useSession } from '@/lib/auth/auth-client'
1818
import { getSubscriptionAccessState } from '@/lib/billing/client'
1919
import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions'
20-
import { isHosted } from '@/lib/core/config/env-flags'
20+
import {
21+
isBillingEnabled as isBillingEnabledAtModuleInit,
22+
isHosted as isHostedAtModuleInit,
23+
} from '@/lib/core/config/env-flags'
2124
import { hasBrowserAgent, hasDesktopSettings, hasTerminal } from '@/lib/desktop'
2225
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
2326
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2427
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
2528
import {
2629
allNavigationItems,
27-
isBillingEnabled,
2830
sectionConfig,
2931
} from '@/app/workspace/[workspaceId]/settings/navigation'
3032
import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section'
@@ -122,6 +124,13 @@ export function SettingsSidebar({
122124

123125
const { data: session } = useSession()
124126
const hostContext = useWorkspaceHostContext()
127+
/**
128+
* Server-resolved, not the `NEXT_PUBLIC_*` module constants: those read false on
129+
* the `__next_error__` 404 document, which renders Sim Cloud's sidebar as
130+
* self-hosted. Constants are the fallback for a host context predating the field.
131+
*/
132+
const isHosted = hostContext.deployment?.isHosted ?? isHostedAtModuleInit
133+
const isBillingEnabled = hostContext.deployment?.billingEnabled ?? isBillingEnabledAtModuleInit
125134
const { data: generalSettings } = useGeneralSettings()
126135
const { data: inboxConfig } = useInboxConfig(workspaceId)
127136
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({
@@ -147,10 +156,14 @@ export function SettingsSidebar({
147156
if (isHosted) return null
148157
if (!userId || isLoadingSSO) return null
149158
return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
150-
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
159+
}, [isHosted, userId, ssoProvidersData?.providers, isLoadingSSO])
151160

152161
const navigationItems = useMemo(() => {
153162
return allNavigationItems.filter((item) => {
163+
if (item.requiresSelfHosted && isHosted) {
164+
return false
165+
}
166+
154167
if (item.requiresDesktopSurface && !desktopSurfaces[item.requiresDesktopSurface]) {
155168
return false
156169
}
@@ -247,6 +260,8 @@ export function SettingsSidebar({
247260
return true
248261
})
249262
}, [
263+
isHosted,
264+
isBillingEnabled,
250265
hasTeamPlan,
251266
hasEnterprisePlan,
252267
isEnterprisePlan,

apps/sim/components/settings/navigation.test.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { afterAll, beforeEach, describe, expect, it } from 'vitest'
88
import {
99
ACCOUNT_SETTINGS_ITEMS,
1010
ACCOUNT_SETTINGS_PATH_ALIASES,
11-
buildUnifiedSettingsNavigation,
11+
buildUnifiedSettingsCatalog,
1212
canMutateWorkspaceSettingsSection,
1313
getAccountSettingsHref,
1414
getOrganizationSettingsHref,
@@ -39,12 +39,12 @@ afterAll(() => {
3939
describe('settings navigation boundaries', () => {
4040
it('keeps Custom Blocks opt-in on self-hosted deployments', () => {
4141
expect(
42-
buildUnifiedSettingsNavigation().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride
42+
buildUnifiedSettingsCatalog().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride
4343
).toBe(false)
4444
})
4545

4646
it('preserves the order of all four settings catalogs', () => {
47-
expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([
47+
expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toEqual([
4848
'general',
4949
'desktop',
5050
'browser',
@@ -115,7 +115,7 @@ describe('settings navigation boundaries', () => {
115115
it('keeps the Sandboxes section in the legacy self-hosted defaults', () => {
116116
setEnv({ NEXT_PUBLIC_SANDBOXES_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: undefined })
117117

118-
expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('sandboxes')
118+
expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('sandboxes')
119119
expect(
120120
resolveWorkspaceNavigation({
121121
permission: 'admin',
@@ -135,13 +135,13 @@ describe('settings navigation boundaries', () => {
135135
/**
136136
* The Self-host section links out to the managed service that issues this
137137
* deployment's Chat keys. On Sim Cloud that surface is reached from the
138-
* account plane instead, so the section must not exist there at all — in the
139-
* sidebar catalog or in the workspace-plane gate the route consults.
138+
* account plane instead, so the workspace-plane gate the route consults must
139+
* drop it there. The catalog keeps it either way — see the test below.
140140
*/
141141
it('shows the Self-host section only on a self-hosted deployment', () => {
142142
setEnvFlags({ isHosted: false })
143143

144-
expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('self-host')
144+
expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('self-host')
145145
expect(
146146
resolveWorkspaceNavigation({
147147
permission: 'admin',
@@ -158,10 +158,14 @@ describe('settings navigation boundaries', () => {
158158
).toContain('self-host')
159159
})
160160

161-
it('drops the Self-host section on hosted Sim', () => {
161+
/**
162+
* The catalog keeps the section on hosted Sim so the route can tell an
163+
* unavailable section from an unknown one and redirect instead of 404ing.
164+
*/
165+
it('drops the Self-host section from the hosted workspace gate but keeps it in the catalog', () => {
162166
setEnvFlags({ isHosted: true })
163167

164-
expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('self-host')
168+
expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('self-host')
165169
expect(
166170
resolveWorkspaceNavigation({
167171
permission: 'admin',
@@ -184,7 +188,7 @@ describe('settings navigation boundaries', () => {
184188
* one colored item in a monochrome icon column.
185189
*/
186190
it('marks the Self hosting section with a currentColor line icon', () => {
187-
const selfHost = buildUnifiedSettingsNavigation().find(({ id }) => id === 'self-host')
191+
const selfHost = buildUnifiedSettingsCatalog().find(({ id }) => id === 'self-host')
188192
const markup = renderToStaticMarkup(createElement(selfHost!.icon, {}))
189193

190194
expect(selfHost?.label).toBe('Self hosting')
@@ -215,7 +219,7 @@ describe('settings navigation boundaries', () => {
215219
expect(new Set(selfHostIds).size).toBe(selfHostIds.length)
216220
expect(new Set(workspaceIds).size).toBe(workspaceIds.length)
217221
expect([...unifiedIds].sort()).toEqual(
218-
buildUnifiedSettingsNavigation()
222+
buildUnifiedSettingsCatalog()
219223
.map(({ id }) => id)
220224
.sort()
221225
)
@@ -242,7 +246,7 @@ describe('settings navigation boundaries', () => {
242246
})
243247

244248
it('shares labels, icons, and docs links across projections', () => {
245-
const unifiedSso = buildUnifiedSettingsNavigation().find(({ id }) => id === 'sso')
249+
const unifiedSso = buildUnifiedSettingsCatalog().find(({ id }) => id === 'sso')
246250
const organizationSso = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'sso')
247251

248252
expect(organizationSso?.label).toBe(unifiedSso?.label)
@@ -252,7 +256,7 @@ describe('settings navigation boundaries', () => {
252256

253257
it('uses scope-specific labels consistently across settings surfaces', () => {
254258
const organizationMembers = ORGANIZATION_SETTINGS_ITEMS.find(({ id }) => id === 'members')
255-
const unifiedOrganization = buildUnifiedSettingsNavigation().find(
259+
const unifiedOrganization = buildUnifiedSettingsCatalog().find(
256260
({ id }) => id === 'organization'
257261
)
258262

apps/sim/components/settings/navigation.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -830,12 +830,19 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
830830
},
831831
]
832832

833-
export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] {
833+
/**
834+
* Every unified section this build knows how to render, including ones the
835+
* current deployment does not offer.
836+
*
837+
* Route resolution reads this rather than the filtered navigation so an
838+
* unavailable section stays a *known* segment the page gate can redirect to
839+
* General. Resolving it to nothing answers 404 instead — and a 404 document
840+
* breaks every `NEXT_PUBLIC_*` read (see `deployment` in
841+
* `@/lib/api/contracts/workspaces`).
842+
*/
843+
export function buildUnifiedSettingsCatalog(): UnifiedSettingsNavigationItem[] {
834844
return SETTINGS_SECTION_REGISTRY.flatMap(({ label, icon, docsLink, unified }) => {
835845
if (!unified) return []
836-
// Dropped here so the sidebar, the route's `parseSection` gate, and section
837-
// metadata all agree that the section does not exist on Sim Cloud.
838-
if (unified.requiresSelfHosted && isHosted) return []
839846
const { group, ...item } = unified
840847
return [
841848
{

apps/sim/lib/api/contracts/workspaces.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,23 @@ export const workspaceHostContextSchema = z.object({
274274
credentialGroups: z.boolean(),
275275
})
276276
.optional(),
277+
/**
278+
* Deployment shape, resolved per request from `process.env` on the server.
279+
*
280+
* The client-side `NEXT_PUBLIC_*` module constants cannot be trusted for this:
281+
* they are frozen at module init, and on Next's `__next_error__` document — the
282+
* shell every 404 renders from — the root layout never runs, so `window.__ENV`
283+
* is unassigned and every read comes back undefined.
284+
*
285+
* Optional for rolling compatibility; consumers fall back to those constants,
286+
* which are correct on every document that runs the root layout.
287+
*/
288+
deployment: z
289+
.object({
290+
isHosted: z.boolean(),
291+
billingEnabled: z.boolean(),
292+
})
293+
.optional(),
277294
})
278295

279296
export type WorkspaceHostContext = z.output<typeof workspaceHostContextSchema>

apps/sim/lib/billing/workspace-permissions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ export function canViewWorkspaceBillingSettings(
3232
hostContext: WorkspaceHostContext,
3333
viewerUserId?: string | null
3434
): boolean {
35-
return isBillingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId)
35+
// Constant reads false on the `__next_error__` 404 document; see `deployment` in the contract.
36+
const billingEnabled = hostContext.deployment?.billingEnabled ?? isBillingEnabled
37+
return billingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId)
3638
}
3739

3840
/**

apps/sim/lib/workspaces/host-context.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { cache } from 'react'
22
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
33
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
4+
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
45
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
56
import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access'
67
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -48,6 +49,10 @@ async function resolveWorkspaceHostContextForViewer(
4849
features: {
4950
credentialGroups: credentialGroupsAvailable,
5051
},
52+
deployment: {
53+
isHosted,
54+
billingEnabled: isBillingEnabled,
55+
},
5156
}
5257
}
5358

0 commit comments

Comments
 (0)