From 44a16125c5732a3b9d01af8d10d374e8e66a5e7f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 3 Sep 2026 16:34:48 -0700 Subject: [PATCH 01/13] fix(config): resolve the deployment shape on the server and read it through one client reader (#7461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fix(config): re-evaluate option lists when the host shape lands after mount A host context served by an app version that predates the deployment field leaves the browser on the env fallback until a refetch carries the shape. Block option builders read the shape outside React, so the sub-block combobox now subscribes to it and keys its option memo on it; the reader hands out one stable fallback object per document so that dependency only changes when the shape does. The host-provider test now renders without query data first and then lets a refetch land, so the effect path that follows a later host context is exercised rather than the initial seed twice. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .claude/rules/global.md | 3 + CLAUDE.md | 1 + .../components/invite-modal/invite-modal.tsx | 5 +- .../share-modal/share-modal.test.tsx | 7 +- .../components/share-modal/share-modal.tsx | 5 +- .../components/credits-chip/credits-chip.tsx | 5 +- .../components/special-tags/special-tags.tsx | 15 +- .../[block]/integration-block-detail.tsx | 7 +- .../showcase-with-explore.tsx | 5 +- .../[id]/components/connector-entitlements.ts | 13 +- .../components/log-details/log-details.tsx | 5 +- .../workspace-host-provider.test.tsx | 143 +++++++++++++ .../providers/workspace-host-provider.tsx | 29 ++- .../settings/[section]/settings.tsx | 9 +- .../settings/components/byok/byok.test.tsx | 7 +- .../settings/components/byok/byok.tsx | 7 +- .../general/components/privacy-view.tsx | 5 +- .../settings/components/general/general.tsx | 5 +- .../recently-deleted/recently-deleted.tsx | 7 +- .../components/teammates/teammates.tsx | 5 +- .../[workspaceId]/settings/navigation.ts | 8 +- .../deploy-modal/components/chat/chat.tsx | 5 +- .../components/combobox/combobox.tsx | 10 +- .../panel/hooks/use-usage-limits.ts | 5 +- .../w/[workflowId]/components/panel/panel.tsx | 5 +- .../log-row-context-menu.tsx | 5 +- .../workflow-block/workflow-block.tsx | 5 +- .../search-modal/search-modal.test.tsx | 4 - .../components/search-modal/search-modal.tsx | 31 +-- .../settings-sidebar/settings-sidebar.tsx | 36 +++- .../sidebar-footer/sidebar-footer.test.tsx | 7 +- .../sidebar-footer/sidebar-footer.tsx | 5 +- .../workspace-header/workspace-header.tsx | 5 +- .../w/components/sidebar/sidebar.tsx | 29 ++- apps/sim/blocks/blocks/function.ts | 4 +- apps/sim/blocks/utils.ts | 20 +- .../components/settings/navigation.test.ts | 191 +++++++++++------- apps/sim/components/settings/navigation.ts | 123 ++++++----- .../settings/standalone-settings-shell.tsx | 11 +- .../components/access-control.test.tsx | 1 - .../components/access-control.tsx | 9 +- .../components/data-retention-settings.tsx | 5 +- .../components/usage-monitoring.tsx | 9 +- .../components/session-policy-settings.tsx | 5 +- apps/sim/ee/sso/components/sso-settings.tsx | 11 +- .../whitelabeling-settings.test.tsx | 11 +- .../components/whitelabeling-settings.tsx | 11 +- .../ee/workspace-forking/components/forks.tsx | 5 +- apps/sim/hooks/queries/copilot-keys.ts | 5 +- apps/sim/hooks/use-mothership-chat-events.ts | 7 +- apps/sim/lib/api/contracts/workspaces.ts | 42 ++++ .../lib/billing/workspace-permissions.test.ts | 62 +++++- apps/sim/lib/billing/workspace-permissions.ts | 9 +- .../core/config/deployment-shape.dom.test.tsx | 136 +++++++++++++ .../lib/core/config/deployment-shape.test.ts | 62 ++++++ apps/sim/lib/core/config/deployment-shape.ts | 158 +++++++++++++++ .../sim/lib/core/config/env-flags.dom.test.ts | 54 ----- apps/sim/lib/core/config/env-flags.test.ts | 5 +- apps/sim/lib/core/config/env-flags.ts | 14 +- apps/sim/lib/core/config/env.ts | 16 +- .../workspace-section-access.test.ts | 41 +++- .../application/workspace-section-access.ts | 14 +- .../sim/lib/workflows/subblocks/visibility.ts | 4 +- apps/sim/lib/workspaces/host-context.test.ts | 2 + apps/sim/lib/workspaces/host-context.ts | 2 + apps/sim/stores/terminal/console/store.ts | 4 +- 66 files changed, 1111 insertions(+), 390 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx create mode 100644 apps/sim/lib/core/config/deployment-shape.dom.test.tsx create mode 100644 apps/sim/lib/core/config/deployment-shape.test.ts create mode 100644 apps/sim/lib/core/config/deployment-shape.ts delete mode 100644 apps/sim/lib/core/config/env-flags.dom.test.ts diff --git a/.claude/rules/global.md b/.claude/rules/global.md index afd2290e37d..8ae6e0e874c 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index 773e3bccf51..cc25ad704c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index 9b21c25ed31..38e7ef2038a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -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' @@ -141,6 +141,7 @@ export function InviteModal({ } const { data: session } = useSession() + const { billingEnabled } = useDeploymentShape() const isOrganizationInvite = Boolean(organizationId) const sendInvitations = useSendWorkspaceInvitations() @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx index 8eea65cc85f..e188eae0f80 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.test.tsx @@ -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 @@ -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, })) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx index d2ba8aeda37..298f5906f5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx @@ -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' @@ -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) @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx index 0785ff9a53e..1accd4478d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index a543f294b12..6fd2a7ea41a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -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' @@ -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.' @@ -3032,13 +3033,13 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {canManageBilling ? ( {buttonLabel} - {isHosted ? : } + {hosted ? : } ) : (

{unavailableMessage}

diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 075c690f70f..d04cd899859 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -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, @@ -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) @@ -197,7 +198,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) : ( Unavailable ) - ) : isChatEnabled ? ( + ) : chatEnabled ? ( Add to Sim @@ -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 && ( - {isChatEnabled && ( + {chatEnabled && ( (null) @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx new file mode 100644 index 00000000000..b509c374f67 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.test.tsx @@ -0,0 +1,143 @@ +/** + * @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 { mockUseWorkspaceHostContextQuery } = vi.hoisted(() => ({ + mockUseWorkspaceHostContextQuery: vi.fn(), +})) + +vi.mock('@/hooks/queries/workspace-host', () => ({ + useWorkspaceHostContextQuery: mockUseWorkspaceHostContextQuery, +})) + +vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({ + WorkspaceAccessDenied: () => , +})) + +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, +} from '@/lib/core/config/deployment-shape' +import { + useWorkspaceHostContext, + WorkspaceHostProvider, +} from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const HOST_CONTEXT: WorkspaceHostContext = { + workspace: { + id: 'workspace-1', + name: 'Workspace', + workspaceMode: 'organization', + billedAccountUserId: 'owner-1', + }, + hostOrganizationId: 'org-1', + ownerBilling: { + plan: 'team', + status: 'active', + isPaid: true, + isPro: false, + isTeam: true, + isEnterprise: false, + isOrgScoped: true, + organizationId: 'org-1', + billingInterval: 'month', + billingBlocked: false, + billingBlockedReason: null, + }, + viewer: { + permission: 'admin', + isHostOrganizationMember: true, + isHostOrganizationAdmin: true, + }, + deployment: { + ...resolveDeploymentShape(), + hosted: true, + billingEnabled: true, + }, +} + +/** Reads the getter during render, the way block conditions do. */ +function GetterReader() { + return {String(getDeploymentShape().hosted)} +} + +function ContextReader() { + const { deployment } = useWorkspaceHostContext() + return {String(deployment?.billingEnabled)} +} + +let host: HTMLDivElement +let root: Root + +function renderProvider(initialContext: WorkspaceHostContext) { + act(() => + root.render( + + + + + ) + ) +} + +function textOf(testId: string): string | undefined { + return host.querySelector(`[data-testid="${testId}"]`)?.textContent ?? undefined +} + +beforeEach(() => { + resetDeploymentShape() + mockUseWorkspaceHostContextQuery.mockReturnValue({ data: undefined, error: null }) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +describe('WorkspaceHostProvider', () => { + it('seeds the server deployment shape before workspace children render', () => { + renderProvider(HOST_CONTEXT) + + expect(textOf('getter')).toBe('true') + expect(textOf('context')).toBe('true') + expect(getDeploymentShape()).toBe(HOST_CONTEXT.deployment) + }) + + it('follows a host context that arrives after mount over the initial seed', () => { + renderProvider(HOST_CONTEXT) + expect(textOf('context')).toBe('true') + expect(getDeploymentShape().billingEnabled).toBe(true) + + mockUseWorkspaceHostContextQuery.mockReturnValue({ + data: { + ...HOST_CONTEXT, + deployment: { ...HOST_CONTEXT.deployment!, billingEnabled: false }, + }, + error: null, + }) + renderProvider(HOST_CONTEXT) + + expect(textOf('context')).toBe('false') + expect(getDeploymentShape().billingEnabled).toBe(false) + }) + + it('keeps the env fallback for a host context that predates deployment projection', () => { + const { deployment: _legacy, ...legacyContext } = HOST_CONTEXT + + renderProvider(legacyContext) + + expect(textOf('getter')).toBe('false') + expect(textOf('context')).toBe('undefined') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx index 9aa14b79562..b4ff6a1c872 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/workspace-host-provider.tsx @@ -1,13 +1,28 @@ 'use client' -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, type ReactNode, useContext, useEffect, useState } from 'react' import { isApiClientError } from '@/lib/api/client/errors' -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { seedDeploymentShape } from '@/lib/core/config/deployment-shape' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' const WorkspaceHostContextValue = createContext(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 @@ -16,8 +31,10 @@ interface WorkspaceHostProviderProps { /** * Provides route-derived workspace host identity and entitlements to workspace - * UI. A later 403 (for example after access is revoked) replaces the workspace - * tree with an explicit denial instead of navigating to another workspace. + * UI, and seeds the server-resolved deployment shape for readers outside React + * before any workspace child renders. A later 403 (for example after access is + * revoked) replaces the workspace tree with an explicit denial instead of + * navigating to another workspace. */ export function WorkspaceHostProvider({ children, @@ -25,13 +42,15 @@ export function WorkspaceHostProvider({ initialContext, }: WorkspaceHostProviderProps) { const { data, error } = useWorkspaceHostContextQuery(workspaceId) + const context = data ?? initialContext + useSeedDeploymentShape(context.deployment) if (isApiClientError(error) && error.status === 403) { return } return ( - + {children} ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 8295fa0b230..7407f7af1fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -4,13 +4,13 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { captureEvent } from '@/lib/posthog/client' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { getSettingsSectionMeta, - isBillingEnabled, type SettingsSection, } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -131,13 +131,14 @@ interface SettingsPageProps { export function SettingsPage({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const hostContext = useWorkspaceHostContext() + const { billingEnabled } = useDeploymentShape() const posthog = usePostHog() const isAdminRole = session?.user?.role === 'admin' const normalizedSection: SettingsSection = (section as string) === 'subscription' ? 'billing' : section const effectiveSection = - !isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization') + !billingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization') ? 'general' : normalizedSection === 'admin' && !sessionLoading && !isAdminRole ? 'general' @@ -183,7 +184,7 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'apikeys' && } - {isBillingEnabled && effectiveSection === 'billing' && ( + {billingEnabled && effectiveSection === 'billing' && ( )} {effectiveSection === 'teammates' && } - {isBillingEnabled && effectiveSection === 'organization' && organizationId && ( + {billingEnabled && effectiveSection === 'organization' && organizationId && ( ({ canMutateWorkspaceSettingsSection: () => mocks.canManageWorkspace.current, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true })) +/** Hosted-only scope switching; read through the deployment shape at render time. */ +beforeAll(() => setEnvFlags({ isHosted: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => mocks.hostContext.current, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index b4bf221de6f..b7b3e19d43f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -46,7 +46,7 @@ import { } from '@/components/icons' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { type BYOKProviderId, MAX_BYOK_KEYS_PER_PROVIDER } from '@/lib/api/contracts/byok-keys' -import { isHosted } 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 { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -400,10 +400,11 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const hostContext = useWorkspaceHostContext() const workspacePermissions = useUserPermissionsContext() + const { hosted } = useDeploymentShape() const canManageWorkspace = canMutateWorkspaceSettingsSection('byok', workspacePermissions) const hostOrganizationId = hostContext.hostOrganizationId const canSelectOrganization = Boolean( - isHosted && hostOrganizationId && hostContext.viewer.isHostOrganizationAdmin + hosted && hostOrganizationId && hostContext.viewer.isHostOrganizationAdmin ) const [requestedScope, setRequestedScope] = useQueryState(byokScopeParam.key, { ...byokScopeParam.parser, @@ -415,7 +416,7 @@ export function BYOK() { const isOrganizationScope = effectiveScope === 'organization' const organizationQueryId = isOrganizationScope ? (hostOrganizationId ?? undefined) : undefined const inheritedStatusWorkspaceId = - !isOrganizationScope && isHosted && hostOrganizationId ? workspaceId : undefined + !isOrganizationScope && hosted && hostOrganizationId ? workspaceId : undefined const workspaceKeys = useBYOKKeys(workspaceId) const organizationKeys = useOrganizationBYOKKeys(organizationQueryId, { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx index c61567d7aad..765f10a50a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx @@ -3,7 +3,7 @@ import { ArrowLeft, Label, Switch } from '@sim/emcn' import { requestJson } from '@/lib/api/client/request' import { telemetryContract } from '@/lib/api/contracts/telemetry' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -25,6 +25,7 @@ interface PrivacyViewProps { export function PrivacyView({ onBack }: PrivacyViewProps) { const { data: settings } = useGeneralSettings() const updateSetting = useUpdateGeneralSetting() + const { hosted } = useDeploymentShape() const handleTelemetryToggle = async (checked: boolean) => { if (checked === settings?.telemetryEnabled || updateSetting.isPending) return @@ -65,7 +66,7 @@ export function PrivacyView({ onBack }: PrivacyViewProps) { - {isHosted && } + {hosted && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index d42d9798ec5..03d0d5de409 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -24,7 +24,7 @@ import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { signOut, useSession } from '@/lib/auth/auth-client' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' import { DeleteAccountModal } from '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal' @@ -80,6 +80,7 @@ export function General() { const router = useRouter() const brandConfig = useBrandConfig() const { data: session } = useSession() + const { hosted } = useDeploymentShape() const { data: profile, isLoading: isProfileLoading } = useUserProfile() const updateProfile = useUpdateUserProfile() @@ -276,7 +277,7 @@ export function General() { } const actions: SettingsAction[] = [ - ...(isHosted + ...(hosted ? [ { id: 'home-page', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index bc624c67293..cc8b0dbe8f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -9,7 +9,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' @@ -193,6 +193,7 @@ export function RecentlyDeleted() { const router = useRouter() const workspaceId = params?.workspaceId as string const workspacePermissions = useUserPermissionsContext() + const { chatEnabled } = useDeploymentShape() const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions) const [{ tab: activeTab }, setRecentlyDeletedFilters] = useQueryStates( recentlyDeletedParsers, @@ -254,7 +255,7 @@ export function RecentlyDeleted() { // query's loading/error state feeds the whole panel's. const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived', - enabled: queryPlan.chats && isChatEnabled, + enabled: queryPlan.chats && chatEnabled, }) const restoreWorkflow = useRestoreWorkflow() @@ -274,7 +275,7 @@ export function RecentlyDeleted() { queryPlan.tableFolders ? tableFoldersQuery : null, queryPlan.files ? filesQuery : null, queryPlan.workspaceFolders ? workspaceFoldersQuery : null, - queryPlan.chats && isChatEnabled ? chatsQuery : null, + queryPlan.chats && chatEnabled ? chatsQuery : null, ] const isLoading = activeQueryStates.some((query) => query?.isLoading) const error = activeQueryStates.find((query) => query?.error)?.error diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index b8ba236cf22..de8294ee6c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -15,7 +15,7 @@ import { import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { WorkspacePermission } from '@/lib/api/contracts/workspaces' import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { MemberRow, @@ -83,6 +83,7 @@ export function Teammates() { const workspaceId = (params?.workspaceId as string) || '' const [searchTerm, setSearchTerm] = useSettingsSearch() + const { billingEnabled } = useDeploymentShape() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const { data: permissions, isPending: permissionsLoading } = @@ -121,7 +122,7 @@ export function Teammates() { const handleInvite = () => { if (isInvitationsDisabled) { - if (isBillingEnabled) router.push(upgradeHref) + if (billingEnabled) router.push(upgradeHref) return } setIsInviteModalOpen(true) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 57de4bd4a04..8427439f40a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -1,6 +1,5 @@ import { - buildUnifiedSettingsNavigation, - SETTINGS_NAVIGATION_BILLING_ENABLED, + buildUnifiedSettingsCatalog, toSettingsHeaderMeta, type UnifiedNavigationSection, type UnifiedSettingsNavigationItem, @@ -14,8 +13,6 @@ export type NavigationSection = UnifiedNavigationSection export type NavigationItem = UnifiedSettingsNavigationItem -export const isBillingEnabled = SETTINGS_NAVIGATION_BILLING_ENABLED - export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, { key: 'workspace', title: 'Workspace' }, @@ -23,7 +20,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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 1fab391fee1..0ca5b49384f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -20,7 +20,7 @@ import { Check, TriangleAlert } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GeneratedPasswordInput } from '@/components/ui' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' import { validateAllowlistEntry } from '@/lib/messaging/email/validation' import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' @@ -696,6 +696,7 @@ function AuthSelector({ error, }: AuthSelectorProps) { const revealPasswordMutation = useRevealChatPassword() + const { features } = useDeploymentShape() /** * Editing or regenerating the password clears a failed reveal. The mutation @@ -711,7 +712,7 @@ function AuthSelector({ const allowedAuthTypes = permissionConfig.allowedChatDeployAuthTypes const ssoAvailable = - isSsoEnabled || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false) + features.sso || savedAuthType === 'sso' || (allowedAuthTypes?.includes('sso') ?? false) const baseAuthOptions: AuthType[] = ssoAvailable ? ['public', 'password', 'email', 'sso'] : ['public', 'password', 'email'] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 3d43ad468b6..7b40f491164 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Combobox, type ComboboxOption, cn } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { useReactFlow } from '@xyflow/react' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import type { SelectorKey } from '@/lib/selectors/manifest' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' @@ -127,6 +128,13 @@ export const ComboBox = memo(function ComboBox({ : undefined ) + /** + * Option builders such as the model list and the Function block's languages read the + * deployment shape outside React, so the list is keyed on the subscribed shape as well: + * a host context that lands after mount (an app version rolling out the field) must + * re-evaluate them rather than leave the env fallback's list in place. + */ + const deploymentShape = useDeploymentShape() const staticOptions = useMemo(() => { const opts = typeof options === 'function' @@ -138,7 +146,7 @@ export const ComboBox = memo(function ComboBox({ } return opts - }, [options, blockValues, subBlockId, isModelUsable]) + }, [options, blockValues, subBlockId, isModelUsable, deploymentShape]) const [selectorSearch, setSelectorSearch] = useState('') const debouncedSelectorSearch = useDebounce(selectorSearch.trim(), SEARCH_DEBOUNCE_MS) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts index 8b0ead03c46..2517562fea2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts @@ -1,4 +1,4 @@ -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' interface UseUsageLimitsOptions { @@ -9,7 +9,8 @@ interface UseUsageLimitsOptions { * Exposes the routed workspace's payer/member execution gate. */ export function useUsageLimits({ workspaceId }: UseUsageLimitsOptions) { - const { data, isLoading } = useWorkspaceUsageGate(isBillingEnabled ? workspaceId : undefined) + const { billingEnabled } = useDeploymentShape() + const { data, isLoading } = useWorkspaceUsageGate(billingEnabled ? workspaceId : undefined) return { usageExceeded: data?.isExceeded ?? false, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 261460b2a29..7b77fb5678b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -41,7 +41,7 @@ import { import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -138,6 +138,7 @@ export const Panel = memo(function Panel() { const routeWorkflowId = params.workflowId as string | undefined const posthog = usePostHog() + const { chatEnabled } = useDeploymentShape() const posthogRef = useRef(posthog) const panelRef = useRef(null) @@ -178,7 +179,7 @@ export const Panel = memo(function Panel() { * `hidden`, so a persisted `activeTab: 'copilot'` would hide all three and * paint an empty panel — resolve it to the toolbar instead. */ - const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot + const isCopilotTabAvailable = chatEnabled && !permissionConfig.hideCopilot const activeTab: PanelTab = storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx index f5b94af5817..e9b2b01e5ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu/log-row-context-menu.tsx @@ -2,7 +2,7 @@ import { memo, type RefObject } from 'react' import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import type { ContextMenuPosition, TerminalFilters, @@ -40,6 +40,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ onClearConsole, onFixInCopilot, }: LogRowContextMenuProps) { + const { chatEnabled } = useDeploymentShape() const hasRunId = entry?.executionId != null const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false @@ -74,7 +75,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({ )} {/* Fix in Chat - only for error rows */} - {isChatEnabled && entry && !entry.success && ( + {chatEnabled && entry && !entry.success && ( <> { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 3adcaed1d64..a9f1ca6513e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -33,7 +33,7 @@ import { isEqual } from 'es-toolkit' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/shared' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -640,6 +640,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const contentRef = useRef(null) const params = useParams() + const { chatEnabled } = useDeploymentShape() const workspaceId = params.workspaceId as string const { @@ -1287,7 +1288,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ }} sunsetStatus={sunset?.status} sunsetTooltip={sunset?.tooltip} - canFixSunset={canEditWorkflow && isChatEnabled} + canFixSunset={canEditWorkflow && chatEnabled} onFixSunset={onFixSunset} shouldShowScheduleBadge={shouldShowScheduleBadge} scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 4024fa40810..3227d420c59 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -34,10 +34,6 @@ vi.mock('posthog-js/react', () => ({ usePostHog: () => ({}), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isChatEnabled: true, -})) - vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 433fd0e97e1..6dfc70e6bbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -41,7 +41,7 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' -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 { getFolderPathNames } from '@/lib/folders/tree' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -154,6 +154,7 @@ function SearchModalContent({ }: SearchModalContentProps) { const params = useParams() const router = useRouter() + const { chatEnabled } = useDeploymentShape() const workspaceId = params.workspaceId as string const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) @@ -384,7 +385,7 @@ function SearchModalContent({ }, } ) - if (isChatEnabled) { + if (chatEnabled) { list.push({ id: 'new-chat', name: 'New chat', @@ -667,6 +668,7 @@ function SearchModalContent({ workspaceId, canEdit, canAdmin, + chatEnabled, pageContext, onCreateWorkflow, onCreateFolder, @@ -709,14 +711,19 @@ function SearchModalContent({ * way back the ask row unmounts while cmdk still remembers it as selected, * so Home re-anchors the selection once the result rows are back. */ - const handleSearchKeyDown = useCallback((event: ReactKeyboardEvent) => { - if (event.key !== 'Tab' || !isChatEnabled) return - event.preventDefault() - setAskMode((mode) => !mode) - requestAnimationFrame(() => { - inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) - }) - }, []) + const handleSearchKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key !== 'Tab' || !chatEnabled) return + event.preventDefault() + setAskMode((mode) => !mode) + requestAnimationFrame(() => { + inputRef.current?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }) + ) + }) + }, + [chatEnabled] + ) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1383,7 +1390,7 @@ function SearchModalContent({ {askMode ? '⇥ Search' : '⇥ Ask Sim'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 999e7544375..ddccd7a09a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -12,20 +12,22 @@ import { import { ChevronLeft } from '@sim/emcn/icons' import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' -import type { DesktopSettingsSurface } from '@/components/settings/navigation' -import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' +import { + type DesktopSettingsSurface, + isSelfHostedOverrideEnabled, + ORGANIZATION_PLANE_UNIFIED_SECTIONS, +} from '@/components/settings/navigation' 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 { useDeploymentShape } from '@/lib/core/config/deployment-shape' 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 { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' @@ -105,10 +107,12 @@ export function SettingsSidebar({ const { data: session } = useSession() const hostContext = useWorkspaceHostContext() + const deployment = useDeploymentShape() + const { hosted, billingEnabled } = deployment const { data: generalSettings } = useGeneralSettings() const { data: inboxConfig } = useInboxConfig(workspaceId) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({ - enabled: !isHosted, + enabled: !hosted, }) const { config: permissionConfig } = usePermissionConfig() @@ -127,18 +131,22 @@ export function SettingsSidebar({ const isSuperUser = session?.user?.role === 'admin' const isSSOProviderOwner = useMemo(() => { - if (isHosted) return null + if (hosted) return null if (!userId || isLoadingSSO) return null return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false - }, [userId, ssoProvidersData?.providers, isLoadingSSO]) + }, [hosted, userId, ssoProvidersData?.providers, isLoadingSSO]) const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.requiresSelfHosted && hosted) { + return false + } + if (item.requiresDesktopSurface && !desktopSurfaces[item.requiresDesktopSurface]) { return false } - if (item.hideWhenBillingDisabled && !isBillingEnabled) { + if (item.hideWhenBillingDisabled && !billingEnabled) { return false } @@ -181,7 +189,7 @@ export function SettingsSidebar({ return false } - if (item.selfHostedOverride && !isHosted) { + if (isSelfHostedOverrideEnabled(item.selfHostedOverride, deployment)) { /** * Org-plane sections route through the organization gate in * `settings/[section]/page.tsx` (host organization + org-admin viewer), @@ -216,7 +224,7 @@ export function SettingsSidebar({ return false } - if (item.requiresHosted && !isHosted) { + if (item.requiresHosted && !hosted) { return false } @@ -233,6 +241,9 @@ export function SettingsSidebar({ return true }) }, [ + deployment, + hosted, + billingEnabled, hasTeamPlan, hasEnterprisePlan, isEnterprisePlan, @@ -372,7 +383,10 @@ export function SettingsSidebar({ const active = activeSection === item.id const section = item.id as SettingsSection const href = getSettingsHref({ section }) - const selfHostedUnlocked = Boolean(item.selfHostedOverride && !isHosted) + const selfHostedUnlocked = isSelfHostedOverrideEnabled( + item.selfHostedOverride, + deployment + ) const isLocked = !selfHostedUnlocked && item.requiresMax && diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx index c72aeec6c1b..0684acb4c24 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act } 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' const desktopMocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -31,7 +32,9 @@ vi.mock('@/lib/auth/auth-client', () => ({ vi.mock('@/lib/billing/workspace-permissions', () => ({ canViewWorkspaceBillingSettings: () => true, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) +/** Billing routes the invitations-disabled row to Subscription; read at render time. */ +beforeAll(() => setEnvFlags({ isBillingEnabled: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/lib/workspaces/colors', () => ({ getUserColor: () => '#000000' })) vi.mock('@/hooks/use-workspace-invite-policy', () => ({ useWorkspaceInvitePolicy: () => ({ isInvitationsDisabled: false }), diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index bd9c180d979..612a927149b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -22,7 +22,7 @@ import { SlackIcon } from '@/components/icons' import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getDesktopUpdates } from '@/lib/desktop' import { getUserColor } from '@/lib/workspaces/colors' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' @@ -133,6 +133,7 @@ export function SidebarFooter({ const { data: profile } = useUserProfile() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() + const { billingEnabled } = useDeploymentShape() const { isInvitationsDisabled } = useWorkspaceInvitePolicy(workspaceId) const updateState = useDesktopUpdateState() @@ -169,7 +170,7 @@ export function SidebarFooter({ */ const resolveMenuDestination = (section: SettingsSection): SettingsSection | null => { if (section === 'teammates' && isInvitationsDisabled) { - return isBillingEnabled ? 'billing' : null + return billingEnabled ? 'billing' : null } return section } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index f188a591562..e69b89f8653 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -25,7 +25,7 @@ import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -160,6 +160,7 @@ function WorkspaceHeaderImpl({ onExpandSidebar, }: WorkspaceHeaderProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const { billingEnabled } = useDeploymentShape() const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const [isViewInvitationsOpen, setIsViewInvitationsOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) @@ -840,7 +841,7 @@ function WorkspaceHeaderImpl({ onClick={() => { setIsWorkspaceMenuOpen(false) if (isInvitationsDisabled) { - if (isBillingEnabled) navigateToSettings({ section: 'billing' }) + if (billingEnabled) navigateToSettings({ section: 'billing' }) return } setIsInviteModalOpen(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index d8574684642..530e5a3dc6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -38,7 +38,8 @@ import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { isChatEnabled, isHosted, isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' @@ -422,6 +423,7 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() const { workspace: routeWorkspace } = useWorkspaceHostContext() + const { hosted, chatEnabled } = useDeploymentShape() const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() const { config: permissionConfig, @@ -780,13 +782,13 @@ export const Sidebar = memo(function Sidebar({ [ { id: 'home', - label: isChatEnabled ? 'New chat' : 'New workflow', - icon: isChatEnabled ? Home : Plus, - href: isChatEnabled ? `/workspace/${workspaceId}/home` : undefined, - onClick: isChatEnabled ? undefined : createWorkflow, + label: chatEnabled ? 'New chat' : 'New workflow', + icon: chatEnabled ? Home : Plus, + href: chatEnabled ? `/workspace/${workspaceId}/home` : undefined, + onClick: chatEnabled ? undefined : createWorkflow, // Creation navigates optimistically, so a read-only member would land // on a workflow the server declined to create. - hidden: !isChatEnabled && !permissionsLoading && !canEdit, + hidden: !chatEnabled && !permissionsLoading && !canEdit, }, { id: 'integrations', @@ -802,7 +804,14 @@ export const Sidebar = memo(function Sidebar({ hidden: permissionConfig.hideIntegrationsTab, }, ].filter((item) => !item.hidden), - [workspaceId, createWorkflow, canEdit, permissionsLoading, permissionConfig.hideIntegrationsTab] + [ + workspaceId, + createWorkflow, + canEdit, + permissionsLoading, + permissionConfig.hideIntegrationsTab, + chatEnabled, + ] ) const workspaceNavItems = useMemo( @@ -866,7 +875,7 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedChats = EMPTY_CHATS, isLoading: chatsLoading } = useMothershipChats( workspaceId, - { enabled: isChatEnabled } + { enabled: chatEnabled } ) useMothershipChatEvents(workspaceId) @@ -1491,7 +1500,7 @@ export const Sidebar = memo(function Sidebar({ )} >
- {isChatEnabled && ( + {chatEnabled && (
- {(isHosted || isStatusNoticePreviewEnabled) && !isCollapsed ? ( + {(hosted || isStatusNoticePreviewEnabled) && !isCollapsed ? (
diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts index ebf685b687c..36c3e9319ad 100644 --- a/apps/sim/blocks/blocks/function.ts +++ b/apps/sim/blocks/blocks/function.ts @@ -1,5 +1,5 @@ import { CodeIcon } from '@/components/icons' -import { isSandboxesEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' import { SANDBOX_OUTPUT_DIR } from '@/lib/execution/remote-sandbox/sandbox-paths' import type { BlockConfig } from '@/blocks/types' @@ -37,7 +37,7 @@ export const FunctionBlock: BlockConfig = { options: () => [ { label: getLanguageDisplayName(CodeLanguage.JavaScript), id: CodeLanguage.JavaScript }, { label: getLanguageDisplayName(CodeLanguage.Python), id: CodeLanguage.Python }, - ...(isSandboxesEnabled + ...(getDeploymentShape().features.sandboxes ? [{ label: getLanguageDisplayName(CodeLanguage.Shell), id: CodeLanguage.Shell }] : []), ], diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 4351c4cc396..dca2389b503 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,11 +1,7 @@ import { toError } from '@sim/utils/errors' import { SimAutoIcon } from '@/components/icons' -import { - isAzureConfigured, - isCohereConfigured, - isHosted, - isOllamaConfigured, -} from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isOllamaConfigured } from '@/lib/core/config/env-flags' import { getScopesForService } from '@/lib/oauth/utils' import { containsReference } from '@/lib/workflows/sanitization/references' import type { SubBlockConfig } from '@/blocks/types' @@ -89,7 +85,7 @@ export function getModelOptions() { // Hosted-only automatic model. Deliberately LAST in the list (limited // visibility for the initial release): available to anyone who scrolls or // searches for it, but never the first thing the dropdown offers. - if (isHosted) { + if (getDeploymentShape().hosted) { options.push({ label: 'Auto', id: SIM_AUTO_MODEL_ID, icon: SimAutoIcon }) } @@ -145,12 +141,13 @@ function shouldRequireApiKeyForModel(model: string): boolean { const normalizedModel = model.trim().toLowerCase() if (!normalizedModel) return false + const { hosted, azureConfigured } = getDeploymentShape() // On hosted Sim the auto pseudo-model resolves server-side to a hosted pool // model. On self-hosted it exists only via imported workflows and always // falls back to the default Anthropic model, so the key field must show. - if (isAutoModel(normalizedModel)) return !isHosted + if (isAutoModel(normalizedModel)) return !hosted - if (isHosted) { + if (hosted) { const hostedModels = getHostedModels() if (hostedModels.some((m) => m.toLowerCase() === normalizedModel)) return false } @@ -159,7 +156,7 @@ function shouldRequireApiKeyForModel(model: string): boolean { return false } if ( - isAzureConfigured && + azureConfigured && (normalizedModel.startsWith('azure/') || normalizedModel.startsWith('azure-openai/') || normalizedModel.startsWith('azure-anthropic/') || @@ -263,7 +260,8 @@ export function getApiKeyCondition() { */ export function getCohereRerankerApiKeyCondition() { return () => { - if (isHosted || isCohereConfigured) { + const { hosted, cohereConfigured } = getDeploymentShape() + if (hosted || cohereConfigured) { return { field: 'operation', value: '__never_show__' } } return { diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index e39d4905aab..da487b47ccc 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -2,17 +2,18 @@ * @vitest-environment node */ import { createElement } from 'react' -import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' import { renderToStaticMarkup } from 'react-dom/server' -import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, - buildUnifiedSettingsNavigation, + buildUnifiedSettingsCatalog, canMutateWorkspaceSettingsSection, getAccountSettingsHref, + getOrganizationSettingsFeatures, getWorkspaceSettingsHref, isOrganizationSettingsSectionAvailable, + isSelfHostedOverrideEnabled, ORGANIZATION_PLANE_UNIFIED_SECTIONS, parseSettingsPathSection, resolveOrganizationSectionAccess, @@ -24,26 +25,65 @@ import { WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' - -beforeEach(() => { - setEnv({}) - resetEnvFlagsMock() -}) - -afterAll(() => { - resetEnvMock() - resetEnvFlagsMock() -}) +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' + +const SELF_HOSTED: DeploymentShape = { + hosted: false, + billingEnabled: false, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: true, + sandboxes: false, + sessionPolicies: true, + sso: false, + usageMonitoring: false, + whitelabeling: true, + }, +} + +const HOSTED: DeploymentShape = { ...SELF_HOSTED, hosted: true, billingEnabled: true } + +const ALL_ENTITLEMENTS = { + byok: true, + credentialGroups: true, + customBlocks: true, + forks: true, + inbox: true, + sandboxes: true, +} describe('settings navigation boundaries', () => { it('keeps Custom Blocks opt-in on self-hosted deployments', () => { + const customBlocks = buildUnifiedSettingsCatalog().find(({ id }) => id === 'custom-blocks') + + expect(customBlocks?.selfHostedOverride).toBe('customBlocks') + expect(isSelfHostedOverrideEnabled(customBlocks?.selfHostedOverride, SELF_HOSTED)).toBe(false) expect( - buildUnifiedSettingsNavigation().find(({ id }) => id === 'custom-blocks')?.selfHostedOverride - ).toBe(false) + isSelfHostedOverrideEnabled(customBlocks?.selfHostedOverride, { + ...SELF_HOSTED, + features: { ...SELF_HOSTED.features, customBlocks: true }, + }) + ).toBe(true) + }) + + it('resolves self-hosted overrides against the deployment shape, never on Sim Cloud', () => { + expect(isSelfHostedOverrideEnabled(undefined, SELF_HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('always', SELF_HOSTED)).toBe(true) + expect(isSelfHostedOverrideEnabled('always', HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('sessionPolicies', SELF_HOSTED)).toBe(true) + expect(isSelfHostedOverrideEnabled('sessionPolicies', HOSTED)).toBe(false) + expect(isSelfHostedOverrideEnabled('sso', SELF_HOSTED)).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', @@ -101,22 +141,14 @@ 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') + it('keeps the Sandboxes section in the catalog and the workspace navigation', () => { + expect(buildUnifiedSettingsCatalog().map(({ id }) => id)).toContain('sandboxes') expect( resolveWorkspaceNavigation({ permission: 'admin', permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }).map(({ id }) => id) ).toContain('sandboxes') }) @@ -124,47 +156,62 @@ 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. */ it('shows the Self-host section only on a self-hosted deployment', () => { - setEnvFlags({ isHosted: false }) - - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('self-host') - expect( + const navigate = (hosted: boolean) => resolveWorkspaceNavigation({ permission: 'admin', permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted, }).map(({ id }) => id) - ).toContain('self-host') + + expect(navigate(false)).toContain('self-host') + expect(navigate(true)).not.toContain('self-host') }) - it('drops the Self-host section on hosted Sim', () => { - setEnvFlags({ isHosted: true }) + /** + * The catalog keeps every section regardless of deployment so the route can + * tell an unavailable section from an unknown one and redirect to General + * instead of answering 404 — the sidebar applies deployment visibility itself. + */ + it('keeps deployment-gated sections in the catalog', () => { + const ids = buildUnifiedSettingsCatalog().map(({ id }) => id) + + expect(ids).toContain('self-host') + expect(ids).toContain('byok') + expect( + buildUnifiedSettingsCatalog().find(({ id }) => id === 'self-host')?.requiresSelfHosted + ).toBe(true) + }) - expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('self-host') + it('derives organization settings features from the deployment shape', () => { expect( - resolveWorkspaceNavigation({ - permission: 'admin', - permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - inbox: true, - customBlocks: true, - forks: true, - sandboxes: true, - }, - }).map(({ id }) => id) - ).not.toContain('self-host') + getOrganizationSettingsFeatures(true, { + ...SELF_HOSTED, + features: { ...SELF_HOSTED.features, sso: true, usageMonitoring: true }, + }) + ).toEqual({ + billingEnabled: false, + hasEnterprisePlan: true, + hosted: false, + selfHosted: { + 'access-control': false, + 'audit-logs': false, + sso: true, + sessions: true, + 'data-retention': false, + 'data-drains': false, + usage: true, + whitelabeling: true, + }, + }) + expect(getOrganizationSettingsFeatures(false, HOSTED)).toMatchObject({ + billingEnabled: true, + hosted: true, + }) }) /** @@ -173,7 +220,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') @@ -200,7 +247,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() ) @@ -265,7 +312,7 @@ describe('settings navigation boundaries', () => { }) it('labels the members section consistently', () => { - const unifiedOrganization = buildUnifiedSettingsNavigation().find( + const unifiedOrganization = buildUnifiedSettingsCatalog().find( ({ id }) => id === 'organization' ) @@ -452,14 +499,8 @@ describe('settings navigation boundaries', () => { const items = resolveWorkspaceNavigation({ permission, permissionConfig: {}, - entitlements: { - byok: true, - credentialGroups: true, - customBlocks: true, - forks: true, - inbox: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }) expect(items.map(({ id }) => id)).toEqual(visible) @@ -478,14 +519,8 @@ describe('settings navigation boundaries', () => { disableCustomTools: true, hideSandboxesTab: true, }, - entitlements: { - byok: true, - credentialGroups: true, - customBlocks: true, - forks: true, - inbox: true, - sandboxes: true, - }, + entitlements: ALL_ENTITLEMENTS, + hosted: false, }) expect(items.map(({ id }) => id)).toEqual([ diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 8bbc4d031a4..6349dbe2b41 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -29,21 +29,7 @@ import { import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' import { CodeIcon, McpIcon } from '@/components/icons' import type { SettingsHeaderMeta } from '@/components/settings/settings-header' -import { getEnv, isTruthy } from '@/lib/core/config/env' -import { - isAccessControlEnabled, - isAuditLogsEnabled, - isCustomBlocksEnabled, - isDataDrainsEnabled, - isDataRetentionEnabled, - isHosted, - isInboxEnabled, - isSandboxesEnabled, - isSessionPoliciesEnabled, - isSsoEnabled, - isUsageMonitoringEnabled, - isWhitelabelingEnabled, -} from '@/lib/core/config/env-flags' +import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces' export type SettingsPlane = 'account' | 'selfhost' | 'workspace' @@ -157,7 +143,8 @@ export interface UnifiedSettingsNavigationItem { * where the same surface is reached from the managed service instead. */ requiresSelfHosted?: boolean - selfHostedOverride?: boolean + /** See {@link SelfHostedOverride}; resolved against the deployment shape at filter time. */ + selfHostedOverride?: SelfHostedOverride requiresSuperUser?: boolean requiresAdminRole?: boolean requiresDesktopSurface?: DesktopSettingsSurface @@ -213,29 +200,26 @@ export interface SettingsSectionRegistryEntry { } /** - * Which enterprise sections a self-hosted deployment may show. - * - * These read the same resolved flags the server gates use, so a section is - * visible exactly when its API would accept the request. Reading the raw - * `NEXT_PUBLIC_*` vars here instead is what previously let nav and server - * disagree — a feature could be reachable but hidden, or listed but rejected. - * + * How a section unlocks on a self-hosted deployment: `'always'` unconditionally, or + * when the named enterprise feature resolves on for the deployment. Named rather than + * read here so the catalog stays a constant and the sidebar and the server gate resolve + * the same server-provided shape — see {@link isSelfHostedOverrideEnabled}. That is what + * keeps nav and server agreeing: a section is visible exactly when its API would accept + * the request. */ -const SETTINGS_SELF_HOSTED_OVERRIDES = { - accessControl: isAccessControlEnabled, - auditLogs: isAuditLogsEnabled, - customBlocks: isCustomBlocksEnabled, - dataDrains: isDataDrainsEnabled, - dataRetention: isDataRetentionEnabled, - inbox: isInboxEnabled, - sandboxes: isSandboxesEnabled, - sessionPolicies: isSessionPoliciesEnabled, - sso: isSsoEnabled, - usageMonitoring: isUsageMonitoringEnabled, - whitelabeling: isWhitelabelingEnabled, -} as const +export type SelfHostedOverride = 'always' | keyof DeploymentFeatures -export const SETTINGS_NAVIGATION_BILLING_ENABLED = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) +/** + * Whether a section's self-hosted override unlocks it on this deployment. Always false + * on Sim Cloud, where subscription plans decide entitlement instead. + */ +export function isSelfHostedOverrideEnabled( + override: SelfHostedOverride | undefined, + deployment: DeploymentShape +): boolean { + if (override === undefined || deployment.hosted) return false + return override === 'always' || deployment.features[override] +} type SettingsHrefSearchParams = Pick @@ -403,7 +387,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 4, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, + selfHostedOverride: 'accessControl', organizationSection: 'access-control', }, }, @@ -418,7 +402,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 5, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, + selfHostedOverride: 'auditLogs', organizationSection: 'audit-logs', }, }, @@ -516,7 +500,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] */ requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, + selfHostedOverride: 'usageMonitoring', organizationSection: 'usage', }, }, @@ -543,7 +527,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 9, requiresEnterprise: true, allowNonOrgAdmin: true, - selfHostedOverride: true, + selfHostedOverride: 'always', }, planes: { workspace: { id: 'credential-groups', group: 'workspace', order: 4 }, @@ -636,7 +620,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'workspace', order: 8, requiresMax: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, + selfHostedOverride: 'sandboxes', showWhenLocked: true, }, planes: { @@ -665,7 +649,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 5, requiresMax: true, requiresHosted: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.inbox, + selfHostedOverride: 'inbox', showWhenLocked: true, }, planes: { @@ -710,7 +694,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 7, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + selfHostedOverride: 'sso', organizationSection: 'sso', }, }, @@ -725,7 +709,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 8, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, + selfHostedOverride: 'sessionPolicies', organizationSection: 'sessions', }, }, @@ -741,7 +725,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 9, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, + selfHostedOverride: 'dataRetention', organizationSection: 'data-retention', }, }, @@ -756,7 +740,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 10, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, + selfHostedOverride: 'dataDrains', organizationSection: 'data-drains', }, }, @@ -771,7 +755,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 6, requiresHosted: true, requiresEnterprise: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, + selfHostedOverride: 'whitelabeling', organizationSection: 'whitelabeling', }, }, @@ -787,7 +771,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] requiresHosted: true, requiresEnterprise: true, allowNonOrgAdmin: true, - selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.customBlocks, + selfHostedOverride: 'customBlocks', }, planes: { workspace: { id: 'custom-blocks', group: 'enterprise', order: 11 }, @@ -823,12 +807,16 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, ] -export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { +/** + * Every unified section this build can render, including ones the current deployment + * does not offer. Deployment filtering (`requiresHosted`, `requiresSelfHosted`, billing) + * belongs to the sidebar and the section gate, which read the server-resolved shape. + * Keeping an unavailable section in the catalog is what lets the route treat it as a + * known segment and redirect to General rather than answer 404. + */ +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 [ { @@ -944,21 +932,23 @@ export interface OrganizationSettingsFeatures { } export function getOrganizationSettingsFeatures( - hasEnterprisePlan: boolean + hasEnterprisePlan: boolean, + deployment: DeploymentShape ): OrganizationSettingsFeatures { + const { features } = deployment return { - billingEnabled: SETTINGS_NAVIGATION_BILLING_ENABLED, + billingEnabled: deployment.billingEnabled, hasEnterprisePlan, - hosted: isHosted, + hosted: deployment.hosted, selfHosted: { - 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, - 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, - sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, - sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, - 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, - 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, - usage: SETTINGS_SELF_HOSTED_OVERRIDES.usageMonitoring, - whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, + 'access-control': features.accessControl, + 'audit-logs': features.auditLogs, + sso: features.sso, + sessions: features.sessionPolicies, + 'data-retention': features.dataRetention, + 'data-drains': features.dataDrains, + usage: features.usageMonitoring, + whitelabeling: features.whitelabeling, }, } } @@ -1027,6 +1017,8 @@ interface ResolveWorkspaceNavigationOptions { permission: PermissionType permissionConfig: WorkspacePermissionConfig entitlements: WorkspaceSettingsEntitlements + /** Sim Cloud drops the Self hosting section, which the managed service owns there. */ + hosted: boolean } export interface ResolvedWorkspaceNavigationItem @@ -1070,6 +1062,7 @@ export function resolveWorkspaceNavigation({ permission, permissionConfig, entitlements, + hosted, }: ResolveWorkspaceNavigationOptions): ResolvedWorkspaceNavigationItem[] { return WORKSPACE_SETTINGS_ITEMS.flatMap((item) => { const permissionConfigKey = WORKSPACE_PERMISSION_CONFIG_KEYS[item.id] @@ -1084,7 +1077,7 @@ export function resolveWorkspaceNavigation({ if (item.id === 'byok' && !entitlements.byok) return [] if (item.id === 'custom-blocks' && !entitlements.customBlocks) return [] // Absent on Sim Cloud, where the managed service owns these settings. - if (item.id === 'self-host' && isHosted) return [] + if (item.id === 'self-host' && hosted) return [] const lockedBy = LOCKABLE_WORKSPACE_SECTIONS[item.id] const locked = lockedBy !== undefined && !entitlements[lockedBy] diff --git a/apps/sim/components/settings/standalone-settings-shell.tsx b/apps/sim/components/settings/standalone-settings-shell.tsx index 82a9302611c..5a2f5ccbf68 100644 --- a/apps/sim/components/settings/standalone-settings-shell.tsx +++ b/apps/sim/components/settings/standalone-settings-shell.tsx @@ -17,7 +17,7 @@ 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 { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { SIDEBAR_WIDTH } from '@/stores/constants' interface StandaloneSettingsShellBaseProps { @@ -39,19 +39,20 @@ export function StandaloneSettingsShell(props: StandaloneSettingsShellProps) { const { children, plane } = props useSettingsBeforeUnload() const pathname = usePathname() + const { hosted, billingEnabled } = useDeploymentShape() const isSuperUser = plane === 'account' ? (props.isSuperUser ?? false) : false const accountItems = ACCOUNT_SETTINGS_ITEMS.filter((item) => { - if (item.id === 'billing' && !isBillingEnabled) return false + if (item.id === 'billing' && !billingEnabled) return false if ((item.id === 'admin' || item.id === 'mothership') && !isSuperUser) return false return true }) const selfHostItems = SELFHOST_SETTINGS_ITEMS.filter((item) => { - if (item.id === 'billing' && !isBillingEnabled) return false + if (item.id === 'billing' && !billingEnabled) return false // Chat keys are issued by the managed service, so there are none to list on - // a self-hosted deployment — useCopilotKeys is `enabled: isHosted` for the + // a self-hosted deployment — useCopilotKeys is `enabled: hosted` for the // same reason. Self-hosters manage their keys on sim.ai. - if (item.id === 'chat-keys' && !isHosted) return false + if (item.id === 'chat-keys' && !hosted) return false return true }) const selfHostSection = parseSettingsPathSection({ diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx index 4057b4db8d5..21d9a202e93 100644 --- a/apps/sim/ee/access-control/components/access-control.test.tsx +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -26,7 +26,6 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) -vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, })) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index adf546a7847..f7a341b2852 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -18,7 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' -import { isAccessControlEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { groupIdParam, groupIdUrlKeys, @@ -56,6 +56,7 @@ interface AccessControlProps { export function AccessControl({ isOrganizationAdmin, organizationId }: AccessControlProps) { const params = useParams() + const { features } = useDeploymentShape() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined /** @@ -74,7 +75,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon isPending: organizationBillingLoading, error: organizationBillingError, } = useOrganizationBilling(organizationId, { - enabled: !isAccessControlEnabled && !userPermissionConfig?.entitled, + enabled: !features.accessControl && !userPermissionConfig?.entitled, }) const currentUserIsOrgAdmin = isOrganizationAdmin @@ -92,12 +93,12 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon * set show the section and then refuse to manage it. */ const isEntitled = - isAccessControlEnabled || + features.accessControl || !!userPermissionConfig?.entitled || isEnterprise(organizationBillingData?.data?.subscriptionPlan) const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId const organizationEntitlementLoading = - !isAccessControlEnabled && !userPermissionConfig?.entitled && organizationBillingLoading + !features.accessControl && !userPermissionConfig?.entitled && organizationBillingLoading const isLoading = (workspaceId ? entitlementLoading : false) || diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index f342d9748f6..985015f27e6 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -24,7 +24,7 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization' import type { RetentionOverride } from '@/lib/api/contracts/primitives' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { type CustomPiiPattern, emptyPiiStages, @@ -987,6 +987,7 @@ function DataRetentionForm({ initialData: data, orgId, workspaces }: DataRetenti export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSettingsProps) { const { data, isLoading } = useOrganizationRetention(orgId) const { data: workspaces = [] } = useWorkspacesQuery(Boolean(orgId)) + const { billingEnabled } = useDeploymentShape() if (isLoading) { return ( @@ -1009,7 +1010,7 @@ export function DataRetentionSettings({ organizationId: orgId }: DataRetentionSe return Failed to load data retention settings. } - if (isBillingEnabled && !data.isEnterprise) { + if (billingEnabled && !data.isEnterprise) { return ( Data retention is available on Enterprise plans only. ) diff --git a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx index ecdb4acbcd7..648fae039b4 100644 --- a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx +++ b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx @@ -18,7 +18,7 @@ import { type UsageBreakdownDimension, } from '@/lib/api/contracts/organization-usage' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { isAuditLogsEnabled, isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { ManageCreditsModal, type ManageCreditsTarget, @@ -100,6 +100,7 @@ export function UsageMonitoring({ auditLogsHref: auditLogsBaseHref, }: UsageMonitoringProps) { const router = useRouter() + const { hosted, features } = useDeploymentShape() const { window, tab, workspace, expanded, preset, startDate, endDate, periodLabel, setState } = useUsageWindow() const [datePickerOpen, setDatePickerOpen] = useState(false) @@ -125,7 +126,7 @@ export function UsageMonitoring({ * `requiresHosted` with no self-hosted override — so without this the menu would * offer an action that could only fail. */ - const canManageCredits = tab === 'member' && isHosted + const canManageCredits = tab === 'member' && hosted const summary = useOrganizationUsageSummary(organizationId, window) /** @@ -210,9 +211,7 @@ export function UsageMonitoring({ * periods, so there is no honest mapping for `current-period`. */ const auditLogsHref = - isHosted || isAuditLogsEnabled - ? serializeAuditLogFilters(auditLogsBaseHref, { workspace }) - : null + hosted || features.auditLogs ? serializeAuditLogFilters(auditLogsBaseHref, { workspace }) : null /** * The drill-down is the same window, in more detail. Without the params it read its diff --git a/apps/sim/ee/session-policy/components/session-policy-settings.tsx b/apps/sim/ee/session-policy/components/session-policy-settings.tsx index 8301c938054..dcd9ac0ca53 100644 --- a/apps/sim/ee/session-policy/components/session-policy-settings.tsx +++ b/apps/sim/ee/session-policy/components/session-policy-settings.tsx @@ -9,7 +9,7 @@ import { MIN_IDLE_TIMEOUT_HOURS, MIN_SESSION_LIFETIME_HOURS, } from '@/lib/api/contracts/organization' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -63,6 +63,7 @@ interface SessionPolicyFormProps extends SessionPolicySettingsProps { function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormProps) { const updatePolicy = useUpdateOrganizationSessionPolicy() + const { billingEnabled } = useDeploymentShape() const revokeSessions = useRevokeOrganizationSessions() const initialMaxSessionHours = initialData.configured.maxSessionHours?.toString() ?? '' @@ -151,7 +152,7 @@ function SessionPolicyForm({ organizationId, initialData }: SessionPolicyFormPro }), ] - if (isBillingEnabled && !initialData.isEnterprise) { + if (billingEnabled && !initialData.isEnterprise) { return ( diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index cc171a75f90..630d3edc260 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -24,7 +24,7 @@ import type { SettingsAction } from '@/components/settings/settings-header' import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' 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 { REDACTED_MARKER } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -233,6 +233,7 @@ export function SSO({ organizationId }: SSOProps) { function OrganizationSsoSettings({ organizationId }: SSOProps) { const { data: session } = useSession() + const { billingEnabled } = useDeploymentShape() const { data: organizationBillingData, isLoading: isLoadingOrganizationBilling, @@ -257,7 +258,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { const hasEnterprisePlan = isEnterprise(organizationBillingData?.data?.subscriptionPlan) const isSSOProviderOwner = - !isBillingEnabled && userId ? providers.some((p) => p.userId === userId) : null + !billingEnabled && userId ? providers.some((p) => p.userId === userId) : null const configureSSOMutation = useConfigureSSO() @@ -289,13 +290,13 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { useSettingsUnsavedGuard({ isDirty: hasChanges }) - if (isLoadingProviders || (isBillingEnabled && isLoadingOrganizationBilling)) { + if (isLoadingProviders || (billingEnabled && isLoadingOrganizationBilling)) { return null } const providersLoadingError = providersData === undefined ? providersError : null const organizationBillingLoadingError = - isBillingEnabled && organizationBillingData === undefined ? organizationBillingError : null + billingEnabled && organizationBillingData === undefined ? organizationBillingError : null const loadingError = providersLoadingError ?? organizationBillingLoadingError if (loadingError) { return ( @@ -314,7 +315,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { ) } - if (isBillingEnabled) { + if (billingEnabled) { if (!hasEnterprisePlan) { return ( diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx index 7e8f9280f5d..cf1ec994bb1 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx @@ -2,18 +2,17 @@ * @vitest-environment jsdom */ import { act, 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' const { mockUseOrganizationBilling } = vi.hoisted(() => ({ mockUseOrganizationBilling: vi.fn(), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, - isOrganizationsEnabled: false, - isSsoEnabled: false, -})) +/** Billing on, so plan entitlement gates the page; read through the deployment shape. */ +beforeAll(() => setEnvFlags({ isBillingEnabled: true })) +afterAll(resetEnvFlagsMock) vi.mock('@/components/settings/save-discard-actions', () => ({ saveDiscardActions: () => [], })) diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index d81a178fff5..1358af3a0a8 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -10,7 +10,7 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import { isEnterprise } from '@/lib/billing/plan-helpers' import { HEX_COLOR_REGEX } from '@/lib/branding' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL, @@ -457,16 +457,17 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel } export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSettingsProps) { + const { billingEnabled } = useDeploymentShape() const { data: organizationBillingData, isPending: organizationBillingLoading, error: organizationBillingError, - } = useOrganizationBilling(orgId, { enabled: isBillingEnabled }) + } = useOrganizationBilling(orgId, { enabled: billingEnabled }) const { data: workspaces } = useWorkspacesQuery(true) const uploadWorkspaceId = workspaces?.find((workspace) => workspace.organizationId === orgId)?.id const { data: savedSettings, error: settingsError, isLoading } = useWhitelabelSettings(orgId) - if (isLoading || (isBillingEnabled && organizationBillingLoading)) { + if (isLoading || (billingEnabled && organizationBillingLoading)) { return ( {getErrorMessage(organizationBillingError, 'Failed to load organization billing')} @@ -496,7 +497,7 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe ) } - if (isBillingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { + if (billingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { return ( Whitelabeling is available on Enterprise plans only. ) diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx index a34970473cc..8d88b195827 100644 --- a/apps/sim/ee/workspace-forking/components/forks.tsx +++ b/apps/sim/ee/workspace-forking/components/forks.tsx @@ -9,7 +9,7 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { SettingsAction } from '@/components/settings/settings-header' import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -311,6 +311,7 @@ export function Forks() { const workspaceId = params.workspaceId as string const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext() + const { billingEnabled } = useDeploymentShape() const { available: forkingAvailable, isLoading: availabilityLoading } = useForkingAvailability(workspaceId) const canUseForking = forkingAvailable && canAdmin @@ -560,7 +561,7 @@ export function Forks() { sourceWorkspaceName={workspaceName || 'Workspace'} canFork={canFork} onUpgrade={() => { - if (isBillingEnabled) navigateToSettings({ section: 'billing' }) + if (billingEnabled) navigateToSettings({ section: 'billing' }) }} /> diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index a6bc9273520..04b41893991 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -8,7 +8,7 @@ import { generateCopilotApiKeyContract, listCopilotApiKeysContract, } from '@/lib/api/contracts' -import { isHosted } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' const logger = createLogger('CopilotKeysQuery') @@ -39,10 +39,11 @@ async function fetchCopilotKeys(signal?: AbortSignal): Promise { * Hook to fetch Copilot API keys */ export function useCopilotKeys() { + const { hosted } = useDeploymentShape() return useQuery({ queryKey: copilotKeysKeys.keys(), queryFn: ({ signal }) => fetchCopilotKeys(signal), - enabled: isHosted, + enabled: hosted, staleTime: COPILOT_KEY_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index 02079c3a3a9..2d1a9d91717 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' import { createRotatingEventSource } from '@/lib/events/rotating-event-source' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' @@ -164,9 +164,10 @@ export function resyncMothershipChatCaches( */ export function useMothershipChatEvents(workspaceId: string | undefined) { const queryClient = useQueryClient() + const { chatEnabled } = useDeploymentShape() useEffect(() => { - if (!workspaceId || !isChatEnabled) return + if (!workspaceId || !chatEnabled) return const isResubscribe = everSubscribed.has(workspaceId) everSubscribed.add(workspaceId) @@ -194,5 +195,5 @@ export function useMothershipChatEvents(workspaceId: string | undefined) { return () => { connection.close() } - }, [workspaceId, queryClient]) + }, [workspaceId, queryClient, chatEnabled]) } diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 883c9375c0c..318562ae4bf 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -241,6 +241,46 @@ export const workspaceOwnerBillingSchema = z.object({ export type WorkspaceOwnerBilling = z.output +/** + * Enterprise features as this deployment's configuration resolves them (see + * `enterpriseFeatureEnabled` in `@/lib/core/config/env-flags`). The browser consults + * these only off-hosted, where no subscription plan exists to decide entitlement. + */ +export const deploymentFeaturesSchema = z.object({ + accessControl: z.boolean(), + auditLogs: z.boolean(), + customBlocks: z.boolean(), + dataDrains: z.boolean(), + dataRetention: z.boolean(), + inbox: z.boolean(), + sandboxes: z.boolean(), + sessionPolicies: z.boolean(), + sso: z.boolean(), + usageMonitoring: z.boolean(), + whitelabeling: z.boolean(), +}) + +export type DeploymentFeatures = z.output + +/** + * The deployment's shape, resolved on the server per request. Browser code reads it + * from the workspace host context rather than from the `NEXT_PUBLIC_*` module + * constants: those freeze at module init, and a document that never ran the root + * layout — Next's bare `__next_error__` 404 shell, or `global-error` — leaves every + * one of them unset for the life of the tab, including after the app recovers in + * place. See `@/lib/core/config/deployment-shape`. + */ +export const deploymentShapeSchema = z.object({ + hosted: z.boolean(), + billingEnabled: z.boolean(), + chatEnabled: z.boolean(), + azureConfigured: z.boolean(), + cohereConfigured: z.boolean(), + features: deploymentFeaturesSchema, +}) + +export type DeploymentShape = z.output + export const workspaceHostContextSchema = z.object({ workspace: z.object({ id: nonEmptyIdSchema, @@ -266,6 +306,8 @@ export const workspaceHostContextSchema = z.object({ knowledgeMemberAccess: z.boolean().optional(), }) .optional(), + /** Optional for rolling compatibility with app versions that predate deployment projection. */ + deployment: deploymentShapeSchema.optional(), }) export type WorkspaceHostContext = z.output diff --git a/apps/sim/lib/billing/workspace-permissions.test.ts b/apps/sim/lib/billing/workspace-permissions.test.ts index 8c841347827..585ac6c4cec 100644 --- a/apps/sim/lib/billing/workspace-permissions.test.ts +++ b/apps/sim/lib/billing/workspace-permissions.test.ts @@ -1,10 +1,12 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import type { DeploymentShape, WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { canManageWorkspaceBilling, + canViewWorkspaceBillingSettings, getWorkspaceUsageLimitAction, } from '@/lib/billing/workspace-permissions' @@ -36,6 +38,62 @@ const HOST_CONTEXT: WorkspaceHostContext = { }, } +const DEPLOYMENT: DeploymentShape = { + hosted: true, + billingEnabled: true, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: false, + sandboxes: false, + sessionPolicies: false, + sso: false, + usageMonitoring: false, + whitelabeling: false, + }, +} + +const HOST_ADMIN_CONTEXT: WorkspaceHostContext = { + ...HOST_CONTEXT, + viewer: { ...HOST_CONTEXT.viewer, isHostOrganizationAdmin: true }, +} + +describe('canViewWorkspaceBillingSettings', () => { + afterEach(resetEnvFlagsMock) + + it('reads billing availability from the host context deployment shape', () => { + expect( + canViewWorkspaceBillingSettings({ ...HOST_ADMIN_CONTEXT, deployment: DEPLOYMENT }, 'admin-b') + ).toBe(true) + expect( + canViewWorkspaceBillingSettings( + { ...HOST_ADMIN_CONTEXT, deployment: { ...DEPLOYMENT, billingEnabled: false } }, + 'admin-b' + ) + ).toBe(false) + }) + + it('falls back to the deployment reader for a host context that predates the field', () => { + expect(canViewWorkspaceBillingSettings(HOST_ADMIN_CONTEXT, 'admin-b')).toBe(false) + + setEnvFlags({ isBillingEnabled: true }) + + expect(canViewWorkspaceBillingSettings(HOST_ADMIN_CONTEXT, 'admin-b')).toBe(true) + }) + + it('still requires authority over the payer', () => { + expect( + canViewWorkspaceBillingSettings({ ...HOST_CONTEXT, deployment: DEPLOYMENT }, 'viewer') + ).toBe(false) + }) +}) + describe('canManageWorkspaceBilling', () => { it('does not treat an external workspace admin as a host billing admin', () => { expect(canManageWorkspaceBilling(HOST_CONTEXT, 'external-a')).toBe(false) diff --git a/apps/sim/lib/billing/workspace-permissions.ts b/apps/sim/lib/billing/workspace-permissions.ts index 41c57c574eb..0c3a7ead280 100644 --- a/apps/sim/lib/billing/workspace-permissions.ts +++ b/apps/sim/lib/billing/workspace-permissions.ts @@ -1,5 +1,5 @@ import type { WorkspaceHostContext, WorkspaceUsageGate } from '@/lib/api/contracts/workspaces' -import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' export type WorkspaceUsageLimitAction = | { type: 'manage-billing'; message: null } @@ -27,12 +27,17 @@ export function canManageWorkspaceBilling( * organization-hosted workspace that is every member who is not an org admin. * Menus that link to Billing drop the entry when this is false rather than * offering a destination the server will refuse. + * + * Billing availability comes from the host context's server-resolved deployment + * shape; the reader covers a context served by an app version that predates it. */ export function canViewWorkspaceBillingSettings( hostContext: WorkspaceHostContext, viewerUserId?: string | null ): boolean { - return isBillingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) + const billingEnabled = + hostContext.deployment?.billingEnabled ?? getDeploymentShape().billingEnabled + return billingEnabled && canManageWorkspaceBilling(hostContext, viewerUserId) } /** diff --git a/apps/sim/lib/core/config/deployment-shape.dom.test.tsx b/apps/sim/lib/core/config/deployment-shape.dom.test.tsx new file mode 100644 index 00000000000..3f59605c34f --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.dom.test.tsx @@ -0,0 +1,136 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options {"url":"https://www.sim.ai"} + * + * A document that never ran the root layout: no `window.__ENV`, no + * `data-public-env` attribute, so every `NEXT_PUBLIC_*` read is unset and the env + * fallback resolves to self-hosted. That is the state a tab keeps after recovering + * in place from Next's bare 404 shell or `global-error`. + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', '') + vi.stubEnv('NEXT_PUBLIC_BILLING_ENABLED', '') + vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', '') + document.documentElement.id = '__next_error__' +}) + +vi.unmock('@/lib/core/config/env') +vi.unmock('@/lib/core/config/env-flags') +vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: () => [] })) +vi.mock('@/providers/utils', () => ({ getProviderFromModel: () => 'openai' })) + +import type { DeploymentShape } from '@/lib/api/contracts/workspaces' +import { + getDeploymentShape, + resetDeploymentShape, + resolveDeploymentShape, + seedDeploymentShape, + useDeploymentShape, +} from '@/lib/core/config/deployment-shape' +import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env' +import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' +import { getApiKeyCondition } from '@/blocks/utils' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const HOSTED_MODELS = ['gpt-5.6-sol', 'claude-sonnet-5'] + +const HOSTED: DeploymentShape = { + ...resolveDeploymentShape(), + hosted: true, + billingEnabled: true, +} + +function apiKeyFieldShown(model: string): boolean { + return evaluateSubBlockCondition(getApiKeyCondition(), { model }) +} + +function HookReader() { + const { hosted, billingEnabled } = useDeploymentShape() + return {`${hosted}/${billingEnabled}`} +} + +let host: HTMLDivElement +let root: Root + +function render(ui: ReactNode) { + act(() => root.render(ui)) +} + +function textOf(testId: string): string | undefined { + return host.querySelector(`[data-testid="${testId}"]`)?.textContent ?? undefined +} + +beforeEach(() => { + resetDeploymentShape() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE) + Reflect.deleteProperty(window, '__ENV') +}) + +describe('env fallback on a document without the root layout', () => { + it('resolves as self-hosted and shows API key fields for hosted models', () => { + expect(window.__ENV).toBeUndefined() + expect(document.documentElement.getAttribute(PUBLIC_ENV_ATTRIBUTE)).toBeNull() + expect(resolveDeploymentShape().hosted).toBe(false) + expect(getDeploymentShape().hosted).toBe(false) + + for (const model of HOSTED_MODELS) { + expect(apiKeyFieldShown(model)).toBe(true) + } + }) +}) + +describe('seeded server shape', () => { + it('wins over the env fallback for readers outside React', () => { + seedDeploymentShape(HOSTED) + + expect(getDeploymentShape()).toBe(HOSTED) + for (const model of HOSTED_MODELS) { + expect(apiKeyFieldShown(model)).toBe(false) + } + expect(apiKeyFieldShown('custom/model')).toBe(true) + }) + + it('keeps the seeded object when an equal shape is seeded again', () => { + seedDeploymentShape(HOSTED) + seedDeploymentShape({ ...HOSTED, features: { ...HOSTED.features } }) + + expect(getDeploymentShape()).toBe(HOSTED) + }) + + it('is ignored when the server predates deployment projection', () => { + seedDeploymentShape(undefined) + + expect(getDeploymentShape().hosted).toBe(false) + }) + + it('returns to the env fallback after a reset', () => { + seedDeploymentShape(HOSTED) + resetDeploymentShape() + + expect(getDeploymentShape().hosted).toBe(false) + }) +}) + +describe('useDeploymentShape', () => { + it('follows the seeded shape and falls back to the env fallback otherwise', () => { + render() + expect(textOf('hook')).toBe('false/false') + + act(() => seedDeploymentShape(HOSTED)) + + expect(textOf('hook')).toBe('true/true') + }) +}) diff --git a/apps/sim/lib/core/config/deployment-shape.test.ts b/apps/sim/lib/core/config/deployment-shape.test.ts new file mode 100644 index 00000000000..a01adc99bd8 --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.test.ts @@ -0,0 +1,62 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { + getDeploymentShape, + resolveDeploymentShape, + seedDeploymentShape, +} from '@/lib/core/config/deployment-shape' + +afterEach(resetEnvFlagsMock) + +describe('resolveDeploymentShape', () => { + it('packages the resolved env flags', () => { + setEnvFlags({ + isHosted: true, + isBillingEnabled: true, + isChatEnabled: false, + isAzureConfigured: true, + isSsoEnabled: true, + isSandboxesEnabled: true, + }) + + expect(resolveDeploymentShape()).toEqual({ + hosted: true, + billingEnabled: true, + chatEnabled: false, + azureConfigured: true, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: true, + sandboxes: true, + sessionPolicies: true, + sso: true, + usageMonitoring: false, + whitelabeling: true, + }, + }) + }) + + it('reads the flags at call time rather than at module init', () => { + expect(resolveDeploymentShape().hosted).toBe(false) + + setEnvFlags({ isHosted: true }) + + expect(resolveDeploymentShape().hosted).toBe(true) + }) +}) + +describe('getDeploymentShape on the server', () => { + it('answers from the env flags and ignores seeding, which is browser-only', () => { + seedDeploymentShape({ ...resolveDeploymentShape(), hosted: true }) + + expect(getDeploymentShape().hosted).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/config/deployment-shape.ts b/apps/sim/lib/core/config/deployment-shape.ts new file mode 100644 index 00000000000..a00429c6fce --- /dev/null +++ b/apps/sim/lib/core/config/deployment-shape.ts @@ -0,0 +1,158 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces' +import { + isAccessControlEnabled, + isAuditLogsEnabled, + isAzureConfigured, + isBillingEnabled, + isChatEnabled, + isCohereConfigured, + isCustomBlocksEnabled, + isDataDrainsEnabled, + isDataRetentionEnabled, + isHosted, + isInboxEnabled, + isSandboxesEnabled, + isSessionPoliciesEnabled, + isSsoEnabled, + isUsageMonitoringEnabled, + isWhitelabelingEnabled, +} from '@/lib/core/config/env-flags' + +/** + * One reader for the deployment's shape: hosted or self-hosted, whether billing and + * Chat run, which provider credentials the deployment supplies, and which enterprise + * features its configuration turns on. + * + * Server code reads the `env-flags` constants directly and this module only packages + * them. Browser code must not. Those constants are computed once from the + * `NEXT_PUBLIC_*` transport the root layout emits, and a document that never ran the + * root layout — Next's bare `__next_error__` 404 shell, or `global-error` after the + * root layout threw — leaves every one of them unset for the life of the tab, even + * after `retry()` or a client-side navigation recovers the app in place. Sim Cloud then + * renders as self-hosted: API Key fields on hosted models, no Auto model, no billing. + * + * Workspace surfaces therefore read the shape the workspace host context carries, + * resolved on the server per request and seeded here by the host provider before any + * workspace child renders. The constants remain the fallback only outside a workspace, + * where the root layout always runs. + * + * Block definitions import this module, which puts it in React Server Component graphs + * (the block registry is loaded by auth and workflow lifecycle code), so it must not + * import React hooks itself; the seeding hook lives with the client-side host provider. + */ + +interface DeploymentShapeState { + /** Server-resolved shape from the workspace host context; `null` until a workspace mounts. */ + seeded: DeploymentShape | null + seed: (shape: DeploymentShape) => void + reset: () => void +} + +const useDeploymentShapeStore = create()( + devtools( + (set) => ({ + seeded: null, + seed: (shape) => set({ seeded: shape }), + reset: () => set({ seeded: null }), + }), + { name: 'deployment-shape-store' } + ) +) + +/** + * The browser's env fallback, built once per document. The env constants it packages are + * themselves frozen at module init, so caching changes nothing semantically, and it gives + * {@link useDeploymentShape} a stable reference that memo dependencies can key on. + */ +let browserEnvFallback: DeploymentShape | null = null + +function browserFallbackShape(): DeploymentShape { + browserEnvFallback ??= resolveDeploymentShape() + return browserEnvFallback +} + +/** + * The shape this runtime's own configuration resolves to. On the server that is the + * deployment's truth, and what the workspace host context projects. In the browser it + * is the `NEXT_PUBLIC_*` fallback: right on every document that ran the root layout, + * and the only source outside a workspace. + */ +export function resolveDeploymentShape(): DeploymentShape { + return { + hosted: isHosted, + billingEnabled: isBillingEnabled, + chatEnabled: isChatEnabled, + azureConfigured: isAzureConfigured, + cohereConfigured: isCohereConfigured, + features: { + accessControl: isAccessControlEnabled, + auditLogs: isAuditLogsEnabled, + customBlocks: isCustomBlocksEnabled, + dataDrains: isDataDrainsEnabled, + dataRetention: isDataRetentionEnabled, + inbox: isInboxEnabled, + sandboxes: isSandboxesEnabled, + sessionPolicies: isSessionPoliciesEnabled, + sso: isSsoEnabled, + usageMonitoring: isUsageMonitoringEnabled, + whitelabeling: isWhitelabelingEnabled, + }, + } +} + +function isSameDeploymentShape(seeded: DeploymentShape | null, next: DeploymentShape): boolean { + if (seeded === null) return false + if ( + seeded.hosted !== next.hosted || + seeded.billingEnabled !== next.billingEnabled || + seeded.chatEnabled !== next.chatEnabled || + seeded.azureConfigured !== next.azureConfigured || + seeded.cohereConfigured !== next.cohereConfigured + ) { + return false + } + const featureKeys = Object.keys(next.features) as (keyof DeploymentFeatures)[] + return featureKeys.every((key) => seeded.features[key] === next.features[key]) +} + +/** + * Installs the server-resolved shape for browser readers. A no-op on the server, where + * a module-level store would leak across requests, and when the shape is unchanged, so + * a host-context refetch or a sibling workspace never notifies subscribers for nothing. + */ +export function seedDeploymentShape(shape: DeploymentShape | undefined): void { + if (typeof window === 'undefined' || !shape) return + const { seeded, seed } = useDeploymentShapeStore.getState() + if (isSameDeploymentShape(seeded, shape)) return + seed(shape) +} + +/** Drops the seeded shape and the cached fallback. For tests; the app never unseeds on purpose. */ +export function resetDeploymentShape(): void { + browserEnvFallback = null + useDeploymentShapeStore.getState().reset() +} + +/** + * The deployment shape for code that runs outside React, such as block `condition` + * functions and sub-block visibility. Server callers get the resolved truth; browser + * callers get the seeded server value inside a workspace, and the `NEXT_PUBLIC_*` + * fallback elsewhere. + */ +export function getDeploymentShape(): DeploymentShape { + if (typeof window === 'undefined') return resolveDeploymentShape() + return useDeploymentShapeStore.getState().seeded ?? browserFallbackShape() +} + +/** + * {@link getDeploymentShape} for components, subscribed to the seeded value. Returns the + * same object until the shape actually changes, so it is safe as a memo dependency for + * option lists and other derived values that read the shape outside React. + */ +export function useDeploymentShape(): DeploymentShape { + const seeded = useDeploymentShapeStore((state) => state.seeded) + if (seeded) return seeded + return typeof window === 'undefined' ? resolveDeploymentShape() : browserFallbackShape() +} diff --git a/apps/sim/lib/core/config/env-flags.dom.test.ts b/apps/sim/lib/core/config/env-flags.dom.test.ts deleted file mode 100644 index 3511aaccf2b..00000000000 --- a/apps/sim/lib/core/config/env-flags.dom.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @vitest-environment jsdom - * @vitest-environment-options {"url":"https://www.sim.ai"} - */ -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.hoisted(() => { - vi.stubEnv('NEXT_PUBLIC_APP_URL', '') - vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'false') - vi.stubEnv('NODE_ENV', 'production') - document.documentElement.id = '__next_error__' -}) - -vi.unmock('@/lib/core/config/env') -vi.unmock('@/lib/core/config/env-flags') -vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: () => [] })) -vi.mock('@/providers/utils', () => ({ getProviderFromModel: () => 'openai' })) - -import { getEnv, PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' -import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility' -import { getApiKeyCondition } from '@/blocks/utils' -import { getHostedModels } from '@/providers/models' - -describe('hosted detection during client recovery', () => { - afterEach(() => { - document.documentElement.removeAttribute(PUBLIC_ENV_ATTRIBUTE) - Reflect.deleteProperty(window, '__ENV') - }) - - it('hides hosted model keys before the recovered layout installs runtime configuration', () => { - expect(window.__ENV).toBeUndefined() - expect(document.documentElement.getAttribute(PUBLIC_ENV_ATTRIBUTE)).toBeNull() - expect(isHosted).toBe(true) - - for (const model of ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-2.5-pro']) { - expect(getHostedModels()).toContain(model) - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model })).toBe(false) - } - - document.documentElement.setAttribute( - PUBLIC_ENV_ATTRIBUTE, - JSON.stringify({ NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' }) - ) - - expect(getEnv('NEXT_PUBLIC_APP_URL')).toBe('https://www.sim.ai') - expect(isHosted).toBe(true) - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'gpt-5.6-sol' })).toBe(false) - }) - - it('still requires keys for models outside the hosted catalog', () => { - expect(evaluateSubBlockCondition(getApiKeyCondition(), { model: 'custom/model' })).toBe(true) - }) -}) diff --git a/apps/sim/lib/core/config/env-flags.test.ts b/apps/sim/lib/core/config/env-flags.test.ts index fa30e64738e..ca854b6c79d 100644 --- a/apps/sim/lib/core/config/env-flags.test.ts +++ b/apps/sim/lib/core/config/env-flags.test.ts @@ -7,7 +7,6 @@ vi.hoisted(() => { vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://self-hosted.example') vi.stubEnv('NEXT_PUBLIC_FORCE_HOSTED', 'true') vi.stubEnv('NODE_ENV', 'production') - vi.stubGlobal('window', { location: { hostname: 'www.sim.ai' } }) }) vi.unmock('@/lib/core/config/env') @@ -15,8 +14,8 @@ vi.unmock('@/lib/core/config/env-flags') import { isHosted, isProd } from '@/lib/core/config/env-flags' -describe('configured hosted detection', () => { - it('preserves a configured self-hosted URL and ignores the development override in production', () => { +describe('hosted detection', () => { + it('follows the configured URL and ignores the development override in production', () => { expect(isProd).toBe(true) expect(isHosted).toBe(false) }) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index d90b2aa1e84..9fe590a0a8a 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -35,16 +35,18 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application. * True for sim.ai and any subdomain of sim.ai (e.g. staging.sim.ai, dev.sim.ai). - * The browser hostname remains available when an error document boots without - * the root layout's runtime environment, before client rendering recovers it. - * A valid configured URL takes precedence; server detection stays env-only. + * + * Workspace surfaces in the browser read `hosted` from the deployment shape the + * workspace host context carries (`@/lib/core/config/deployment-shape`), not this + * constant: it is computed once from the `NEXT_PUBLIC_*` transport the root layout + * emits, which a `global-error` or bare 404 document never provides. */ const appUrl = getEnv('NEXT_PUBLIC_APP_URL') -let appHostname = typeof window === 'undefined' ? '' : window.location.hostname +let appHostname = '' try { - if (appUrl) appHostname = new URL(appUrl).hostname + appHostname = appUrl ? new URL(appUrl).hostname : '' } catch { - /** Keep the document hostname when the configured URL cannot be parsed. */ + /** An unparseable configured URL reads as self-hosted. */ } /** * Local-development escape hatch for exercising hosted-only paths (the sim-auto diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 31f967a3b38..8a77dc285cd 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -24,11 +24,17 @@ import { z } from 'zod' * hydration. So on a warm cache both module bodies and the first commit can run * before the parser has reached the assignment. * - * On a normally rendered document, the attribute is parsed before bootstrap - * scripts can execute. Next's server-rendering error document omits the root - * layout, so client recovery only installs the attribute when that layout - * mounts. Hosted detection also uses the browser hostname during this gap. - * `window.__ENV` stays the public global and the preferred read. + * An attribute has no such ordering problem. `` is the first tag in the + * document — ~490 bytes ahead of the first bootstrap script — so + * `document.documentElement` already carries this value by the time *any* + * script, framework or application, is able to execute. This is the race-free + * transport; `window.__ENV` stays the public global and the preferred read. + * + * Neither transport exists on a document that never ran the root layout (Next's + * bare `__next_error__` 404 shell, or `global-error`), so a tab that continues + * from one in place keeps reading an unset env. Deployment flags therefore reach + * workspace surfaces through the server-resolved host context instead — see + * `@/lib/core/config/deployment-shape`. */ export const PUBLIC_ENV_ATTRIBUTE = 'data-public-env' diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 14d53c9fa57..ddb6cb05375 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -6,6 +6,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ canOpenOrganizationSettingsSection: vi.fn(), checkWorkspaceAccess: vi.fn(), + deploymentShape: { + hosted: true, + billingEnabled: true, + chatEnabled: true, + azureConfigured: false, + cohereConfigured: false, + features: { + accessControl: false, + auditLogs: false, + customBlocks: false, + dataDrains: false, + dataRetention: false, + inbox: false, + sandboxes: false, + sessionPolicies: false, + sso: false, + usageMonitoring: false, + whitelabeling: false, + }, + }, + getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), getWorkspaceOwnerSubscriptionAccess: vi.fn(), isCredentialGroupsAvailable: vi.fn(), isCustomBlocksEligibleForOrganization: vi.fn(), @@ -18,7 +39,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/components/settings/navigation', () => ({ - getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), + getOrganizationSettingsFeatures: mocks.getOrganizationSettingsFeatures, isOrganizationSettingsSectionAvailable: mocks.isOrganizationSettingsSectionAvailable, resolveWorkspaceNavigation: mocks.resolveWorkspaceNavigation, UNIFIED_TO_ORGANIZATION_SECTION: { @@ -45,7 +66,9 @@ vi.mock('@/lib/billing/core/subscription', () => ({ vi.mock('@/lib/credential-groups/availability', () => ({ isCredentialGroupsAvailable: mocks.isCredentialGroupsAvailable, })) -vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, isHosted: true })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ + getDeploymentShape: () => mocks.deploymentShape, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, })) @@ -192,6 +215,20 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) + it('passes the server-resolved deployment shape to both navigation gates', async () => { + await authorize('secrets') + expect(mocks.resolveWorkspaceNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + hosted: true, + entitlements: expect.objectContaining({ byok: true }), + }) + ) + + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + await expect(authorize('access-control')).resolves.toEqual({ allowed: true }) + expect(mocks.getOrganizationSettingsFeatures).toHaveBeenCalledWith(true, mocks.deploymentShape) + }) + it('resolves the exact entitlement source only for gated workspace sections', async () => { mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'credential-groups' }]) await authorize('credential-groups') diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 28227608393..7127cba0fce 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -10,7 +10,7 @@ import { } from '@/components/settings/navigation' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' @@ -62,11 +62,13 @@ async function canOpenWorkspaceSection( : false, ]) + const deployment = getDeploymentShape() const navigation = resolveWorkspaceNavigation({ permission, permissionConfig: accessControl?.config ?? {}, + hosted: deployment.hosted, entitlements: { - byok: isHosted, + byok: deployment.hosted, credentialGroups: credentialGroupsAvailable, inbox: true, customBlocks: customBlocksAvailable, @@ -86,7 +88,11 @@ async function canOpenOrganizationSection( ): Promise { const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[input.section] if (!organizationSection) return true - if (!isBillingEnabled && (input.section === 'billing' || input.section === 'organization')) { + const deployment = getDeploymentShape() + if ( + !deployment.billingEnabled && + (input.section === 'billing' || input.section === 'organization') + ) { return false } if (!workspace.organizationId) { @@ -104,7 +110,7 @@ async function canOpenOrganizationSection( canOpenSection && isOrganizationSettingsSectionAvailable( organizationSection, - getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization) + getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization, deployment) ) ) } diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 03656549c9c..2e9ed82e8ff 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -1,5 +1,5 @@ +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' @@ -627,7 +627,7 @@ export function isSubBlockHidden( subBlock: SubBlockConfig, options?: { hosted?: boolean } ): boolean { - const hosted = options?.hosted ?? isHosted + const hosted = options?.hosted ?? getDeploymentShape().hosted if (subBlock.hideWhenHosted && hosted) return true if (subBlock.hideWhenEnvSet && anyEnvSet(subBlock.hideWhenEnvSet)) return true return false diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 19cc0e1cfe8..db13e2865cf 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -25,6 +25,7 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, })) +import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' const OWNER_BILLING = { @@ -91,6 +92,7 @@ describe('getWorkspaceHostContextForViewer', () => { }, }) ) + expect(context?.deployment).toEqual(resolveDeploymentShape()) }) it('keeps an external collaborator authorized only by their workspace grant', async () => { diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 78cd140507f..10b4877459c 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -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 { resolveDeploymentShape } from '@/lib/core/config/deployment-shape' import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' @@ -54,6 +55,7 @@ async function resolveWorkspaceHostContextForViewer( credentialGroups: credentialGroupsAvailable, knowledgeMemberAccess: knowledgeMemberAccessAvailable, }, + deployment: resolveDeploymentShape(), } } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index a21a933534c..2bb1713c2a2 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -8,7 +8,7 @@ import { type AgentStreamToolTerminalStatus, settleRunningToolCallList, } from '@/components/agent-stream/tool-call-lifecycle' -import { isChatEnabled } from '@/lib/core/config/env-flags' +import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { redactApiKeys } from '@/lib/core/security/redaction' import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -317,7 +317,7 @@ const notifyBlockError = ({ toast.error(displayName, { description: errorMessage, - action: isChatEnabled + action: getDeploymentShape().chatEnabled ? { label: 'Fix in Chat', onClick: () => sendMothershipMessage(copilotMessage), From 71e848afbfcca0fba2a41e0c55385c7aa17fe268 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 3 Sep 2026 16:42:19 -0700 Subject: [PATCH 02/13] fix(chat): preserve literal code around source chips (#7456) * fix(chat): preserve literal code around source chips * fix(chat): scope delimiter pairing to paragraphs * fix(chat): preserve tilde-fenced citations --- .../chat-content/chat-content.test.ts | 118 +++++++++++++ .../components/chat-content/chat-sanitize.ts | 157 ++++++++++++------ .../components/scaling-test-helpers.ts | 11 +- 3 files changed, 235 insertions(+), 51 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts index e8a3e555831..1f7d07ccff0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts @@ -6,6 +6,104 @@ import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/c import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers' describe('sanitizeChatDisplayContent', () => { + it.each(['source', 'workspace_resource'])( + 'unwraps %s JSON that mentions the other chip tag', + (name) => { + const otherTag = name === 'source' ? 'workspace_resource' : 'source' + const tag = `<${name}>${JSON.stringify({ title: `Use <${otherTag}>` })}` + + expect(sanitizeChatDisplayContent(`\`${tag}\``)).toBe(tag) + } + ) + + it.each([2, 3, 4])('preserves a %i-backtick code span containing a chip', (length) => { + const delimiter = '`'.repeat(length) + const tag = '{"url":"https://example.com","title":"Use `config`"}' + const content = `${delimiter}${tag}${delimiter}` + + expect(sanitizeChatDisplayContent(content)).toBe(content) + expect(sanitizeChatDisplayContent(`${delimiter}json\n\`${tag}\`\n${delimiter}`)).toBe( + `${delimiter}json\n\`${tag}\`\n${delimiter}` + ) + expect(sanitizeChatDisplayContent(`${content} then \`${tag}\``)).toBe(`${content} then ${tag}`) + }) + + it('does not let an unmatched backtick run suppress later citations', () => { + const prefix = 'Use `` for two backticks.\n' + const tag = '{"url":"https://example.com"}' + + expect(sanitizeChatDisplayContent(`${prefix}\`${tag}\``)).toBe(`${prefix}${tag}`) + }) + + it.each(['\n\n', '\r\n\r\n', '\n \t\n'])( + 'does not pair prose runs across paragraph break %j', + (separator) => { + const tag = '{"url":"https://example.com"}' + const before = `Use \`\` as a delimiter.${separator}` + const after = `${separator}Another \`\` marker.` + + expect(sanitizeChatDisplayContent(`${before}\`${tag}\`${after}`)).toBe( + `${before}${tag}${after}` + ) + } + ) + + it('preserves matched multi-backtick spans across a soft line break', () => { + const content = '``Literal\n`{"url":"https://example.com"}`\nexample``' + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it('does not treat blank lines inside chip JSON as paragraph breaks', () => { + const tag = '{\n\n"url":"https://example.com",\n\n"title":"Use `code`"\n}' + + expect(sanitizeChatDisplayContent(`\`${tag}\``)).toBe(tag) + }) + + it.each(['```', '~~~'])( + 'preserves a %s fence closed by a longer run and unwraps citations after it', + (fence) => { + const tag = '{"url":"https://example.com"}' + const block = `${fence}json\n\`${tag}\`\n${fence}${fence[0]}\n` + + expect(sanitizeChatDisplayContent(`${block}\`${tag}\``)).toBe(`${block}${tag}`) + } + ) + + it.each(['```', '~~~'])('leaves an unclosed %s streaming fence literal', (fence) => { + const content = `${fence}json\n\`{"url":"https://example.com"}\`` + + expect(sanitizeChatDisplayContent(content)).toBe(content) + }) + + it.each(['source', 'workspace_resource'])( + 'preserves tilde-fenced %s chips with backticks in the info string', + (name) => { + const tag = `<${name}>{"title":"Example"}` + const block = `~~~example \`code\`\n\`${tag}\`\n~~~\n` + + expect(sanitizeChatDisplayContent(`${block}\`${tag}\``)).toBe(`${block}${tag}`) + } + ) + + it.each(['```', '~~~'])( + 'does not close a %s fence with a different character or a shorter run', + (fence) => { + const tag = '{"url":"https://example.com"}' + const otherFence = fence === '```' ? '~~~~' : '````' + const block = `${fence}${fence[0]}\n${otherFence}\n\`${tag}\`\n${fence}\n\`${tag}\`\n${fence}${fence[0]}\n` + + expect(sanitizeChatDisplayContent(`${block}\`${tag}\``)).toBe(`${block}${tag}`) + } + ) + + it('does not open a backtick fence with backticks in its info string', () => { + const prefix = '```example `code`\n\n' + const tag = '{"url":"https://example.com"}' + + expect(sanitizeChatDisplayContent(`${prefix}\`${tag}\``)).toBe(`${prefix}${tag}`) + }) + it('unwraps workspace resource tags from inline code spans', () => { const content = '`I updated {"type":"workflow","id":"wf-1","title":"Workflow"}.`' @@ -159,4 +257,24 @@ describe('sanitizeChatDisplayContent', () => { '{"type":"file","path":"a.md","title":"a"} done' ) }) + + it.each(['source', 'workspace_resource'])( + 'stays linear on repeated %s tags with unterminated JSON strings', + (name) => { + expect( + scalingRatioOver4x(sanitizeChatDisplayContent, (times) => + `<${name}>{${String.fromCharCode(92, 34)}`.repeat(times) + ) + ).toBeLessThan(8) + } + ) + + it.each(['"', '{"key":"'])( + 'stays linear on repeated quoted payload prefix %s', + (prefix) => { + expect( + scalingRatioOver4x(sanitizeChatDisplayContent, (times) => prefix.repeat(times)) + ).toBeLessThan(8) + } + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts index 867916bd863..0c9813cdf65 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts @@ -1,66 +1,129 @@ const HIDDEN_INLINE_REFERENCE_PATTERN = /`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g -/** JSON strings own their escaped quotes, backticks, and any quoted tag markers. */ -const JSON_STRING_SOURCE = String.raw`"(?:[^"\\\r\n]|\\[^\r\n])*"` +/** JSON strings own their escaped quotes, backticks, and quoted tag markers. */ +const JSON_STRING_SOURCE = '"(?:\\\\(?:["\\\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\\\\\r\\n])*"' -/** - * Complete chip tags consume JSON strings atomically. Outside strings, a new - * opener or backtick ends the candidate, so prose mentions cannot join into a - * tag and repeated unclosed openers cannot repeatedly scan the same suffix. - */ -const COMPLETE_TAG_SOURCE = `<(?workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<])*?\\}\\s*>` +/** Unquoted openers, backticks, and invalid backslashes bound failed payload scans. */ +const COMPLETE_TAG_SOURCE = `<(?workspace_resource|source)>\\s*\\{(?:${JSON_STRING_SOURCE}|[^"\`<\\\\])*?\\}\\s*>` -const CHIP_OR_CODE_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`|\n`, 'g') +const INLINE_CHIP_OR_DELIMITER = new RegExp(`${COMPLETE_TAG_SOURCE}|\`+|\\n`, 'g') +const CHIP_OR_PARAGRAPH_BREAK = new RegExp(`${COMPLETE_TAG_SOURCE}|\\n[\\t \\r]*\\n`, 'g') -/** - * Pair Markdown delimiters outside chip payloads in one forward pass. A pair - * containing a chip is unwrapped; a lone delimiter is removed only when flush - * against a chip. Neighbouring code spans and multiline fences keep their pairs. - */ -export function sanitizeChatDisplayContent(content: string): string { - const removedDelimiters: number[] = [] - let openingTick = -1 - let containsChip = false - let adjacentToChip = false - let lastChipEnd = -1 +interface OpenCodeSpan { + index: number + containsChip: boolean + touchesChip: boolean +} - for (const match of content.matchAll(CHIP_OR_CODE_DELIMITER)) { - const index = match.index - if (match.groups?.chipTag) { - if (openingTick !== -1) { - containsChip = true - adjacentToChip ||= index === openingTick + 1 - } - lastChipEnd = index + match[0].length - continue +/** Only matched multi-backtick runs are code; an unmatched run remains ordinary prose. */ +function unwrapInlineParagraph(content: string): string { + const remainingRuns = new Map() + for (const [value] of content.matchAll(INLINE_CHIP_OR_DELIMITER)) { + if (value.startsWith('`') && value.length > 1) { + remainingRuns.set(value.length, (remainingRuns.get(value.length) ?? 0) + 1) } + } + const removedDelimiters: number[] = [] + let openSpan: OpenCodeSpan | null = null + let previousChipEnd = -1 + let protectedRunLength: number | null = null - if (match[0] === '\n') { - if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick) - openingTick = -1 - lastChipEnd = -1 - continue - } + const finishLine = () => { + if (openSpan?.touchesChip) removedDelimiters.push(openSpan.index) + openSpan = null + } - if (openingTick === -1) { - openingTick = index - containsChip = false - adjacentToChip = lastChipEnd === index + for (const token of content.matchAll(INLINE_CHIP_OR_DELIMITER)) { + const [value] = token + const index = token.index + if (value === '\n') { + finishLine() + previousChipEnd = -1 + } else if (value.startsWith('`')) { + if (value.length > 1) { + remainingRuns.set(value.length, (remainingRuns.get(value.length) ?? 1) - 1) + } + if (protectedRunLength !== null) { + if (value.length === protectedRunLength) protectedRunLength = null + continue + } + if (value.length > 1) { + if (!openSpan && remainingRuns.get(value.length)) protectedRunLength = value.length + continue + } + if (openSpan) { + if (openSpan.containsChip) removedDelimiters.push(openSpan.index, index) + openSpan = null + } else { + openSpan = { index, containsChip: false, touchesChip: previousChipEnd === index } + } } else { - if (containsChip) removedDelimiters.push(openingTick, index) - openingTick = -1 + if (openSpan) { + openSpan.containsChip = true + openSpan.touchesChip ||= index === openSpan.index + 1 + } + previousChipEnd = index + value.length } } - - if (openingTick !== -1 && adjacentToChip) removedDelimiters.push(openingTick) + finishLine() const parts: string[] = [] - let start = 0 + let cursor = 0 for (const index of removedDelimiters) { - parts.push(content.slice(start, index)) - start = index + 1 + parts.push(content.slice(cursor, index)) + cursor = index + 1 + } + parts.push(content.slice(cursor)) + return parts.join('') +} + +/** Paragraph breaks end inline spans, but blank lines inside chip JSON belong to the payload. */ +function unwrapInlineChips(content: string): string { + const parts: string[] = [] + let cursor = 0 + + for (const match of content.matchAll(CHIP_OR_PARAGRAPH_BREAK)) { + if (!match[0].startsWith('\n')) continue + parts.push(unwrapInlineParagraph(content.slice(cursor, match.index)), match[0]) + cursor = match.index + match[0].length + } + parts.push(unwrapInlineParagraph(content.slice(cursor))) + return parts.join('') +} + +/** Fenced blocks are literal, including unclosed streaming fences and longer closing runs. */ +export function sanitizeChatDisplayContent(content: string): string { + const parts: string[] = [] + let cursor = 0 + let fenceStart: number | null = null + let fence = '' + + for (const line of content.matchAll(/^ {0,3}(`{3,}|~{3,})([^\n]*)(?:\n|$)/gm)) { + const [, delimiter, info] = line + if (fenceStart === null) { + if (delimiter[0] === '`' && info.includes('`')) continue + fenceStart = line.index + fence = delimiter + } else if ( + delimiter[0] === fence[0] && + delimiter.length >= fence.length && + /^[\t \r]*$/.test(info) + ) { + const end = line.index + line[0].length + parts.push( + unwrapInlineChips(content.slice(cursor, fenceStart)), + content.slice(fenceStart, end) + ) + cursor = end + fenceStart = null + } + } + + if (fenceStart === null) { + parts.push(unwrapInlineChips(content.slice(cursor))) + } else { + parts.push(unwrapInlineChips(content.slice(cursor, fenceStart)), content.slice(fenceStart)) } - parts.push(content.slice(start)) return parts.join('').replace(HIDDEN_INLINE_REFERENCE_PATTERN, '') } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts index 7d2dab3b83e..921de059228 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers.ts @@ -27,13 +27,16 @@ function fastest(run: (content: string) => void, content: string): number { * through at the single size it happens to sample. Quadratic costs ~16x for 4x * the input; linear costs ~4x. */ -export function scalingRatioOver4x(run: (content: string) => void): number { +export function scalingRatioOver4x( + run: (content: string) => void, + buildContent: (times: number) => string = buildRepeatedTagMentions +): number { // Warm up first — the JIT would otherwise charge the whole compile to the // small sample and flatter the ratio. - fastest(run, buildRepeatedTagMentions(2_000)) + fastest(run, buildContent(2_000)) - const small = fastest(run, buildRepeatedTagMentions(2_000)) - const large = fastest(run, buildRepeatedTagMentions(8_000)) + const small = fastest(run, buildContent(2_000)) + const large = fastest(run, buildContent(8_000)) return large / small } From 99225b5966c65b61150d0542eb5cf5a90f22e5b6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 3 Sep 2026 17:01:19 -0700 Subject: [PATCH 03/13] docs(library): update n8n-alternatives (#7463) Co-authored-by: Sim Pi Agent --- .../library/n8n-alternatives/index.mdx | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/sim/content/library/n8n-alternatives/index.mdx b/apps/sim/content/library/n8n-alternatives/index.mdx index 7378582bd13..ef2163d4382 100644 --- a/apps/sim/content/library/n8n-alternatives/index.mdx +++ b/apps/sim/content/library/n8n-alternatives/index.mdx @@ -3,9 +3,9 @@ slug: n8n-alternatives title: '10 Best n8n Alternatives for AI Agent Workflows in 2026' description: Comparing the 10 best n8n alternatives in 2026 for AI agent workflows - covering Sim, Make, Zapier, Activepieces, Pipedream, and more, with pricing and use cases. date: 2026-07-13 -updated: 2026-07-23 +updated: 2026-09-03 authors: - - emir + - andrew readingTime: 15 tags: [n8n Alternatives, Workflow Automation, AI Agents, Sim] ogImage: /library/n8n-alternatives/cover.jpg @@ -29,8 +29,10 @@ This guide covers 10 n8n alternatives for two distinct groups: teams that want s ## TL;DR - **n8n's core friction points in 2026:** Self-hosting overhead, execution-based pricing that escalates at volume, and AI capabilities bolted onto a pre-agentic architecture push teams toward purpose-built alternatives. -- **For AI agent workflows:** Sim offers native multi-model orchestration, agent memory, and MCP support as fully integral features rather than add-ons. Gumloop and Botpress serve more specific AI niches (visual LLM workflows and conversational agents, respectively). -- **For simpler SaaS automation:** Make provides the best debugging experience for visual workflow builders. Zapier offers the widest integration catalog. Neither is built for agentic AI. +- **Best for self-hosting:** n8n remains a strong option for developer teams that want to manage traditional automation infrastructure themselves. Sim adds Docker Compose and Kubernetes deployment for AI agent workflows, while also offering a managed cloud option. +- **Best for AI-native workflows:** Sim offers native multi-model orchestration, agent memory, MCP support, and 1,000+ integrations in an open-source visual workflow builder. Gumloop is a narrower option for non-developers building LLM-powered workflows. +- **Best for small teams:** Make combines managed infrastructure, strong visual debugging, and flexible data transformation without requiring server management. +- **Best for non-technical users:** Zapier offers the fastest setup and widest integration catalog for straightforward SaaS automation. Gumloop is the more AI-focused choice for non-developers. - **Open source matters, but licenses vary:** Activepieces uses a genuine MIT license with no commercial restrictions. n8n's [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) restricts commercial redistribution, which isn't true open source by OSI standards. We unpack the practical consequences in [Apache 2.0 vs fair-code](/library/apache-2-0-vs-fair-code). - **Migration is never a weekend project:** Moving a portfolio of n8n workflows to any alternative requires real engineering time. Calculate total cost of ownership, including DevOps hours, before committing. - **Self-hosting isn't automatically cheaper:** Factor in server costs, database hosting, security patches, and maintenance hours against the subscription price of managed alternatives before assuming you'll save money. @@ -57,7 +59,14 @@ What to look for in an alternative: AI-agent architecture depth (native memory, ## How to Choose: A Framework Before the List -Before scrolling through 10 tools, it helps to know which two or three deserve your attention. Here's how to match your team's situation to the right category. +Before scrolling through 10 tools, identify which category best matches your team. The quickest shortlist is n8n for developer-controlled self-hosting, Sim for general-purpose AI-native workflows, Make for small teams that value visual debugging, Zapier for non-technical SaaS automation, and Gumloop for non-developers building LLM-powered workflows. + +| Category | Best Fit | Why | Also Consider | +| --- | --- | --- | --- | +| **Self-hosting** | **n8n** | Flexible, developer-controlled automation for teams comfortable managing a server | **Sim** for open-source AI agent workflows via Docker Compose or Kubernetes | +| **AI-native workflows** | **Sim** | Open-source visual AI agent builder with native memory, multi-model orchestration, MCP support, and 1,000+ integrations | **Gumloop** for a simpler visual LLM workflow canvas | +| **Small teams** | **Make** | Managed infrastructure, strong visual debugging, and flexible data transformation | **Sim** when the team's workflows require AI agents rather than conventional automation | +| **Non-technical users** | **Zapier** | Linear setup, 9,000+ integrations, and the fastest path to simple SaaS automation | **Gumloop** for no-code, AI-focused workflows | **Building AI agents that reason and remember.** You need a platform where agent memory, multi-model routing, and orchestration are native features, not something you assemble from generic nodes. Look at Sim, Gumloop, or Botpress, depending on whether your agents are general-purpose, LLM-workflow-focused, or conversational. @@ -69,18 +78,18 @@ Before scrolling through 10 tools, it helps to know which two or three deserve y This table summarizes all 10 tools across the dimensions that matter most when evaluating n8n alternatives. -| Tool | Best For | AI Agent Depth | Open Source | Starting Price | +| Tool | Concise Definition | Best For | Hosting | AI Agent Depth | | --- | --- | --- | --- | --- | -| **Sim** | AI agent workflows with multi-model orchestration | Native (memory, multi-agent, MCP) | Yes | Free plan available | -| **[Make](https://www.make.com/en/pricing)** | Visual SaaS automation with strong debugging | One-shot AI nodes only | No | Free tier; paid from $12/mo | -| **[Zapier](https://zapier.com/pricing)** | Widest integration catalog, fastest setup | Basic (Zapier Agents, Copilot) | No | Free tier; paid from $19.99/mo (annual) | -| **[Activepieces](https://www.activepieces.com/pricing)** | Open-source self-hosting with MIT license | Growing (AI agents, MCP servers) | Yes (MIT) | Free self-hosted; cloud from $5/flow/mo after 10 free | -| **Pipedream** | Code-first automation without infra management | Not AI-agent native | Partial | Free tier available | -| **[Gumloop](https://www.gumloop.com/pricing)** | Non-developers building LLM-powered workflows | Native visual AI canvas | No | See pricing page | -| **[Lindy](https://www.lindy.ai/pricing)** | Task-specific AI agents (email, meetings, sales) | Pre-built agent templates | No | $49.99/mo | -| **[Botpress](https://botpress.com/pricing)** | Conversational AI agents (chat, voice) | Native (memory, RAG, goals) | Partial | See pricing page | -| **[Microsoft Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing)** | Microsoft 365/Azure ecosystem teams | AI Builder (add-on cost) | No | $15/user/mo; included with some M365 plans | -| **Workato** | Enterprise iPaaS with SLAs and compliance | Enterprise orchestration | No | Custom pricing | +| **Sim** | Open-source visual AI agent and workflow builder with 1,000+ integrations | AI-native workflows, including for teams that prefer managed cloud | Managed cloud or self-hosted | Native memory, multi-agent orchestration, and MCP | +| [**Make**](https://www.make.com/en/pricing) | Visual SaaS automation platform with color-coded execution paths | Small teams and ops users who prioritize debugging | Managed cloud | One-shot AI nodes only | +| [**Zapier**](https://zapier.com/pricing) | Linear trigger-action automation platform with 9,000+ integrations | Non-technical users who want fast setup | Managed cloud | Basic Agents and Copilot features | +| [**Activepieces**](https://www.activepieces.com/pricing) | MIT-licensed trigger-action automation platform | Open-source self-hosting without per-run software charges | Managed cloud or self-hosted | Growing AI agent and MCP support | +| **Pipedream** | Code-first serverless workflow platform | Developers who want code control without managing infrastructure | Managed cloud | Not AI-agent native | +| [**Gumloop**](https://www.gumloop.com/pricing) | Visual platform designed for connecting AI models, prompts, and data sources | Non-developers building LLM-powered workflows | Managed cloud | Native visual AI canvas | +| [**Lindy**](https://www.lindy.ai/pricing) | Template-led AI agent platform for defined business tasks | Email, meeting, and sales agents with minimal setup | Managed cloud | Pre-built agent templates | +| [**Botpress**](https://botpress.com/pricing) | Conversational AI agent platform for chat and voice | Multi-turn customer support and sales agents | Managed platform | Native memory, RAG, and goals | +| [**Microsoft Power Automate**](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) | Microsoft automation platform with cloud and desktop flows | Organizations standardized on Microsoft 365 and Azure | Managed platform | AI Builder available at added cost | +| **Workato** | Enterprise integration platform with governance and contractual support | Large organizations with compliance and SLA requirements | Managed platform | Enterprise orchestration | ## The 10 Best n8n Alternatives in 2026 @@ -88,7 +97,7 @@ This table summarizes all 10 tools across the dimensions that matter most when e **Best for:** Teams building AI agent workflows with native multi-model orchestration, memory, and visual collaboration. -[Sim](https://sim.ai) isn't a workflow tool that added AI nodes as an afterthought. It's a visual AI agent workflow builder where agents, knowledge bases, tables, and multi-model LLM routing are features built into the platform's core architecture. If you're leaving n8n because its AI capabilities feel bolted on, Sim is the direct answer. +[Sim](https://sim.ai) is an open-source AI agent builder and visual workflow platform with 1,000+ integrations. It competes directly with n8n for teams building workflows around agents, knowledge bases, tables, and multi-model LLM routing, but it does not require those teams to self-host: Sim offers managed cloud infrastructure alongside Docker Compose and Kubernetes deployment. If you're leaving n8n because its AI capabilities feel bolted on or because you no longer want to manage infrastructure, Sim is a direct alternative. **Strengths:** @@ -262,13 +271,12 @@ Many teams looking for an n8n open source alternative care about one of three th ## The Bottom Line -The right n8n alternative depends entirely on what's driving you away from n8n in the first place: - -If you're leaving because AI agent capabilities feel like an afterthought, Sim gives you native multi-model orchestration, agent memory, and MCP support without fighting the platform's architecture. - -If you're leaving because self-hosting is consuming too much engineering time, Make or Zapier eliminate infrastructure overhead entirely, with Make offering better debugging for complex workflows and Zapier offering the fastest path to a working automation. +The right choice depends on your team's primary requirement: -If licensing restrictions concern you, Activepieces offers the cleanest MIT-licensed alternative with a growing integration ecosystem. +- **Best for self-hosting:** Choose n8n for developer-controlled, traditional automation when your team is comfortable managing infrastructure. Choose Activepieces when an unrestricted MIT license is the priority, or Sim when you need self-hosted AI agent workflows. +- **Best for AI-native workflows:** Choose Sim for an open-source visual AI agent builder with 1,000+ integrations, native multi-model orchestration, agent memory, and MCP support. It also offers managed cloud infrastructure for teams that do not want to self-host. Choose Gumloop for a narrower, non-developer-focused LLM workflow canvas. +- **Best for small teams:** Choose Make for managed visual automation, strong debugging, and flexible data transformation. +- **Best for non-technical users:** Choose Zapier for the fastest route to straightforward SaaS automation and the widest integration catalog. Choose Gumloop instead when AI is central to the workflow. Don't try to evaluate all 10 tools on this list. Identify which of the four archetypes from the framework section fits your team, narrow the list to two or three candidates, and build a real workflow in each. The free tiers across Sim, Make, Zapier, Activepieces, and Pipedream allow you to do this without any upfront spend. From 8ddba9a6fa45d0bfe6841e8485cf290f4e01221c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 3 Sep 2026 17:25:03 -0700 Subject: [PATCH 04/13] fix(desktop): render the offline page and server picker in packaged builds (#7464) * fix(desktop): render the offline page and server picker in packaged builds * fix(desktop): keep retrying the origin past a broken offline page and drop the font copy --- apps/desktop/README.md | 5 +- .../docs/electron-upgrade-checklist.md | 2 +- apps/desktop/e2e/packaged-smoke.spec.ts | 78 +++++++- apps/desktop/e2e/smoke.spec.ts | 27 ++- apps/desktop/src/main/index.ts | 20 +- apps/desktop/src/main/ipc.test.ts | 23 +-- apps/desktop/src/main/ipc.ts | 22 +-- apps/desktop/src/main/load-health.test.ts | 79 +++++++- apps/desktop/src/main/load-health.ts | 27 ++- apps/desktop/src/main/local-pages.test.ts | 151 +++++++++++++++ apps/desktop/src/main/local-pages.ts | 181 ++++++++++++++++++ apps/desktop/src/main/server-window.test.ts | 62 +++++- apps/desktop/src/main/server-window.ts | 53 ++++- apps/desktop/src/test/electron-mock.ts | 6 + apps/desktop/static/offline.html | 4 +- apps/desktop/static/server.html | 7 +- 16 files changed, 689 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/main/local-pages.test.ts create mode 100644 apps/desktop/src/main/local-pages.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bd3e9dffc5d..823bf0d4ced 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -19,6 +19,7 @@ src/main/ # main process (bundled to dist/main.cjs) handoff.ts # 127.0.0.1 loopback login handoff + token redeem session-lifecycle.ts # sign-out teardown, 401 watcher, connect intercept load-health.ts # offline/error page, auto-retry, watchdog + local-pages.ts # sim-shell: scheme for the bundled pages (file: cannot read app.asar with its privileges fused off) local-filesystem.ts # session-scoped read-only directory grants + localfs:// broker local-filesystem-grant-store.ts # those grants, encrypted at rest desktop-settings.ts # renderer-facing settings surface @@ -38,7 +39,7 @@ src/preload/ # isolated renderer bridges index.ts # hosted-app contextBridge IPC bridge (dist/preload.cjs) browser/ # minimal agent-browser credential helper (dist/browser-preload.cjs) native/ # Node-API/AppKit bridge for native macOS Help docs search -static/ # bundled local pages (offline.html) +static/ # bundled local pages (offline.html, server.html), served over sim-shell: e2e/ # Playwright _electron smoke suite ``` @@ -130,7 +131,7 @@ Yes — the architecture has a single, clean seam for native features, and nothi 1. **One bridge.** The preload (`src/preload/index.ts`) exposes `window.simDesktop` via `contextBridge` on the main window. This is the *only* channel between web content and native capability. It exposes narrow, typed methods — never raw `ipcRenderer` (Electron security checklist item 20). 2. **Feature-detect, never assume.** The same web app is served to browsers and to the desktop from one origin, so a desktop feature is progressive enhancement: `if (window.simDesktop) { … }`. In a browser `window.simDesktop` is `undefined` and the feature is simply absent. (`isHosted` already tags these sessions for analytics.) -3. **Gate in main.** Every channel is validated in `src/main/ipc.ts` by sender frame — app-origin for capability calls, bundled `file:` pages for shell-control calls (checklist item 17). A new native feature adds one gated channel there. +3. **Gate in main.** Every channel is validated in `src/main/ipc.ts` by sender frame — app-origin for capability calls, the bundled `sim-shell://pages/…` documents for shell-control calls (checklist item 17). A new native feature adds one gated channel there. 4. **Single-source the contract.** `apps/sim` cannot import from `apps/desktop` (monorepo rule: `apps/* → packages/*` only). The bridge interface lives in the shared types-only `packages/desktop-bridge` package, which both the preload and web app consume. Concrete example — a "Reveal in Finder" button: diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md index 04aa583558a..dbdd667bddf 100644 --- a/apps/desktop/docs/electron-upgrade-checklist.md +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -4,7 +4,7 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on 1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses. 2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`. -3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. +3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. `grantFileProtocolExtraPrivileges` stays off, which is why the bundled pages are served over `sim-shell:` (`src/main/local-pages.ts`) rather than `file:` — with it off, `file:` cannot read inside `app.asar`. The packaged smoke test loads the offline page over remote debugging to prove the pages still render after an upgrade. 4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest. 5. **Manual spot-checks (packaged build):** - Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem). diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts index e7613321e87..f4010e8abfa 100644 --- a/apps/desktop/e2e/packaged-smoke.spec.ts +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses' -import { expect, test } from '@playwright/test' +import { type Browser, chromium, expect, test } from '@playwright/test' const FUSE_DISABLED = '0'.charCodeAt(0) const FUSE_ENABLED = '1'.charCodeAt(0) @@ -82,3 +82,79 @@ test('packaged main process starts and records launch telemetry', async () => { rmSync(userDataPath, { recursive: true, force: true }) } }) + +// The unpackaged suite cannot see this: the bundled pages live inside app.asar +// only once packaged, and the file-protocol fuse is only off once packaged. +// v0.8.13 through v0.8.19 shipped both pages blank because nothing loaded them +// in that configuration. Chromium's remote-debugging switch is honoured by the +// fused binary, which is what lets the test read the rendered page. +test('packaged shell renders the bundled offline page', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) + // Cookie encryption and safeStorage key their secret off the app's identity + // in the login keychain. A build under test (unsigned locally, or the first + // run on a machine that already has the real app's item) would block on a + // Keychain prompt on its main thread, and the debugging endpoint with it. + const child = spawn(executablePath, ['--remote-debugging-port=0', '--use-mock-keychain'], { + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', + SIM_DESKTOP_USER_DATA: userDataPath, + }, + stdio: 'ignore', + }) + const portFile = join(userDataPath, 'DevToolsActivePort') + let browser: Browser | undefined + + try { + await expect + .poll( + () => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Packaged app exited with ${child.exitCode ?? child.signalCode ?? 'unknown status'}` + ) + } + return existsSync(portFile) && readFileSync(portFile, 'utf8').trim().length > 0 + }, + { timeout: 15_000 } + ) + .toBe(true) + const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`) + const findPage = (urlPrefix: string) => + browser + ?.contexts() + .flatMap((context) => context.pages()) + .find((page) => page.url().startsWith(urlPrefix)) + await expect + .poll(() => Boolean(findPage('sim-shell://pages/offline.html?')), { timeout: 15_000 }) + .toBe(true) + const offline = findPage('sim-shell://pages/offline.html?') + if (!offline) throw new Error('offline page disappeared') + await expect(offline.locator('#title')).toHaveText('Can’t connect to Sim') + await expect(offline.locator('#server')).toBeVisible() + + // The picker is the recovery path from here. Opening it and reading the + // pre-filled value crosses the local-page IPC gate twice, which packaged + // builds also used to refuse: the allowlist was resolved against a working + // directory that is `/` when Finder launches the app. + await offline.locator('#server').click() + await expect + .poll(() => Boolean(findPage('sim-shell://pages/server.html')), { timeout: 15_000 }) + .toBe(true) + const picker = findPage('sim-shell://pages/server.html') + if (!picker) throw new Error('server picker disappeared') + await expect(picker.locator('h1')).toHaveText('Sim server') + await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1') + } finally { + await browser?.close().catch(() => {}) + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + } + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index b96f923908b..4b6c03016ca 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -157,7 +157,7 @@ test.describe('desktop shell smoke', () => { app = await launchApp('http://127.0.0.1:1') const window = await app.firstWindow() await window.waitForSelector('#retry', { timeout: 30_000 }) - expect(window.url().startsWith('file:')).toBe(true) + expect(window.url()).toMatch(/^sim-shell:\/\/pages\/offline\.html\?/) await expect(window.locator('.wordmark')).toBeVisible() await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim') await expect(window.locator('#title')).toHaveText('Can’t connect to Sim') @@ -183,4 +183,29 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid') await expect(window.locator('#detail')).toHaveAttribute('role', 'status') }) + + // The picker is the only way to repoint a shell whose server is unreachable. + // Its page, the pre-filled value (which crosses the local-page IPC gate) and + // Escape are asserted together because the packaged build once opened it as + // a blank sheet with no way out. + test('the offline page opens the server picker, pre-filled, and Escape closes it', async () => { + app = await launchApp('http://127.0.0.1:1') + const window = await app.firstWindow() + await window.waitForSelector('#server', { timeout: 30_000 }) + + const pickerPromise = app.waitForEvent('window') + await window.locator('#server').click() + const picker = await pickerPromise + + expect(picker.url()).toBe('sim-shell://pages/server.html') + await expect(picker.locator('h1')).toHaveText('Sim server') + await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1') + + const closed = picker.waitForEvent('close') + // The main process destroys the window on the key-down, so the key-up half + // of `press` has no target to reach; the close event is the assertion. + await picker.keyboard.press('Escape').catch(() => {}) + await closed + expect(app.windows()).toHaveLength(1) + }) }) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9cfcc37b401..4bb615b229b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { OpenDialogOptions, Session, WebContents } from 'electron' @@ -57,6 +57,12 @@ import { registerIpcHandlers } from '@/main/ipc' import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' import { LocalFilesystemService } from '@/main/local-filesystem' import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' +import { + attachLocalPageProtocol, + isLocalPageUrl, + localPageUrl, + registerLocalPageScheme, +} from '@/main/local-pages' import { installApplicationMenu } from '@/main/menu' import { openExternalSafe } from '@/main/navigation' import { createEventLog, installMainProcessFailureObservers } from '@/main/observability' @@ -91,8 +97,6 @@ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } -const OFFLINE_PAGE = 'static/offline.html' -const SERVER_PAGE = 'static/server.html' const DOCK_ICON_FOR_CHANNEL = { prod: 'dock-icon.png', staging: 'dock-icon-staging.png', @@ -259,6 +263,7 @@ function main(): void { } configuredPartitions.add(partition) setupPermissionHandlers(ses, appOrigin) + attachLocalPageProtocol(ses) attachCspFallback(ses, appOrigin) attachDownloadHandling(ses, events) attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) @@ -425,7 +430,7 @@ function main(): void { allowHttpLocalhost: allowHttpLocalhost(), }) const loadHealth = attachLoadHealth(win, { - offlinePagePath: OFFLINE_PAGE, + offlinePageUrl: (query) => localPageUrl('offline.html', query), getStartUrl: () => `${appOrigin()}${route}`, isOnline: () => net.isOnline(), events, @@ -524,7 +529,6 @@ function main(): void { const serverWindow = createServerWindow({ config, defaultOrigin: DEFAULT_ORIGIN, - pagePath: SERVER_PAGE, preloadPath, isPackaged: app.isPackaged, getParentWindow: getMainWindow, @@ -754,7 +758,7 @@ function main(): void { appOrigin, allowHttpLocalhost, accountDataAvailable, - localPagePaths: [resolve(OFFLINE_PAGE), resolve(SERVER_PAGE)], + isLocalPageUrl, scopeEvents, retryLoad: (sender) => { const win = windowForContents(sender) @@ -894,6 +898,10 @@ if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } +// The scheme the offline page and server picker load from must be declared +// before the app is ready; the per-session handlers attach later. +registerLocalPageScheme() + // Capture native minidumps for main/renderer/GPU crashes. Local-only: there is // no crash-ingest backend, so nothing is uploaded — the dumps land under // userData/Crashpad and the event log records where. Must start before the app diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index b75537b14c6..519c9a9cf46 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -133,6 +133,7 @@ import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' +import { isLocalPageUrl } from '@/main/local-pages' import { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' @@ -224,14 +225,14 @@ function trackedSender() { } const rejectedSender = () => trackedSender().sender -const fileSender = rejectedSender() +const localPageSender = rejectedSender() const appSender = rejectedSender() const evilSender = rejectedSender() const activeSender = trackedSender() const activeChooserSender = trackedSender() -const fileEvent = { - senderFrame: { url: 'file:///app/static/offline.html' }, - sender: fileSender, +const localPageEvent = { + senderFrame: { url: 'sim-shell://pages/offline.html?kind=dns&detail=probe' }, + sender: localPageSender, } const appEvent = { senderFrame: { url: `${APP}/workspace/ws1` }, sender: appSender } const activeAppEvent = { @@ -246,7 +247,7 @@ const inactiveAppEvent = { const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } const arbitraryFileEvent = { senderFrame: { url: 'file:///Users/example/private.html' }, - sender: fileSender, + sender: localPageSender, } /** The chooser anchors a native menu, so it needs a sender with a window. */ const FAKE_WINDOW = { id: 'main-window' } @@ -288,7 +289,7 @@ describe('registerIpcHandlers', () => { appOrigin: () => APP, allowHttpLocalhost: () => false, accountDataAvailable: () => true, - localPagePaths: ['/app/static/offline.html', '/app/static/server.html'], + isLocalPageUrl, retryLoad: vi.fn(), beginOAuthConnect: vi.fn(async () => true), localFilesystem: new LocalFilesystemService({ @@ -402,7 +403,7 @@ describe('registerIpcHandlers', () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') expect(await handler?.(evilEvent, 'slack')).toBe(false) - expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(await handler?.(localPageEvent, 'slack')).toBe(false) expect(await handler?.(appEvent, 'slack')).toBe(false) expect(deps.beginOAuthConnect).not.toHaveBeenCalled() expect(await handler?.(activeAppEvent, 42)).toBe(false) @@ -681,8 +682,8 @@ describe('registerIpcHandlers', () => { expect(deps.retryLoad).not.toHaveBeenCalled() on.get('offline:retry')?.(arbitraryFileEvent) expect(deps.retryLoad).not.toHaveBeenCalled() - on.get('offline:retry')?.(fileEvent) - expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) + on.get('offline:retry')?.(localPageEvent) + expect(deps.retryLoad).toHaveBeenCalledWith(localPageSender) }) it('registers every channel the preload bridge invokes or sends', () => { @@ -738,7 +739,7 @@ describe('registerIpcHandlers', () => { ok: false, error: expect.stringContaining('not allowed'), }) - expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ + expect(await handler?.(localPageEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ ok: false, }) expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {}, 'chat-1')).toMatchObject({ @@ -1648,7 +1649,7 @@ describe('registerIpcHandlers', () => { const handler = invoke.get('browser-import:list-profiles') expect(await handler?.(evilEvent)).toEqual([]) - expect(await handler?.(fileEvent)).toEqual([]) + expect(await handler?.(localPageEvent)).toEqual([]) expect(listChromeImportProfiles).not.toHaveBeenCalled() expect(await handler?.(appEvent)).toEqual([{ id: 'Default', label: 'Person 1' }]) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 3befddec165..c4f9fd51662 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,5 +1,3 @@ -import { normalize } from 'node:path' -import { fileURLToPath } from 'node:url' import { BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS, type BrowserPanelAction, @@ -332,8 +330,8 @@ export interface IpcDeps { allowHttpLocalhost: () => boolean /** False while local account-data persistence is unavailable or teardown must be retried. */ accountDataAvailable: () => boolean - /** Absolute paths of the bundled recovery pages allowed to control the shell. */ - localPagePaths: readonly string[] + /** Whether a frame URL is one of the bundled pages allowed to control the shell. */ + isLocalPageUrl: (url: string) => boolean retryLoad: (sender: WebContents) => void localFilesystem: LocalFilesystemService terminal: TerminalRegistry @@ -383,7 +381,8 @@ export interface IpcDeps { /** * Who may call a channel: * - `app-origin`: only the remote app origin (main window pages). - * - `local-page`: only bundled `file:` pages (offline) — shell control. + * - `local-page`: only the bundled pages served from the shell's own scheme + * (offline, server) — shell control. * - `browser-page`: only the built-in browser's own tabs, identified by * WebContents rather than by URL. These carry reports from the browser * preload about untrusted pages, so they are the one inbound surface whose @@ -435,16 +434,9 @@ type ChannelSpec = function isLocalPageSender( event: IpcMainEvent | IpcMainInvokeEvent, - localPagePaths: readonly string[] + isLocalPageUrl: (url: string) => boolean ): boolean { - try { - const url = new URL(event.senderFrame?.url ?? '') - if (url.protocol !== 'file:') return false - const senderPath = normalize(fileURLToPath(url)) - return localPagePaths.some((allowedPath) => senderPath === normalize(allowedPath)) - } catch { - return false - } + return isLocalPageUrl(event.senderFrame?.url ?? '') } /** @@ -1929,7 +1921,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (gate === 'any') return true if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) if (gate === 'browser-page') return isAgentWebContents(event.sender) - return isLocalPageSender(event, deps.localPagePaths) + return isLocalPageSender(event, deps.isLocalPageUrl) } const featureAllowed = (feature: ChannelFeature | undefined): boolean => { diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts index e313d0a6533..d306809932e 100644 --- a/apps/desktop/src/main/load-health.test.ts +++ b/apps/desktop/src/main/load-health.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { classifyLoadError } from '@/main/load-health' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachLoadHealth, classifyLoadError } from '@/main/load-health' +import { BrowserWindow as MockBrowserWindow } from '@/test/electron-mock' describe('classifyLoadError', () => { it('ignores aborted navigations (OAuth redirects abort constantly)', () => { @@ -32,3 +33,77 @@ describe('classifyLoadError', () => { expect(classifyLoadError(-324)).toBe('unreachable') }) }) + +vi.mock('electron', () => import('@/test/electron-mock')) + +describe('attachLoadHealth', () => { + function setup() { + vi.useFakeTimers() + const win = new MockBrowserWindow() + const events = { record: vi.fn() } + attachLoadHealth(win as never, { + offlinePageUrl: ({ kind, detail }) => + `sim-shell://pages/offline.html?kind=${kind}&detail=${encodeURIComponent(detail)}`, + getStartUrl: () => 'https://sim.example.com/workspace', + isOnline: () => true, + events: events as never, + }) + const failLoad = (errorCode: number, description: string, url: string) => { + const handler = win.webContents.on.mock.calls.find(([name]) => name === 'did-fail-load')?.[1] + if (!handler) throw new Error('no did-fail-load handler') + ;(handler as (...args: unknown[]) => void)({}, errorCode, description, url, true) + } + return { win, events, failLoad } + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('swaps a failed origin load for the bundled offline page', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + + expect(win.loadURL).toHaveBeenCalledWith( + 'sim-shell://pages/offline.html?kind=dns&detail=ERR_NAME_NOT_RESOLVED%20(-105)' + ) + expect(events.record).toHaveBeenCalledWith('load_failure', { + kind: 'dns', + detail: 'ERR_NAME_NOT_RESOLVED (-105)', + }) + }) + + // A packaged build once failed to load the offline page itself and re-showed + // it on every failure; that must stop at the first one. + it('does not loop when the offline page itself fails to load', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + failLoad(-6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/offline.html?kind=dns') + + expect(win.loadURL).toHaveBeenCalledTimes(1) + expect(events.record).toHaveBeenCalledTimes(1) + }) + + // Stopping the retry instead would strand the window blank until a relaunch. + // The origin keeps being retried on the usual cadence; only the broken + // bundled page is never navigated to again. + it('keeps retrying the origin after the offline page broke, without reloading it', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + failLoad(-6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/offline.html?kind=dns') + vi.advanceTimersByTime(5000) + + expect(win.loadURL).toHaveBeenCalledTimes(2) + expect(win.loadURL).toHaveBeenLastCalledWith('https://sim.example.com/workspace') + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + vi.advanceTimersByTime(5000) + + expect(events.record).toHaveBeenCalledTimes(2) + expect(win.loadURL).toHaveBeenCalledTimes(3) + expect(win.loadURL).toHaveBeenLastCalledWith('https://sim.example.com/workspace') + }) +}) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts index ab8b9d4c141..de71f85ecb9 100644 --- a/apps/desktop/src/main/load-health.ts +++ b/apps/desktop/src/main/load-health.ts @@ -35,7 +35,8 @@ export function classifyLoadError(errorCode: number): LoadErrorKind { } export interface LoadHealthDeps { - offlinePagePath: string + /** URL of the bundled offline page, carrying the failure it should explain. */ + offlinePageUrl: (query: { kind: LoadErrorKind; detail: string }) => string getStartUrl: () => string isOnline: () => boolean events: EventRecorder @@ -48,13 +49,14 @@ export interface LoadHealthHandle { /** * Branded recovery for a fully remote renderer: on main-frame load failures - * the window swaps to the bundled offline page (a local file, never wrapping - * the origin), auto-retries when the network returns, and a first-paint + * the window swaps to the bundled offline page (served from the shell's own + * scheme, never wrapping the origin), auto-retries when the network returns, and a first-paint * watchdog catches servers that accept connections but never respond. */ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): LoadHealthHandle { let intendedUrl: string | null = null let showingOffline = false + let offlinePageBroken = false let retryTimer: NodeJS.Timeout | undefined let watchdogTimer: NodeJS.Timeout | undefined @@ -107,7 +109,12 @@ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): Load } showingOffline = true deps.events.record('load_failure', { kind, detail }) - void win.loadFile(deps.offlinePagePath, { query: { kind, detail } }) + // A bundled page that failed once fails for a packaging reason, not a + // transient one, so it is never navigated to again. The origin retry stays + // armed regardless: it is the only way the window recovers on its own. + if (!offlinePageBroken) { + void win.loadURL(deps.offlinePageUrl({ kind, detail })) + } startAutoRetry() } @@ -122,6 +129,18 @@ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): Load if (kind === 'ignored') { return } + // Only the origin is retried through the offline page. If the bundled + // page itself failed there is nothing left to swap to, and showing it + // again would loop. + if (showingOffline && !validatedURL?.startsWith('http')) { + offlinePageBroken = true + logger.error('Bundled offline page failed to load', { + errorCode, + errorDescription, + url: scrubUrl(validatedURL ?? ''), + }) + return + } if (validatedURL?.startsWith('http')) { intendedUrl = validatedURL } diff --git a/apps/desktop/src/main/local-pages.test.ts b/apps/desktop/src/main/local-pages.test.ts new file mode 100644 index 00000000000..a425b2f6235 --- /dev/null +++ b/apps/desktop/src/main/local-pages.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Session } from 'electron' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + attachLocalPageProtocol, + createLocalPageHandler, + isLocalPageUrl, + LOCAL_PAGE_ORIGIN, + localPageUrl, +} from '@/main/local-pages' + +describe('localPageUrl', () => { + it('addresses the bundled pages on the shell scheme', () => { + expect(localPageUrl('server.html')).toBe('sim-shell://pages/server.html') + expect(localPageUrl('offline.html')).toBe('sim-shell://pages/offline.html') + }) + + it('encodes the query it is given', () => { + const url = new URL( + localPageUrl('offline.html', { kind: 'dns', detail: 'ERR_NAME_NOT_RESOLVED (-105)' }) + ) + // Node's URL parser has no notion of Chromium's `standard` privilege, so + // `origin` serialises as "null" here; scheme, host and path are what count. + expect(url.protocol).toBe('sim-shell:') + expect(url.host).toBe('pages') + expect(url.pathname).toBe('/offline.html') + expect(url.searchParams.get('kind')).toBe('dns') + expect(url.searchParams.get('detail')).toBe('ERR_NAME_NOT_RESOLVED (-105)') + }) +}) + +describe('isLocalPageUrl', () => { + it('accepts the bundled pages with or without a query', () => { + expect(isLocalPageUrl('sim-shell://pages/offline.html')).toBe(true) + expect(isLocalPageUrl('sim-shell://pages/offline.html?kind=dns&detail=x')).toBe(true) + expect(isLocalPageUrl('sim-shell://pages/server.html')).toBe(true) + }) + + // The IPC gate for shell control runs on this: a page the server serves, a + // stray file, or a bundled asset that is not a page must all be refused. + it('rejects every other scheme, host, and path', () => { + for (const url of [ + 'file:///app/static/offline.html', + 'https://www.sim.ai/offline.html', + 'sim-shell://evil/offline.html', + 'sim-shell://pages/SeasonSansUprightsVF.woff2', + 'sim-shell://pages/static/offline.html', + 'sim-shell://pages/', + 'not a url', + '', + ]) { + expect(isLocalPageUrl(url), url).toBe(false) + } + }) +}) + +describe('createLocalPageHandler', () => { + let root: string + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'sim-local-pages-')) + writeFileSync(join(root, 'offline.html'), '

offline

') + writeFileSync(join(root, 'secret.txt'), 'nope') + }) + + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('serves allowlisted files with their content type', async () => { + const response = await createLocalPageHandler([root])( + new Request(`${LOCAL_PAGE_ORIGIN}/offline.html?kind=dns`) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(await response.text()).toBe('

offline

') + }) + + it('refuses everything outside the allowlist, however the path is spelled', async () => { + const handler = createLocalPageHandler([root]) + for (const path of [ + '/secret.txt', + '/../secret.txt', + '/%2e%2e/secret.txt', + '/static/offline.html', + '/', + ]) { + const response = await handler(new Request(`${LOCAL_PAGE_ORIGIN}${path}`)) + expect(response.status, path).toBe(404) + } + }) + + it('refuses a foreign host and non-GET methods', async () => { + const handler = createLocalPageHandler([root]) + + expect((await handler(new Request('sim-shell://evil/offline.html'))).status).toBe(404) + expect( + (await handler(new Request(`${LOCAL_PAGE_ORIGIN}/offline.html`, { method: 'POST' }))).status + ).toBe(405) + }) + + it('answers 404 for an allowlisted file that is missing on disk', async () => { + const response = await createLocalPageHandler([root])( + new Request(`${LOCAL_PAGE_ORIGIN}/server.html`) + ) + + expect(response.status).toBe(404) + }) + + // Unpackaged runs read the brand font from the web app's public fonts rather + // than a generated copy in static/, so roots are consulted in order. + it('falls through to a later root for an asset the first one lacks', async () => { + const fonts = mkdtempSync(join(tmpdir(), 'sim-local-pages-fonts-')) + writeFileSync(join(fonts, 'SeasonSansUprightsVF.woff2'), 'woff2-bytes') + try { + const handler = createLocalPageHandler([root, fonts]) + + const font = await handler(new Request(`${LOCAL_PAGE_ORIGIN}/SeasonSansUprightsVF.woff2`)) + expect(font.status).toBe(200) + expect(font.headers.get('content-type')).toBe('font/woff2') + expect(await font.text()).toBe('woff2-bytes') + + const page = await handler(new Request(`${LOCAL_PAGE_ORIGIN}/offline.html`)) + expect(await page.text()).toBe('

offline

') + } finally { + rmSync(fonts, { recursive: true, force: true }) + } + }) +}) + +describe('attachLocalPageProtocol', () => { + it('installs one handler per session', () => { + const ses = { + protocol: { isProtocolHandled: vi.fn(() => false), handle: vi.fn() }, + } + + attachLocalPageProtocol(ses as unknown as Session, ['/tmp/static']) + expect(ses.protocol.handle).toHaveBeenCalledWith('sim-shell', expect.any(Function)) + + ses.protocol.isProtocolHandled.mockReturnValue(true) + attachLocalPageProtocol(ses as unknown as Session, ['/tmp/static']) + expect(ses.protocol.handle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/local-pages.ts b/apps/desktop/src/main/local-pages.ts new file mode 100644 index 00000000000..1446b6517f0 --- /dev/null +++ b/apps/desktop/src/main/local-pages.ts @@ -0,0 +1,181 @@ +import { readFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { Session } from 'electron' +import { app, protocol } from 'electron' + +const logger = createLogger('DesktopLocalPages') + +/** + * The scheme the shell serves its bundled pages from. + * + * Not `file:`. The packaged app disables the `grantFileProtocolExtraPrivileges` + * fuse, and with it Electron stops routing `file:` navigations through its + * asar-aware loader: Chromium looks for a real path inside `app.asar`, fails + * with ERR_FILE_NOT_FOUND, and the window shows nothing but its background + * colour. Unpackaged runs never see this — no asar, default fuses — which is + * how the offline page and the server picker shipped blank. A privileged custom + * scheme is what Electron's fuse documentation asks for instead: the main + * process reads the files itself, where Node's asar support applies. + * `standard` gives the pages a real origin (`sim-shell://pages`), so `'self'` + * in their CSP resolves the bundled font. + */ +export const LOCAL_PAGE_SCHEME = 'sim-shell' +const LOCAL_PAGE_HOST = 'pages' +export const LOCAL_PAGE_ORIGIN = `${LOCAL_PAGE_SCHEME}://${LOCAL_PAGE_HOST}` + +export type LocalPage = 'offline.html' | 'server.html' + +const LOCAL_PAGES: ReadonlySet = new Set(['offline.html', 'server.html']) + +/** + * Every file the scheme serves. An exact-name allowlist rather than a + * directory walk: nothing outside it can be requested however the path is + * spelled, and adding an asset is a deliberate one-line change. + */ +const SERVABLE_FILES: ReadonlySet = new Set([...LOCAL_PAGES, 'SeasonSansUprightsVF.woff2']) + +const CONTENT_TYPES: Readonly> = { + '.html': 'text/html; charset=utf-8', + '.woff2': 'font/woff2', +} + +/** Builds the URL of a bundled page, with its query encoded. */ +export function localPageUrl(page: LocalPage, query?: Readonly>): string { + const url = new URL(`${LOCAL_PAGE_ORIGIN}/${page}`) + for (const [key, value] of Object.entries(query ?? {})) { + url.searchParams.set(key, value) + } + return url.toString() +} + +/** + * Whether a frame URL is one of the bundled pages. The IPC gate for shell + * control runs on this, so scheme, host, and path must match exactly; only + * the query is ignored. + */ +export function isLocalPageUrl(raw: string): boolean { + let url: URL + try { + url = new URL(raw) + } catch { + return false + } + if (url.protocol !== `${LOCAL_PAGE_SCHEME}:` || url.host !== LOCAL_PAGE_HOST) { + return false + } + return LOCAL_PAGES.has(url.pathname.slice(1)) +} + +/** + * Declares the scheme's privileges. Electron requires this before the app is + * ready, so it runs at module load in `index.ts`. + */ +export function registerLocalPageScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: LOCAL_PAGE_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: false, + corsEnabled: false, + allowServiceWorkers: false, + bypassCSP: false, + stream: false, + }, + }, + ]) +} + +function notFound(): Response { + return new Response(null, { status: 404 }) +} + +/** + * Serves allowlisted files from the first of `rootDirs` that has them. Split + * from the session wiring so tests can drive it against temporary directories. + */ +export function createLocalPageHandler( + rootDirs: readonly string[] +): (request: Request) => Promise { + return async (request) => { + if (request.method !== 'GET') { + return new Response(null, { status: 405 }) + } + let name: string + try { + const url = new URL(request.url) + if (url.host !== LOCAL_PAGE_HOST) { + return notFound() + } + name = decodeURIComponent(url.pathname).replace(/^\/+/, '') + } catch { + return notFound() + } + if (!SERVABLE_FILES.has(name)) { + return notFound() + } + const file = await readFirst(rootDirs, name) + if (!file) { + return notFound() + } + // A copy into a plain ArrayBuffer: Response bodies take BufferSource, and + // a Node Buffer's backing store is not typed as one. + const body = new Uint8Array(file.byteLength) + body.set(file) + return new Response(body.buffer, { + status: 200, + headers: { + 'Content-Type': CONTENT_TYPES[extname(name)] ?? 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } +} + +async function readFirst(rootDirs: readonly string[], name: string): Promise { + for (const rootDir of rootDirs) { + try { + return await readFile(join(rootDir, name)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Could not read a bundled page asset', { name, error: getErrorMessage(error) }) + return null + } + } + } + logger.error('Bundled page asset is missing', { name }) + return null +} + +/** + * Where the pages and their assets live. `__dirname` is `dist/` in every + * build, so `static/` resolves inside the packaged asar as well as in an + * unpackaged checkout. The brand font is copied into `static/` only when + * packaging (electron-builder.yml); an unpackaged run reads it from the web + * app's public fonts instead, so nothing generated has to exist in the tree + * and a cached build restores everything the pages need. + */ +function localPageRoots(): string[] { + const roots = [join(__dirname, '..', 'static')] + if (!app.isPackaged) { + roots.push(join(__dirname, '..', '..', 'sim', 'public', 'brand', 'fonts')) + } + return roots +} + +/** + * Serves the scheme on a session. Handlers are per session, so every partition + * that hosts a bundled page installs one; repeat calls are no-ops. + */ +export function attachLocalPageProtocol( + ses: Session, + rootDirs: readonly string[] = localPageRoots() +): void { + if (ses.protocol.isProtocolHandled(LOCAL_PAGE_SCHEME)) { + return + } + ses.protocol.handle(LOCAL_PAGE_SCHEME, createLocalPageHandler(rootDirs)) +} diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts index 2ee1b41571a..c6936234413 100644 --- a/apps/desktop/src/main/server-window.test.ts +++ b/apps/desktop/src/main/server-window.test.ts @@ -4,6 +4,7 @@ vi.mock('electron', () => import('@/test/electron-mock')) import type { ConfigStore, OriginValidation } from '@/main/config' import { createServerWindow, type ServerWindowDeps } from '@/main/server-window' +import { dialog, BrowserWindow as MockBrowserWindow, session } from '@/test/electron-mock' const CURRENT = 'https://sim.example.com' const DEFAULT = 'https://www.sim.ai' @@ -31,7 +32,6 @@ function makeDeps(overrides: Partial = {}): ServerWindowDeps { raw.startsWith('https://') ? { ok: true, origin: raw } : { ok: false, error: 'bad origin' } ), defaultOrigin: DEFAULT, - pagePath: 'static/server.html', preloadPath: '/tmp/preload.cjs', isPackaged: false, getParentWindow: () => null, @@ -43,11 +43,71 @@ function makeDeps(overrides: Partial = {}): ServerWindowDeps { } } +type WebContentsHandler = (...args: unknown[]) => void + +function openPicker(deps: ServerWindowDeps) { + const ses = { + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + protocol: { isProtocolHandled: vi.fn(() => false), handle: vi.fn() }, + } + vi.mocked(session.fromPartition).mockReturnValue(ses as never) + createServerWindow(deps).open() + const win = MockBrowserWindow.instances.at(-1) + if (!win) throw new Error('no window was created') + const handler = (name: string): WebContentsHandler => { + const found = win.webContents.on.mock.calls.find(([event]) => event === name)?.[1] + if (!found) throw new Error(`no ${name} handler`) + return found as WebContentsHandler + } + return { win, ses, handler } +} + describe('server window', () => { let deps: ServerWindowDeps beforeEach(() => { deps = makeDeps() + MockBrowserWindow.instances = [] + vi.mocked(dialog.showMessageBox).mockClear() + }) + + // The page ships inside app.asar. Loaded over `file:` it never rendered in a + // packaged build (the file-protocol fuse is off), which is the blank sheet + // this window used to open as. + it('loads the picker over the shell scheme and serves it on its own partition', () => { + const { win, ses } = openPicker(deps) + + expect(win.loadURL).toHaveBeenCalledWith('sim-shell://pages/server.html') + expect(ses.protocol.handle).toHaveBeenCalledWith('sim-shell', expect.any(Function)) + expect(MockBrowserWindow.lastOptions).toMatchObject({ + webPreferences: expect.objectContaining({ partition: 'server-selection' }), + }) + }) + + it('closes on Escape without needing the page', () => { + const { win, handler } = openPicker(deps) + const event = { preventDefault: vi.fn() } + + handler('before-input-event')(event, { type: 'keyDown', key: 'a' }) + expect(win.destroy).not.toHaveBeenCalled() + + handler('before-input-event')(event, { type: 'keyDown', key: 'Escape' }) + expect(win.destroy).toHaveBeenCalledTimes(1) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + }) + + it('never leaves a blank sheet when the page fails to load', () => { + const { win, handler } = openPicker(deps) + + handler('did-fail-load')({}, -6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/server.html', false) + expect(win.destroy).not.toHaveBeenCalled() + handler('did-fail-load')({}, -3, 'ERR_ABORTED', 'sim-shell://pages/server.html', true) + expect(win.destroy).not.toHaveBeenCalled() + + handler('did-fail-load')({}, -6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/server.html', true) + expect(win.destroy).toHaveBeenCalledTimes(1) + expect(dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('reports the configured origin alongside the build default', () => { diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts index 193f73dd630..db4685b9e9f 100644 --- a/apps/desktop/src/main/server-window.ts +++ b/apps/desktop/src/main/server-window.ts @@ -1,9 +1,10 @@ import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { app, BrowserWindow, nativeTheme, session } from 'electron' +import { app, BrowserWindow, dialog, nativeTheme, session } from 'electron' import type { ConfigStore, DesktopSettings } from '@/main/config' import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config' +import { attachLocalPageProtocol, localPageUrl } from '@/main/local-pages' import { backgroundColorFor, createSecureWebPreferences, @@ -21,8 +22,8 @@ const WINDOW_HEIGHT = 340 * Deliberately NOT the app session's partition. This window exists to move the * shell between deployments, so binding it to the partition of the deployment * being left would tie the escape hatch to the state it is escaping — and the - * page is a bundled `file:` document that stores nothing, so it has no reason - * to touch a persistent jar at all. + * page ships with the shell and stores nothing, so it has no reason to touch a + * persistent jar at all. */ const SERVER_WINDOW_PARTITION = 'server-selection' @@ -48,8 +49,6 @@ const ORIGIN_SCOPED_SETTINGS: readonly (keyof DesktopSettings)[] = [ export interface ServerWindowDeps { config: ConfigStore defaultOrigin: string - /** The bundled page to load, resolved by the caller like the offline page. */ - pagePath: string preloadPath: string isPackaged: boolean getParentWindow: () => BrowserWindow | null @@ -100,7 +99,7 @@ export interface ServerWindowHandle { * the origin being changed. Someone whose stored origin is unreachable — a * typo, a VPN-only host, an instance that moved — can never reach an in-app * settings route to fix it, which is exactly when they need this most. The - * same reasoning gates its IPC channels to bundled `file:` senders. + * same reasoning gates its IPC channels to the bundled pages' own scheme. */ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { let win: BrowserWindow | null = null @@ -136,7 +135,9 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { // Electron decides for itself what a page may ask the OS for. The page here // asks for nothing, and a foreign origin can never load in this window, so // the shared handler resolves to a deny-all — which is the intent. - setupPermissionHandlers(session.fromPartition(SERVER_WINDOW_PARTITION), deps.config.getOrigin) + const ses = session.fromPartition(SERVER_WINDOW_PARTITION) + setupPermissionHandlers(ses, deps.config.getOrigin) + attachLocalPageProtocol(ses) win = new BrowserWindow({ width: WINDOW_WIDTH, height: WINDOW_HEIGHT, @@ -169,7 +170,43 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { win.on('closed', () => { win = null }) - void win.loadFile(deps.pagePath).catch((error) => { + // A sheet has no title bar, and the page owns the only Cancel button. Both + // ways out must therefore work without the page: Escape is handled here, + // and a page that fails to load closes the window instead of leaving a + // blank sheet nothing can dismiss. + const opened = win + const closeOpened = () => { + if (!opened.isDestroyed()) { + opened.destroy() + } + if (win === opened) { + win = null + } + } + opened.webContents.on('before-input-event', (event, input) => { + if (input.type === 'keyDown' && input.key === 'Escape') { + event.preventDefault() + closeOpened() + } + }) + opened.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, _validatedURL, isMainFrame) => { + // -3 is ERR_ABORTED: a load this window cancelled, not a page that failed. + if (!isMainFrame || errorCode === -3) return + logger.error('Server window page failed to load', { errorCode, errorDescription }) + closeOpened() + const options = { + type: 'error' as const, + message: 'Couldn’t open the server settings', + detail: 'Sim could not load its server settings page. Restart Sim and try again.', + } + void (parent && !parent.isDestroyed() + ? dialog.showMessageBox(parent, options) + : dialog.showMessageBox(options)) + } + ) + void opened.loadURL(localPageUrl('server.html')).catch((error) => { logger.error('Could not open the server window', { error: getErrorMessage(error) }) }) } diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 2c27d51322f..4c2b69eb4b2 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -95,6 +95,12 @@ export const session = { fromPartition: vi.fn(), } +export const protocol = { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + isProtocolHandled: vi.fn(() => false), +} + export const ipcMain = { on: vi.fn(), handle: vi.fn(), diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html index 47ae64e3533..87313fe2cff 100644 --- a/apps/desktop/static/offline.html +++ b/apps/desktop/static/offline.html @@ -10,9 +10,7 @@