Skip to content

Commit c16efce

Browse files
icecrasher321claude
andcommitted
fix(config): resolve the deployment shape on the server and read it through one client reader
Client-side deployment flags (`isHosted`, `isBillingEnabled`, `isChatEnabled`, provider-configured flags, and the enterprise feature set) were module constants computed once from the `NEXT_PUBLIC_*` transport the root layout emits. A document that never runs the root layout — Next's bare `__next_error__` 404 shell, or `global-error` after the root or workspace layout throws — leaves every one of them unset for the life of the tab, including after `retry()` or a client-side navigation recovers the app in place. Sim Cloud then rendered as self-hosted: an API Key field on every hosted-model block, no Auto model, no billing sections, "Self hosting" in settings. Project the deployment shape into the workspace host context, resolved on the server per request (`resolveDeploymentShape`), and give browser code one reader: `useDeploymentShape()` for components and `getDeploymentShape()` for block conditions, sub-block visibility, stores, and helpers. The host provider seeds the reader during its own render, ahead of any workspace child, so the first paint already reads the server value; outside a workspace, where the root layout always runs, the env constants remain the fallback. Parameterize the settings catalog on the shape instead of module constants: `selfHostedOverride` names a feature key resolved by `isSelfHostedOverrideEnabled`, `buildUnifiedSettingsCatalog` is unfiltered so `/settings/self-host` redirects to General on hosted instead of 404ing, and the server section gate passes the same shape. Retire the browser-hostname fallback for `isHosted` (superseded) and the module-scope env reads in the catalog. Tests cover the resolver, the env-less document with and without a seeded shape, provider seeding order, catalog resolution on both deployment kinds, and the billing gate reading the host context; four component suites move from partial `env-flags` factories to `setEnvFlags`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 389eea4 commit c16efce

60 files changed

Lines changed: 1065 additions & 378 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/global.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ const clone = structuredClone(obj)
6868
const filtered = filterUndefined(obj)
6969
```
7070

71+
## Deployment flags in the browser
72+
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`.
73+
7174
## Package Manager
7275
Use `bun` and `bunx`, not `npm` and `npx`.
7376

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ You are a professional software engineer. All code must follow best practices: a
1818
- `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))`
1919
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis
2020
- `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline
21+
- **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`
2122
- **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx`
2223
- **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
2324

apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger'
1616
import type { BatchInvitationResult } from '@/lib/api/contracts/invitations'
1717
import { useSession } from '@/lib/auth/auth-client'
1818
import { isEnterprise } from '@/lib/billing/plan-helpers'
19-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
19+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
2020
import { quickValidateEmail } from '@/lib/messaging/email/validation'
2121
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
2222
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -141,6 +141,7 @@ export function InviteModal({
141141
}
142142

143143
const { data: session } = useSession()
144+
const { billingEnabled } = useDeploymentShape()
144145
const isOrganizationInvite = Boolean(organizationId)
145146

146147
const sendInvitations = useSendWorkspaceInvitations()
@@ -190,7 +191,7 @@ export function InviteModal({
190191
hostContext.viewer.isHostOrganizationAdmin
191192

192193
const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
193-
enabled: open && isBillingEnabled && canViewOrganizationBilling,
194+
enabled: open && billingEnabled && canViewOrganizationBilling,
194195
})
195196

196197
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0

apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import { useSession } from '@/lib/auth/auth-client'
99
import { formatCredits } from '@/lib/billing/credits/conversion'
1010
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
1111
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
12-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
12+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1313
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
1414
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
1515
import { useWorkspaceCreditAvailability } from '@/hooks/queries/workspace-usage'
1616

1717
export function CreditsChip() {
18-
if (!isBillingEnabled) return null
18+
const { billingEnabled } = useDeploymentShape()
19+
if (!billingEnabled) return null
1920

2021
return <CreditsChipInner />
2122
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useSession } from '@/lib/auth/auth-client'
2121
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
2222
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
2323
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
24-
import { isHosted } from '@/lib/core/config/env-flags'
24+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
2525
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
2626
import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt'
2727
import { getDesktopBridge } from '@/lib/desktop'
@@ -2990,16 +2990,17 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
29902990
const { data: session } = useSession()
29912991
const hostContext = useWorkspaceHostContext()
29922992
const { getSettingsHref } = useSettingsNavigation()
2993+
const { hosted } = useDeploymentShape()
29932994
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
29942995

29952996
// Self-hosted plan and limit both live on the hosted account, so local
29962997
// workspace billing roles say nothing about who may change them.
2997-
const href = isHosted
2998+
const href = hosted
29982999
? getSettingsHref({ section: 'billing' })
29993000
: data.action === 'upgrade_plan'
30003001
? buildHostedUpgradeUrl()
30013002
: HOSTED_BILLING_SETTINGS_URL
3002-
const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
3003+
const canManageBilling = !hosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
30033004
const unavailableMessage = hostContext.hostOrganizationId
30043005
? 'Contact an organization admin to manage this workspace’s usage limits.'
30053006
: 'Only the workspace owner can manage this workspace’s usage limits.'
@@ -3032,13 +3033,13 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
30323033
{canManageBilling ? (
30333034
<a
30343035
href={href}
3035-
target={isHosted ? undefined : '_blank'}
3036-
rel={isHosted ? undefined : 'noopener noreferrer'}
3037-
aria-label={isHosted ? undefined : `${buttonLabel} (opens in a new tab)`}
3036+
target={hosted ? undefined : '_blank'}
3037+
rel={hosted ? undefined : 'noopener noreferrer'}
3038+
aria-label={hosted ? undefined : `${buttonLabel} (opens in a new tab)`}
30383039
className='mt-2 inline-flex items-center gap-1 text-amber-700 text-small underline decoration-dashed underline-offset-2 transition-colors hover-hover:text-amber-900 dark:text-amber-300 dark:hover-hover:text-amber-200'
30393040
>
30403041
{buttonLabel}
3041-
{isHosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
3042+
{hosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
30423043
</a>
30433044
) : (
30443045
<p className='mt-2 text-amber-700 text-small dark:text-amber-300'>{unavailableMessage}</p>

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ArrowLeft, Plus } from '@sim/emcn/icons'
66
import { useRouter } from 'next/navigation'
77
import { useQueryState } from 'nuqs'
88
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
9-
import { isChatEnabled } from '@/lib/core/config/env-flags'
9+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1010
import {
1111
blockTypeToIconMap,
1212
type Integration,
@@ -69,6 +69,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
6969
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
7070
const oauthService = resolveOAuthServiceForIntegration(integration)
7171
const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig()
72+
const { chatEnabled } = useDeploymentShape()
7273
const availability = integrationAvailability.get(integration.type.toLowerCase())
7374
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
7475
const [oauthOpen, setOAuthOpen] = useState(false)
@@ -197,7 +198,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
197198
) : (
198199
<Chip disabled>Unavailable</Chip>
199200
)
200-
) : isChatEnabled ? (
201+
) : chatEnabled ? (
201202
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
202203
Add to Sim
203204
</Chip>
@@ -279,7 +280,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
279280

280281
{/* Every template hands its prompt to Chat, so the section has no
281282
destination without it. */}
282-
{isChatEnabled && matchingTemplates.length > 0 && (
283+
{chatEnabled && matchingTemplates.length > 0 && (
283284
<TemplatesSection
284285
integration={integration}
285286
templates={matchingTemplates}

apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Chip } from '@sim/emcn'
44
import { ArrowRight } from '@sim/emcn/icons'
55
import { useParams, useRouter } from 'next/navigation'
6-
import { isChatEnabled } from '@/lib/core/config/env-flags'
6+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
77
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
88
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
99

@@ -25,12 +25,13 @@ interface ShowcaseWithExploreProps {
2525
export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
2626
const params = useParams()
2727
const router = useRouter()
28+
const { chatEnabled } = useDeploymentShape()
2829
const workspaceId = (params?.workspaceId as string) || ''
2930

3031
return (
3132
<div className='relative'>
3233
<IntegrationsShowcase />
33-
{isChatEnabled && (
34+
{chatEnabled && (
3435
<Chip
3536
active
3637
rightIcon={ArrowRight}
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces'
22
import { getSubscriptionAccessState } from '@/lib/billing/client'
3-
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
3+
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
44

55
/**
66
* Client mirror of `hasWorkspaceLiveSyncAccess`.
77
*
8-
* Reads the same two env flags in the same order as the server helper so the two
9-
* cannot diverge: sub-hourly sync is ungated off the hosted deployment even when
10-
* billing is enabled. Without the `isHosted` branch a self-hosted operator with
11-
* billing on saw the "Live" interval locked while the API would have accepted it.
8+
* Reads the same two deployment flags in the same order as the server helper so the
9+
* two cannot diverge: sub-hourly sync is ungated off the hosted deployment even when
10+
* billing is enabled. Without the `hosted` branch a self-hosted operator with billing
11+
* on saw the "Live" interval locked while the API would have accepted it.
1212
*/
1313
export function hasWorkspaceMaxConnectorAccess(ownerBilling: WorkspaceOwnerBilling): boolean {
14-
if (!isHosted || !isBillingEnabled) return true
14+
const { hosted, billingEnabled } = getDeploymentShape()
15+
if (!hosted || !billingEnabled) return true
1516
return getSubscriptionAccessState(ownerBilling).hasUsableMaxAccess
1617
}

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { createPortal } from 'react-dom'
5252
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
5353
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
5454
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
55-
import { isChatEnabled } from '@/lib/core/config/env-flags'
55+
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
5656
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
5757
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
5858
import type { TraceSpan } from '@/lib/logs/types'
@@ -319,6 +319,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
319319
...logDetailsTabUrlKeys,
320320
})
321321
const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 })
322+
const { chatEnabled } = useDeploymentShape()
322323

323324
const scrollAreaRef = useRef<HTMLDivElement>(null)
324325

@@ -451,7 +452,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
451452
* mothership-triggered logs are excluded — `isLikelyExecution` already encodes
452453
* "has an executionId and isn't a mothership run".
453454
*/
454-
const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution
455+
const canTroubleshoot = chatEnabled && log.status === 'failed' && isLikelyExecution
455456

456457
/**
457458
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUseWorkspaceHostContextQuery } = vi.hoisted(() => ({
9+
mockUseWorkspaceHostContextQuery: vi.fn(),
10+
}))
11+
12+
vi.mock('@/hooks/queries/workspace-host', () => ({
13+
useWorkspaceHostContextQuery: mockUseWorkspaceHostContextQuery,
14+
}))
15+
16+
vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({
17+
WorkspaceAccessDenied: () => <output data-testid='denied' />,
18+
}))
19+
20+
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
21+
import {
22+
getDeploymentShape,
23+
resetDeploymentShape,
24+
resolveDeploymentShape,
25+
} from '@/lib/core/config/deployment-shape'
26+
import {
27+
useWorkspaceHostContext,
28+
WorkspaceHostProvider,
29+
} from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
30+
31+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
32+
33+
const HOST_CONTEXT: WorkspaceHostContext = {
34+
workspace: {
35+
id: 'workspace-1',
36+
name: 'Workspace',
37+
workspaceMode: 'organization',
38+
billedAccountUserId: 'owner-1',
39+
},
40+
hostOrganizationId: 'org-1',
41+
ownerBilling: {
42+
plan: 'team',
43+
status: 'active',
44+
isPaid: true,
45+
isPro: false,
46+
isTeam: true,
47+
isEnterprise: false,
48+
isOrgScoped: true,
49+
organizationId: 'org-1',
50+
billingInterval: 'month',
51+
billingBlocked: false,
52+
billingBlockedReason: null,
53+
},
54+
viewer: {
55+
permission: 'admin',
56+
isHostOrganizationMember: true,
57+
isHostOrganizationAdmin: true,
58+
},
59+
deployment: {
60+
...resolveDeploymentShape(),
61+
hosted: true,
62+
billingEnabled: true,
63+
},
64+
}
65+
66+
/** Reads the getter during render, the way block conditions do. */
67+
function GetterReader() {
68+
return <output data-testid='getter'>{String(getDeploymentShape().hosted)}</output>
69+
}
70+
71+
function ContextReader() {
72+
const { deployment } = useWorkspaceHostContext()
73+
return <output data-testid='context'>{String(deployment?.billingEnabled)}</output>
74+
}
75+
76+
let host: HTMLDivElement
77+
let root: Root
78+
79+
function renderProvider(initialContext: WorkspaceHostContext) {
80+
act(() =>
81+
root.render(
82+
<WorkspaceHostProvider workspaceId='workspace-1' initialContext={initialContext}>
83+
<GetterReader />
84+
<ContextReader />
85+
</WorkspaceHostProvider>
86+
)
87+
)
88+
}
89+
90+
function textOf(testId: string): string | undefined {
91+
return host.querySelector(`[data-testid="${testId}"]`)?.textContent ?? undefined
92+
}
93+
94+
beforeEach(() => {
95+
resetDeploymentShape()
96+
mockUseWorkspaceHostContextQuery.mockReturnValue({ data: undefined, error: null })
97+
host = document.createElement('div')
98+
document.body.appendChild(host)
99+
root = createRoot(host)
100+
})
101+
102+
afterEach(() => {
103+
act(() => root.unmount())
104+
host.remove()
105+
vi.clearAllMocks()
106+
})
107+
108+
describe('WorkspaceHostProvider', () => {
109+
it('seeds the server deployment shape before workspace children render', () => {
110+
renderProvider(HOST_CONTEXT)
111+
112+
expect(textOf('getter')).toBe('true')
113+
expect(textOf('context')).toBe('true')
114+
expect(getDeploymentShape()).toBe(HOST_CONTEXT.deployment)
115+
})
116+
117+
it('follows the refetched host context over the initial one', () => {
118+
mockUseWorkspaceHostContextQuery.mockReturnValue({
119+
data: {
120+
...HOST_CONTEXT,
121+
deployment: { ...HOST_CONTEXT.deployment!, billingEnabled: false },
122+
},
123+
error: null,
124+
})
125+
126+
renderProvider(HOST_CONTEXT)
127+
128+
expect(textOf('context')).toBe('false')
129+
expect(getDeploymentShape().billingEnabled).toBe(false)
130+
})
131+
132+
it('keeps the env fallback for a host context that predates deployment projection', () => {
133+
const { deployment: _legacy, ...legacyContext } = HOST_CONTEXT
134+
135+
renderProvider(legacyContext)
136+
137+
expect(textOf('getter')).toBe('false')
138+
expect(textOf('context')).toBe('undefined')
139+
})
140+
})

0 commit comments

Comments
 (0)