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
3 changes: 3 additions & 0 deletions .claude/rules/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ const clone = structuredClone(obj)
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`.

## Package Manager
Use `bun` and `bunx`, not `npm` and `npx`.

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +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`
- **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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger'
import type { BatchInvitationResult } from '@/lib/api/contracts/invitations'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
Expand Down Expand Up @@ -141,6 +141,7 @@ export function InviteModal({
}

const { data: session } = useSession()
const { billingEnabled } = useDeploymentShape()
const isOrganizationInvite = Boolean(organizationId)

const sendInvitations = useSendWorkspaceInvitations()
Expand Down Expand Up @@ -190,7 +191,7 @@ export function InviteModal({
hostContext.viewer.isHostOrganizationAdmin

const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
enabled: open && isBillingEnabled && canViewOrganizationBilling,
enabled: open && billingEnabled && canViewOrganizationBilling,
})

const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
type ReactElement,
type ReactNode,
} from 'react'
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

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

Expand Down Expand Up @@ -287,7 +288,9 @@ vi.mock('@/components/ui', () => ({
),
}))

vi.mock('@/lib/core/config/env-flags', () => ({ isSsoEnabled: true }))
/** SSO is a deployment feature, read through the deployment shape at render time. */
beforeAll(() => setEnvFlags({ isSsoEnabled: true }))
afterAll(resetEnvFlagsMock)
vi.mock('@/lib/messaging/email/validation', () => ({
validateAllowlistEntry: () => null,
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Check, Link, Send } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
import { usePermissionConfig } from '@/hooks/use-permission-config'
Expand Down Expand Up @@ -75,6 +75,7 @@ export function ShareModal({
const { config: permissionConfig } = usePermissionConfig()
const upsertShare = useUpsertFileShare()
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const { features } = useDeploymentShape()

const shareReadReady = isFetchedAfterMount && !isShareError
const saved = shareReadReady ? (share ?? null) : (share ?? initialShare ?? null)
Expand All @@ -91,7 +92,7 @@ export function ShareModal({
const isAuthTypeAllowed = (mode: ShareAuthType) =>
allowedAuthTypes === null || allowedAuthTypes.includes(mode)

const ssoEnabled = isSsoEnabled || savedAccessMode === 'sso'
const ssoEnabled = features.sso || savedAccessMode === 'sso'
const candidateAuthTypes: ShareAuthType[] = [
'public',
'password',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { useSession } from '@/lib/auth/auth-client'
import { formatCredits } from '@/lib/billing/credits/conversion'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
import { useWorkspaceCreditAvailability } from '@/hooks/queries/workspace-usage'

export function CreditsChip() {
if (!isBillingEnabled) return null
const { billingEnabled } = useDeploymentShape()
if (!billingEnabled) return null

return <CreditsChipInner />
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { isHosted } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt'
import { getDesktopBridge } from '@/lib/desktop'
Expand Down Expand Up @@ -2990,16 +2990,17 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const { data: session } = useSession()
const hostContext = useWorkspaceHostContext()
const { getSettingsHref } = useSettingsNavigation()
const { hosted } = useDeploymentShape()
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'

// Self-hosted plan and limit both live on the hosted account, so local
// workspace billing roles say nothing about who may change them.
const href = isHosted
const href = hosted
? getSettingsHref({ section: 'billing' })
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl()
: HOSTED_BILLING_SETTINGS_URL
const canManageBilling = !isHosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
const canManageBilling = !hosted || canManageWorkspaceBilling(hostContext, session?.user?.id)
const unavailableMessage = hostContext.hostOrganizationId
? 'Contact an organization admin to manage this workspace’s usage limits.'
: 'Only the workspace owner can manage this workspace’s usage limits.'
Expand Down Expand Up @@ -3032,13 +3033,13 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
{canManageBilling ? (
<a
href={href}
target={isHosted ? undefined : '_blank'}
rel={isHosted ? undefined : 'noopener noreferrer'}
aria-label={isHosted ? undefined : `${buttonLabel} (opens in a new tab)`}
target={hosted ? undefined : '_blank'}
rel={hosted ? undefined : 'noopener noreferrer'}
aria-label={hosted ? undefined : `${buttonLabel} (opens in a new tab)`}
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'
>
{buttonLabel}
{isHosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
{hosted ? <ArrowRight className='size-3' /> : <SquareArrowUpRight className='size-3' />}
</a>
) : (
<p className='mt-2 text-amber-700 text-small dark:text-amber-300'>{unavailableMessage}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ArrowLeft, Plus } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import {
blockTypeToIconMap,
type Integration,
Expand Down Expand Up @@ -69,6 +69,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
const oauthService = resolveOAuthServiceForIntegration(integration)
const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig()
const { chatEnabled } = useDeploymentShape()
const availability = integrationAvailability.get(integration.type.toLowerCase())
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
const [oauthOpen, setOAuthOpen] = useState(false)
Expand Down Expand Up @@ -197,7 +198,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
) : (
<Chip disabled>Unavailable</Chip>
)
) : isChatEnabled ? (
) : chatEnabled ? (
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
Add to Sim
</Chip>
Expand Down Expand Up @@ -279,7 +280,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration

{/* Every template hands its prompt to Chat, so the section has no
destination without it. */}
{isChatEnabled && matchingTemplates.length > 0 && (
{chatEnabled && matchingTemplates.length > 0 && (
<TemplatesSection
integration={integration}
templates={matchingTemplates}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Chip } from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
import { useParams, useRouter } from 'next/navigation'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'

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

return (
<div className='relative'>
<IntegrationsShowcase />
{isChatEnabled && (
{chatEnabled && (
<Chip
active
rightIcon={ArrowRight}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'

/**
* Client mirror of `hasWorkspaceLiveSyncAccess`.
*
* Reads the same two env flags in the same order as the server helper so the two
* cannot diverge: sub-hourly sync is ungated off the hosted deployment even when
* billing is enabled. Without the `isHosted` branch a self-hosted operator with
* billing on saw the "Live" interval locked while the API would have accepted it.
* Reads the same two deployment flags in the same order as the server helper so the
* two cannot diverge: sub-hourly sync is ungated off the hosted deployment even when
* billing is enabled. Without the `hosted` branch a self-hosted operator with billing
* on saw the "Live" interval locked while the API would have accepted it.
*/
export function hasWorkspaceMaxConnectorAccess(ownerBilling: WorkspaceOwnerBilling): boolean {
if (!isHosted || !isBillingEnabled) return true
const { hosted, billingEnabled } = getDeploymentShape()
if (!hosted || !billingEnabled) return true
return getSubscriptionAccessState(ownerBilling).hasUsableMaxAccess
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { createPortal } from 'react-dom'
import type { WorkflowLogRow } from '@/lib/api/contracts/logs'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
Expand Down Expand Up @@ -319,6 +319,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
...logDetailsTabUrlKeys,
})
const { copied: copiedRunId, copy: copyRunId } = useCopyToClipboard({ resetMs: 1500 })
const { chatEnabled } = useDeploymentShape()

const scrollAreaRef = useRef<HTMLDivElement>(null)

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

/**
* Hands the failed run to Chat. When a chat is already mounted (e.g. the run
Expand Down
Loading
Loading