Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude/rules/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ 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`.
Client code inside a workspace, organization, or standalone settings surface 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, organization layout, or standalone settings layout. Server code keeps reading `env-flags`.

## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/global.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ 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`.
Client code inside a workspace, organization, or standalone settings surface 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, organization layout, or standalone settings layout. Server code keeps reading `env-flags`.

## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +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`
- **Deployment flags in the browser**: client code inside a workspace, organization, or standalone settings surface 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, organization layout, or standalone settings layout 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

Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/account/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'
import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isPlatformAdmin } from '@/lib/permissions/super-user'

export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) {
Expand All @@ -9,7 +10,11 @@ export default async function AccountSettingsLayout({ children }: { children: Re
const isSuperUser = await isPlatformAdmin(session.user.id)

return (
<StandaloneSettingsShell plane='account' isSuperUser={isSuperUser}>
<StandaloneSettingsShell
plane='account'
isSuperUser={isSuperUser}
deployment={resolveDeploymentShape()}
>
{children}
</StandaloneSettingsShell>
)
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/o/[organizationId]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { authMockFns } from '@sim/testing'
import { dehydrate } from '@tanstack/react-query'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'

const {
mockGetOrganizationSurfaceContext,
Expand Down Expand Up @@ -83,6 +83,7 @@ const SURFACE_CONTEXT = {
organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 },
viewer: { role: 'member', isAdmin: false },
searchAccess: { memberScoped: true, sourceMirrored: true },
deployment: resolveDeploymentShape(),
}

describe('OrganizationLayout', () => {
Expand Down Expand Up @@ -127,7 +128,7 @@ describe('OrganizationLayout', () => {
expect(html).toContain('Organization child')
expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
{ organizationId: 'org-1' },
isChatEnabled
SURFACE_CONTEXT.deployment.chatEnabled
)
expect(html).not.toContain('Stop impersonating')
expect(mockWorkspaceChrome).toHaveBeenCalledWith(
Expand Down
3 changes: 1 addition & 2 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
Expand Down Expand Up @@ -62,7 +61,7 @@ export default async function OrganizationLayout({

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrganizationProvider context={context} chatEnabled={isChatEnabled}>
<OrganizationProvider context={context}>
<GlobalCommandsProvider>
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @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 { mockUseMothershipChatEvents } = vi.hoisted(() => ({
mockUseMothershipChatEvents: vi.fn(),
}))

vi.mock('@/hooks/use-mothership-chat-events', () => ({
useMothershipChatEvents: mockUseMothershipChatEvents,
}))

import {
getDeploymentShape,
resetDeploymentShape,
resolveDeploymentShape,
useDeploymentShape,
} from '@/lib/core/config/deployment-shape'
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

function ScimReader() {
return <output data-testid='scim'>{String(useDeploymentShape().features.scim)}</output>
}

let host: HTMLDivElement
let root: Root

beforeEach(() => {
resetDeploymentShape()
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
vi.clearAllMocks()
})

describe('OrganizationProvider', () => {
it("seeds the context's deployment shape before children render and follows its chat switch", () => {
/** Differs from this environment's env fallback in every field read below. */
const fallback = resolveDeploymentShape()
const deployment = {
...fallback,
chatEnabled: !fallback.chatEnabled,
features: { ...fallback.features, scim: !fallback.features.scim },
}
const context = {
organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 },
searchAccess: { memberScoped: true, sourceMirrored: true },
deployment,
} as unknown as OrganizationSurfaceContext

act(() =>
root.render(
<OrganizationProvider context={context}>
<ScimReader />
</OrganizationProvider>
)
)

expect(host.querySelector('[data-testid="scim"]')?.textContent).toBe(
String(deployment.features.scim)
)
expect(getDeploymentShape()).toBe(deployment)
expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
{ organizationId: 'org-1' },
deployment.chatEnabled
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,27 @@
import { createContext, type ReactNode, useContext } from 'react'
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events'
import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape'

const OrganizationContextValue = createContext<OrganizationSurfaceContext | null>(null)

interface OrganizationProviderProps {
children: ReactNode
context: OrganizationSurfaceContext
chatEnabled: boolean
}

/**
* Provides the route-resolved organization and the viewer's standing in it to the
* organization surface. The layout resolves both on the server, so the first paint
* already knows the organization's name and logo.
* organization surface, and seeds the context's deployment shape before any child
* renders, as the workspace host provider does for workspace routes. The layout
* resolves the context on the server, so the first paint already knows the
* organization's name, logo, and which features this deployment serves.
*/
export function OrganizationProvider({
children,
context,
chatEnabled,
}: OrganizationProviderProps) {
export function OrganizationProvider({ children, context }: OrganizationProviderProps) {
useSeedDeploymentShape(context.deployment)
useMothershipChatEvents(
context.searchAccess.memberScoped ? { organizationId: context.organization.id } : undefined,
chatEnabled
context.deployment.chatEnabled
)
return (
<OrganizationContextValue.Provider value={context}>
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/selfhost/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'
import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'

export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session?.user) redirect('/login')

return <StandaloneSettingsShell plane='selfhost'>{children}</StandaloneSettingsShell>
return (
<StandaloneSettingsShell plane='selfhost' deployment={resolveDeploymentShape()}>
{children}
</StandaloneSettingsShell>
)
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
'use client'

import { createContext, type ReactNode, useContext, useEffect, useState } from 'react'
import { createContext, type ReactNode, useContext } from 'react'
import { isApiClientError } from '@/lib/api/client/errors'
import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { seedDeploymentShape } from '@/lib/core/config/deployment-shape'
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host'
import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape'

const WorkspaceHostContextValue = createContext<WorkspaceHostContext | null>(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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockSettingsSidebar } = vi.hoisted(() => ({
mockSettingsSidebar: vi.fn((_props: { items: { id: string }[] }) => null),
}))

vi.mock('next/navigation', () => ({ usePathname: () => '/selfhost/settings/general' }))
vi.mock('@/components/settings/settings-sidebar', () => ({ SettingsSidebar: mockSettingsSidebar }))
vi.mock('@/components/settings/settings-header', () => ({
SettingsHeaderProvider: ({ children }: { children: ReactNode }) => children,
SettingsHeaderShell: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/components/settings/settings-panel', () => ({
SettingsSectionProvider: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/components/settings/use-settings-before-unload', () => ({
useSettingsBeforeUnload: vi.fn(),
}))

import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import {
getDeploymentShape,
resetDeploymentShape,
resolveDeploymentShape,
} from '@/lib/core/config/deployment-shape'

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

let host: HTMLDivElement
let root: Root

beforeEach(() => {
resetDeploymentShape()
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
})

afterEach(() => {
act(() => root.unmount())
host.remove()
vi.clearAllMocks()
})

describe('StandaloneSettingsShell', () => {
it('filters its navigation by the server-resolved shape, not the env fallback', () => {
/** Inverts the fallback's hosted and billing switches, which decide the Billing and Chat keys items. */
const fallback = resolveDeploymentShape()
const deployment = {
...fallback,
hosted: !fallback.hosted,
billingEnabled: !fallback.billingEnabled,
}

act(() =>
root.render(
<StandaloneSettingsShell plane='selfhost' deployment={deployment}>
{null}
</StandaloneSettingsShell>
)
)

const itemIds = mockSettingsSidebar.mock.calls[0][0].items.map((item) => item.id)
expect(itemIds.includes('billing')).toBe(deployment.billingEnabled)
expect(itemIds.includes('chat-keys')).toBe(deployment.hosted)
expect(getDeploymentShape()).toBe(deployment)
})
})
5 changes: 5 additions & 0 deletions apps/sim/components/settings/standalone-settings-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ 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 type { DeploymentShape } from '@/lib/api/contracts/workspaces'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape'
import { SIDEBAR_WIDTH } from '@/stores/constants'

interface StandaloneSettingsShellBaseProps {
children: ReactNode
/** The server-resolved deployment shape, seeded before the sidebar and sections read it. */
deployment: DeploymentShape
}

interface AccountSettingsShellProps extends StandaloneSettingsShellBaseProps {
Expand All @@ -37,6 +41,7 @@ type StandaloneSettingsShellProps = AccountSettingsShellProps | SelfHostSettings

export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) {
const { children, plane } = props
useSeedDeploymentShape(props.deployment)
useSettingsBeforeUnload()
const pathname = usePathname()
const { hosted, billingEnabled } = useDeploymentShape()
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/hooks/use-seed-deployment-shape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client'

import { useEffect, useState } from 'react'
import type { DeploymentShape } from '@/lib/api/contracts/workspaces'
import { seedDeploymentShape } from '@/lib/core/config/deployment-shape'

/**
* Seeds a server-resolved deployment shape during the caller's own render, ahead of its
* children, then follows later changes to the shape in an effect.
*/
export function useSeedDeploymentShape(shape: DeploymentShape | undefined): void {
useState(() => seedDeploymentShape(shape))
useEffect(() => {
seedDeploymentShape(shape)
}, [shape])
}
Loading
Loading