Skip to content

Commit 9df71be

Browse files
refactor(chat): gate the UI on NEXT_PUBLIC_CHAT_DISABLED, not an opt-in flag
CHAT_ENABLED made Chat opt-in, so every existing deployment that already had COPILOT_API_KEY would have lost the module until it set a new variable. Invert to an opt-out so nothing changes for them. That also collapses the twin. The only reason the flag needed a server/client pair was that it projected a secret; NEXT_PUBLIC_CHAT_DISABLED is not one, so getEnv resolves the same value from process.env on the server and window.__ENV in the browser. Gone with it: the FLAG_TWINS entry and its doctor sync check, the two-variable wizard write, and the boot-time throw, whose contradiction (flag on, key absent) can no longer be expressed. Presentation and capability are now separate concerns. NEXT_PUBLIC_CHAT_DISABLED decides whether the surfaces render; COPILOT_API_KEY decides whether the work can run, and gates the paths that need it — the Sim Chat block, prompt-job claims, and inbox access — each failing on its own terms. The wizard writes the opt-out when you skip the chat key, which is the case this started from: a fresh self-host that never configured Chat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha
1 parent bc14b58 commit 9df71be

12 files changed

Lines changed: 54 additions & 73 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
6464
| Variable | Description |
6565
|----------|-------------|
6666
| `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` |
67-
| `COPILOT_API_KEY` | API key for Chat. Required whenever `CHAT_ENABLED` is set — the app refuses to start without it |
68-
| `CHAT_ENABLED` | Shows the Chat module. Leave unset and the workspace lands on your first workflow, with no chats list, scheduled tasks, editor Chat panel, or Sim Chat block |
69-
| `NEXT_PUBLIC_CHAT_ENABLED` | Browser twin of `CHAT_ENABLED`. Set both together (`bun run setup` does) |
67+
| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run |
68+
| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key |
7069
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
7170
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
7271
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |

apps/sim/.env.example

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,8 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
2222
# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients.
2323

2424
# Chat (Optional)
25-
# Leave these unset and the Chat module is hidden entirely: the workspace lands
26-
# on your first workflow, and the chats list, scheduled tasks, workflow-editor
27-
# Chat panel, and Sim Chat block are all absent. `bun run setup` writes all three
28-
# together; set them by hand only if you skip the wizard.
29-
# COPILOT_API_KEY= # Mint one at https://sim.ai — required whenever CHAT_ENABLED is true (the app refuses to boot otherwise)
30-
# CHAT_ENABLED=true # Server-side gate
31-
# NEXT_PUBLIC_CHAT_ENABLED=true # Browser twin — must match CHAT_ENABLED
25+
# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run
26+
# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key
3227

3328
# Security (Required)
3429
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables

apps/sim/bootstrap.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,6 @@
66
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
77

88
await loadRuntimeSecrets()
9-
10-
/**
11-
* Chat cannot reach the mothership without `COPILOT_API_KEY`, so serving the
12-
* module with the key missing yields a UI where every message 401s. Fail the
13-
* deploy instead. Mirrors `isTruthy` from `lib/core/config/env.ts`, inlined
14-
* because this file is bundled separately for the container entrypoint and must
15-
* not pull the Next-only env module into its graph.
16-
*/
17-
const chatEnabled = process.env.CHAT_ENABLED?.toLowerCase()
18-
if ((chatEnabled === 'true' || chatEnabled === '1') && !process.env.COPILOT_API_KEY) {
19-
throw new Error(
20-
'CHAT_ENABLED is set without COPILOT_API_KEY — Chat would render against a backend that rejects every request. Set COPILOT_API_KEY, or unset CHAT_ENABLED and NEXT_PUBLIC_CHAT_ENABLED.'
21-
)
22-
}
239
// `server.js` is the Next standalone build artifact, a sibling of this file in
2410
// the image; it does not exist at type-check time, so the specifier is held in a
2511
// variable to keep it out of static module resolution.

apps/sim/executor/handlers/mothership/mothership-handler.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import '@sim/testing/mocks/executor'
22

3-
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { resetEnvMock, setEnv } from '@sim/testing'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import { BlockType } from '@/executor/constants'
66
import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler'
@@ -115,8 +115,8 @@ describe('MothershipBlockHandler', () => {
115115
mockIsRedisCancellationEnabled.mockReset()
116116
mockIsRedisCancellationEnabled.mockReturnValue(false)
117117
mockReadUserFileContent.mockReset()
118-
// The handler refuses to run without Chat; the shared mock defaults it off.
119-
setEnvFlags({ isChatEnabled: true })
118+
// The handler refuses to run without the mothership credential.
119+
setEnv({ COPILOT_API_KEY: 'test-copilot-key' })
120120

121121
block = {
122122
id: 'mothership-block-1',
@@ -149,7 +149,7 @@ describe('MothershipBlockHandler', () => {
149149
vi.useRealTimers()
150150
vi.clearAllMocks()
151151
vi.unstubAllGlobals()
152-
resetEnvFlagsMock()
152+
resetEnvMock()
153153
})
154154

155155
function createNdjsonResponse(events: unknown[]): Response {
@@ -226,12 +226,12 @@ describe('MothershipBlockHandler', () => {
226226
})
227227
})
228228

229-
it('rejects execution before the internal request when Chat is disabled', async () => {
230-
setEnvFlags({ isChatEnabled: false })
229+
it('rejects execution before the internal request when COPILOT_API_KEY is unset', async () => {
230+
setEnv({ COPILOT_API_KEY: undefined })
231231

232232
await expect(
233233
handler.execute(context, block, { prompt: 'Hello from workflow' })
234-
).rejects.toThrow('Chat is disabled on this deployment')
234+
).rejects.toThrow('COPILOT_API_KEY is not configured')
235235
expect(fetchMock).not.toHaveBeenCalled()
236236
})
237237

apps/sim/executor/handlers/mothership/mothership-handler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
BILLING_ATTRIBUTION_HEADER,
66
serializeBillingAttributionHeader,
77
} from '@/lib/billing/core/billing-attribution'
8-
import { isChatEnabled } from '@/lib/core/config/env-flags'
8+
import { env } from '@/lib/core/config/env'
99
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
1010
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
1111
import {
@@ -338,10 +338,10 @@ export class MothershipBlockHandler implements BlockHandler {
338338
block: SerializedBlock,
339339
inputs: Record<string, any>
340340
): Promise<BlockOutput | StreamingExecution> {
341-
// Reaches the same backend as the Chat module, so with Chat off the request
342-
// can only come back 401. Fail with something the workflow author can act on.
343-
if (!isChatEnabled) {
344-
throw new Error('Chat is disabled on this deployment, so the Sim Chat block cannot run')
341+
// Without the key the mothership rejects every request, so fail with
342+
// something the workflow author can act on instead of a bare 401.
343+
if (!env.COPILOT_API_KEY) {
344+
throw new Error('COPILOT_API_KEY is not configured, so the Sim Chat block cannot run')
345345
}
346346

347347
const prompt = inputs.prompt

apps/sim/lib/billing/core/subscription.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import {
2424
hasUsableSubscriptionAccess,
2525
USABLE_SUBSCRIPTION_STATUSES,
2626
} from '@/lib/billing/subscriptions/utils'
27+
import { env } from '@/lib/core/config/env'
2728
import {
2829
isAccessControlEnabled,
2930
isBillingEnabled,
30-
isChatEnabled,
3131
isHosted,
3232
isInboxEnabled,
3333
isSandboxesEnabled,
@@ -623,10 +623,11 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise<boolean>
623623
* the workspace's organization, or its billed account for personal workspaces,
624624
* is on a Max or enterprise plan.
625625
*
626-
* Always false when Chat is disabled — inbox tasks run through the mothership
627-
* and answer with a link to the resulting chat, so neither half works without it.
628-
* That check comes first because the `!isBillingEnabled` shortcut below would
629-
* otherwise hand every self-hosted deployment a broken Inbox.
626+
* Always false without `COPILOT_API_KEY` — inbox tasks are executed by the
627+
* mothership and answered with a link to the resulting chat, so neither half
628+
* works without it. That check comes first because the `!isBillingEnabled`
629+
* shortcut below would otherwise hand every self-hosted deployment a broken
630+
* Inbox.
630631
*
631632
* Otherwise returns true if:
632633
* - INBOX_ENABLED env var is set (self-hosted override), OR
@@ -636,7 +637,7 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise<boolean>
636637
*/
637638
export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<boolean> {
638639
try {
639-
if (!isChatEnabled) return false
640+
if (!env.COPILOT_API_KEY) return false
640641
if (isInboxEnabled) return true
641642
if (!isBillingEnabled) return true
642643
return await hasMaxTierWorkspaceAccess(workspaceId)

apps/sim/lib/core/config/env-flags.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,22 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy(
6060
export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED)
6161

6262
/**
63-
* Is the Chat module exposed.
63+
* Are the Chat module's surfaces shown. On by default, so a deployment that
64+
* already has `COPILOT_API_KEY` keeps Chat without setting anything; the setup
65+
* wizard writes the opt-out when you skip the key.
6466
*
65-
* Server code reads `CHAT_ENABLED`; client evaluation reads the
66-
* `NEXT_PUBLIC_CHAT_ENABLED` twin via `window.__ENV`, so deployments must set
67-
* both together (the setup wizard writes the pair). Chat needs
68-
* `COPILOT_API_KEY` to reach the mothership at all — `bootstrap.ts` throws when
69-
* this flag is on without it, so the two can never disagree at runtime.
67+
* This governs presentation only. Whether Chat can actually reach the mothership
68+
* is a separate question answered by `COPILOT_API_KEY`, which gates the paths
69+
* that need it (the Sim Chat block, prompt-job claims, inbox execution). Keeping
70+
* them separate is what lets this be a single variable: the key is a secret and
71+
* could never be read in the browser, but `NEXT_PUBLIC_CHAT_DISABLED` is not, so
72+
* `getEnv` resolves the same value from `process.env` on the server and
73+
* `window.__ENV` on the client — no twin to keep in sync.
7074
*
7175
* Read at module scope or inline during render only. Resolving it through
7276
* `useState`/`useEffect` would render chat surfaces before removing them.
7377
*/
74-
export const isChatEnabled =
75-
typeof window === 'undefined'
76-
? isTruthy(env.CHAT_ENABLED)
77-
: isTruthy(getEnv('NEXT_PUBLIC_CHAT_ENABLED'))
78+
export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED'))
7879

7980
/**
8081
* Holds tools the catalog marks `requiresApproval` — shell commands, workflow
@@ -303,13 +304,12 @@ export const isOrganizationsEnabled =
303304

304305
/**
305306
* Is inbox (Sim Mailer) enabled
306-
*
307-
* Requires Chat: an inbound message is executed by the mothership and answered
308-
* with a link to the resulting chat, so with Chat off every inbox task fails and
309-
* mails the recipient a dead link.
310307
*/
311-
export const isInboxEnabled =
312-
enterpriseFeatureEnabled('inbox', env.INBOX_ENABLED, 'NEXT_PUBLIC_INBOX_ENABLED') && isChatEnabled
308+
export const isInboxEnabled = enterpriseFeatureEnabled(
309+
'inbox',
310+
env.INBOX_ENABLED,
311+
'NEXT_PUBLIC_INBOX_ENABLED'
312+
)
313313

314314
/**
315315
* Are custom sandboxes (workspace dependency sets for Function blocks) enabled.

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export const env = createEnv({
5151

5252
// Copilot
5353
COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication
54-
CHAT_ENABLED: z.boolean().optional(), // Expose the Chat module (requires COPILOT_API_KEY); set the NEXT_PUBLIC_ twin together
5554
/** Enables attributed-v1 only after compatible Copilot instances are deployed. */
5655
COPILOT_BILLING_ATTRIBUTION_V1_ENABLED: z.boolean().optional(),
5756
/** Rejects markerless old-Go billing traffic only when explicitly enabled. */
@@ -606,7 +605,7 @@ export const env = createEnv({
606605
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
607606
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
608607
NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted
609-
NEXT_PUBLIC_CHAT_ENABLED: z.boolean().optional(), // Client twin of CHAT_ENABLED — set both together. Read via getEnv(), never env.*
608+
NEXT_PUBLIC_CHAT_DISABLED: z.boolean().optional(), // Hide the Chat module. Not a secret, so it is read via getEnv() on both server and client
610609
NEXT_PUBLIC_SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted
611610
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
612611
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget
@@ -651,7 +650,7 @@ export const env = createEnv({
651650
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
652651
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
653652
NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED,
654-
NEXT_PUBLIC_CHAT_ENABLED: process.env.NEXT_PUBLIC_CHAT_ENABLED,
653+
NEXT_PUBLIC_CHAT_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED,
655654
NEXT_PUBLIC_SANDBOXES_ENABLED: process.env.NEXT_PUBLIC_SANDBOXES_ENABLED,
656655
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
657656
NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,

apps/sim/lib/invitations/core.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ describe('acceptInvitation', () => {
988988

989989
expect(result.success).toBe(true)
990990
if (result.success) {
991-
expect(result.redirectPath).toBe('/workspace/workspace-1/home')
991+
expect(result.redirectPath).toBe('/workspace/workspace-1')
992992
}
993993
expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith(
994994
expect.anything(),
@@ -1437,7 +1437,7 @@ describe('acceptInvitation', () => {
14371437

14381438
expect(result.success).toBe(true)
14391439
if (result.success) {
1440-
expect(result.redirectPath).toBe('/workspace/workspace-1/home')
1440+
expect(result.redirectPath).toBe('/workspace/workspace-1')
14411441
}
14421442
})
14431443

packages/testing/src/mocks/env-flags.mock.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { vi } from 'vitest'
33
/**
44
* Mutable value-export state for the shared `@/lib/core/config/env-flags` mock.
55
* Defaults mirror the real module evaluated under the vitest environment
6-
* (NODE_ENV=test, no feature env vars set): only `isTest` and
7-
* `isEmailPasswordEnabled` are true.
6+
* (NODE_ENV=test, no feature env vars set): only `isTest`,
7+
* `isEmailPasswordEnabled`, and `isChatEnabled` are true — the last because it
8+
* is an opt-out flag, on unless `NEXT_PUBLIC_CHAT_DISABLED` is set.
89
*/
910
export interface EnvFlagsMockState {
1011
isProd: boolean
@@ -59,7 +60,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = {
5960
isHosted: false,
6061
isCopilotBillingAttributionV1Enabled: false,
6162
isCopilotBillingProtocolRequired: false,
62-
isChatEnabled: false,
63+
isChatEnabled: true,
6364
isCopilotToolPermissionsEnabled: false,
6465
isBillingEnabled: false,
6566
isEmailVerificationEnabled: false,

0 commit comments

Comments
 (0)