Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/prefetch.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,7 +44,11 @@ export function prefetchWorkspaceHostContext(
): Promise<WorkspaceHostContext | null> {
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,
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>),
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()
})
})
5 changes: 3 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
buildUnifiedSettingsNavigation,
buildUnifiedSettingsCatalog,
SETTINGS_NAVIGATION_BILLING_ENABLED,
toSettingsHeaderMeta,
type UnifiedNavigationSection,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand All @@ -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
}
Expand Down Expand Up @@ -248,6 +261,8 @@ export function SettingsSidebar({
return true
})
}, [
isHosted,
isBillingEnabled,
hasTeamPlan,
hasEnterprisePlan,
isEnterprisePlan,
Expand Down
28 changes: 16 additions & 12 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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')
Expand All @@ -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()
)
Expand Down Expand Up @@ -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'
)

Expand Down
15 changes: 11 additions & 4 deletions apps/sim/components/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/api/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof workspaceHostContextSchema>
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/billing/workspace-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/workspaces/host-context.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -48,6 +49,10 @@ async function resolveWorkspaceHostContextForViewer(
features: {
credentialGroups: credentialGroupsAvailable,
},
deployment: {
isHosted,
billingEnabled: isBillingEnabled,
},
}
}

Expand Down
Loading