Skip to content

Commit c5cc6ce

Browse files
feat(chat): hide the Chat module when NEXT_PUBLIC_CHAT_DISABLED is set (#6137)
* feat(chat): hide the Chat module when CHAT_ENABLED is unset A self-hosted deployment that skipped the chat key still rendered the full mothership Chat UI, landing on the composer and 401ing on every message. Gate it behind a CHAT_ENABLED / NEXT_PUBLIC_CHAT_ENABLED twin, written by the setup wizard alongside COPILOT_API_KEY and validated by the existing FLAG_TWINS doctor check. The flag resolves at module scope on both render passes, so no chat surface renders then disappears. With Chat off the workspace lands on its first workflow (resolved server-side, behind the cached host-context check so no workflow id leaks to non-members), and the chats list, scheduled tasks, editor Chat panel, and chat CTAs are absent. Routes are gated rather than deleted: /home redirects because it is baked into delivered invitation emails and the accept contract. Also fixes two bugs the gate exposed: a persisted activeTab of 'copilot' left the workflow panel blank from first paint, and the panel's handoff listener claimed MOTHERSHIP_SEND_MESSAGE events outside its own gate, silently swallowing "Fix in Chat" messages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * 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 * feat(setup): prompt for the chat key in k8s mode The dev and compose flows minted a chat key and wrote the Chat opt-out alongside it; k8s did neither, so a cluster install with no COPILOT_API_KEY in its Helm values rendered a Chat module that rejects every message. Prompt with the same flow and feed both values into `app.env`, which the chart already renders as arbitrary container env. Reading the previous release's key matters here in a way it does not for the file-based modes: `helm upgrade` without `--reuse-values` keeps only what this document carries, so a key the user elects to keep has to be re-supplied or it is silently dropped. Splits the release-values read from the secret-reuse check so both the key and the secrets come from one `helm get values` call, and carries the mothership override across for the same mint-here-validate-there reason the other modes document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(setup): write app-behavior flags to every env file the app can start from The wizard wrote the Chat opt-out only to the env file its own mode owns, so choosing compose put it in the root `.env` while `bun run dev` reads `apps/sim/.env` and never saw it. Skipping the chat key appeared to do nothing. Mirror values that change how the app behaves — as opposed to where it connects — across both targets. Connection settings deliberately do not go through this: DATABASE_URL and friends differ between the compose stack and a local dev run, which is why this takes an explicit set of values rather than the whole batch. The mirrored file is written even when absent, since missing is exactly the case that stranded the flag, but with seeding suppressed so a compose run leaves a one-line apps/sim/.env instead of a full .env.example for a stack the user is not running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(compose): forward NEXT_PUBLIC_CHAT_DISABLED to the app container The wizard wrote the flag into the root .env, but compose only passes through variables the service's `environment` block names — and that block listed COPILOT_API_KEY without its companion. Skipping the chat key on a Docker install therefore did nothing: the value sat in .env and never reached the container. Add the passthrough to all four compose files. Reverts the previous commit's mirroring into apps/sim/.env, which treated the symptom — each mode writes only the env file it owns, and that file is now wired correctly. k8s needs no equivalent: its values flow into `app.env`, which the chart renders key by key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(chat): resolve the landing route without blocking on the database Server-resolving the first workflow meant a session lookup, an access check and a query had to finish before anything rendered. A slow or unreachable database left the user on a blank page under a populated sidebar — worse than the instant redirect it replaced, and with no signal that anything was wrong. Redirect straight to `/w` instead and let it pick from the workflow list the layout already prefetches, so the choice costs no round trip and cannot hang. Repoints the sidebar's primary action rather than hiding it: the slot that offered "New chat" now offers "New workflow" and creates one, since with Chat off there is no composer to open but the intent is the same. Sends the CLI key handoff to signup rather than login. It is reached from a terminal — usually the setup wizard standing up a fresh self-host — where the visitor has no account yet. Both auth pages cross-link carrying the callback, so a returning user is one click from login with their destination intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * improvement(chat): address cleanup-pass findings on the Chat gate Effects: the panel's auto-select effect read the copilot chat list while the list query was deliberately skipped, took "empty" for "deleted in another tab", and cleared the user's selection — latching a ref that stopped it ever being restored. Guarded on the same condition as the handoff listener. Memo: `/w` filtered workflows through a useMemo whose array dependency was a fresh `[]` on every render while the query had no data — the exact window the page exists for — so it memoized nothing and re-fired the redirect effect. Keyed on the workflow id instead. Same unstable-default problem on the sidebar's chat list, where it invalidated five downstream memos; given a stable empty constant. Callback: `handleCreateWorkflow` listed the whole mutation object in its deps, which TanStack recreates every render. Harmless until this branch wired it into the top nav, where it defeated `memo(SidebarNavItem)`. React Query: Recently Deleted still fetched archived chats unconditionally and offered restores into routes that now 404. Also surfaces an error state on `/w` — it is the landing route now, so a failed list fetch would otherwise spin forever behind a log line — fixes a spinner using a token undefined in dark mode, and trims comments that restated code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(chat): gate workflow creation on write access, pin the key in schedule tests The zero-workflow landing offered "Create workflow" to every member. Creation navigates optimistically, so a read-only member was sent to a workflow the server had already refused to create, with the failure never surfaced. Gate both entry points — the empty state and the sidebar's "New workflow" row — on the same `canEdit` check the rest of the sidebar uses, and tell read-only members who can make one instead of offering an action that cannot succeed. The schedule-execution tests only passed locally because vitest loads the developer's own `.env`, which supplied COPILOT_API_KEY; CI has none, so the prompt-job claim guard skipped the claims those cases assert on. Pin the key through the env mock so the suite states its own preconditions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(setup): name both variables in the chat-key failure hint The caller writes the Chat opt-out whenever the prompt returns no key, so the hint's "or set COPILOT_API_KEY yourself" restored capability while leaving the module hidden — the one path where following setup's own advice does not work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0bcf64a commit c5cc6ce

54 files changed

Lines changed: 581 additions & 229 deletions

File tree

Some content is hidden

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

.devcontainer/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ services:
1919
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here}
2020
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here}
2121
- COPILOT_API_KEY=${COPILOT_API_KEY}
22+
- NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-}
2223
- SIM_AGENT_API_URL=${SIM_AGENT_API_URL}
2324
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
2425
- NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +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 copilot features |
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 |
6869
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
6970
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
7071
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |

apps/sim/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000
2121
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
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

24+
# Chat (Optional)
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
27+
2428
# Security (Required)
2529
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
2630
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes

apps/sim/app/api/mothership/events/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server'
1111
import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats'
1212
import { validationErrorResponse } from '@/lib/api/server'
1313
import { chatPubSub } from '@/lib/copilot/chat-status'
14+
import { isChatEnabled } from '@/lib/core/config/env-flags'
1415
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1516
import { createWorkspaceSSE } from '@/lib/events/sse-endpoint'
1617

@@ -37,6 +38,10 @@ const mothershipEventsHandler = createWorkspaceSSE({
3738
})
3839

3940
export const GET = withRouteHandler((request: NextRequest) => {
41+
// Closes streams held by tabs that were open when Chat was turned off; the
42+
// client hook already declines to open new ones.
43+
if (!isChatEnabled) return new Response(null, { status: 404 })
44+
4045
const validation = mothershipEventsQuerySchema.safeParse(
4146
Object.fromEntries(request.nextUrl.searchParams.entries())
4247
)

apps/sim/app/api/schedules/execute/route.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
requestUtilsMockFns,
1010
resetDbChainMock,
1111
resetEnvFlagsMock,
12+
resetEnvMock,
13+
setEnv,
1214
setEnvFlags,
1315
} from '@sim/testing'
1416
import { type NextRequest, NextResponse } from 'next/server'
@@ -275,7 +277,10 @@ function createMockRequest(): NextRequest {
275277
} as NextRequest
276278
}
277279

278-
afterAll(resetEnvFlagsMock)
280+
afterAll(() => {
281+
resetEnvFlagsMock()
282+
resetEnvMock()
283+
})
279284

280285
describe('Scheduled Workflow Execution API Route', () => {
281286
beforeEach(() => {
@@ -290,6 +295,9 @@ describe('Scheduled Workflow Execution API Route', () => {
290295
dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never)
291296
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id')
292297
setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true })
298+
// Prompt-job claims are skipped without the mothership credential; pin it so
299+
// these cases do not depend on whether the runner happens to have a .env.
300+
setEnv({ COPILOT_API_KEY: 'test-api-key' })
293301
mockShouldExecuteInline.mockReturnValue(false)
294302
mockEnqueue.mockReset()
295303
mockEnqueue.mockResolvedValue('job-id-1')

apps/sim/app/api/schedules/execute/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@/lib/billing/core/billing-attribution'
1818
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
1919
import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types'
20+
import { env } from '@/lib/core/config/env'
2021
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
2122
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2223
import { runDetached } from '@/lib/core/utils/background'
@@ -1245,7 +1246,18 @@ export async function runScheduleTick(requestId: string): Promise<ScheduleTickRe
12451246
let iterations = 0
12461247
let remainingWorkflowBudget = SCHEDULE_WORKFLOW_ENQUEUE_LIMIT
12471248
let schedulesExhausted = false
1248-
let jobsExhausted = false
1249+
/**
1250+
* Prompt jobs run through the mothership, so without a key every claim ends in
1251+
* a 401. Skipping the claim entirely leaves the rows `active` and resumable;
1252+
* claiming them would burn each one through `MAX_CONSECUTIVE_FAILURES` and
1253+
* permanently disable a schedule the user can no longer see, let alone stop.
1254+
* Keyed on the credential rather than `CHAT_ENABLED` so jobs keep running for
1255+
* a deployment that only hid the UI.
1256+
*/
1257+
let jobsExhausted = !env.COPILOT_API_KEY
1258+
if (jobsExhausted) {
1259+
logger.info(`[${requestId}] COPILOT_API_KEY not set, skipping prompt job claims`)
1260+
}
12491261

12501262
while (Date.now() - tickStart < MAX_TICK_DURATION_MS) {
12511263
if (schedulesExhausted && jobsExhausted) break

apps/sim/app/cli/auth/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@ export const dynamic = 'force-dynamic'
1919
/**
2020
* Browser half of the CLI key handoff.
2121
*
22-
* Signed-out visitors bounce through login carrying a *re-serialized*
22+
* Signed-out visitors bounce through signup carrying a *re-serialized*
2323
* `callbackUrl` — only the params the handoff understands survive, so the round
2424
* trip cannot be used to smuggle anything else back into this page. The request
2525
* is validated before that bounce: a bogus callback is rejected here rather
2626
* than after making the user sign in for nothing.
27+
*
28+
* Signup rather than login because this page is reached from a terminal: the
29+
* setup wizard sends people here while standing up a self-host, and someone
30+
* configuring Sim for the first time has no account yet. Both auth pages
31+
* cross-link carrying the same `callbackUrl`, so a returning user is one click
32+
* from login with their destination intact.
2733
*/
2834
export default async function CliAuthPage({
2935
searchParams,
@@ -43,7 +49,7 @@ export default async function CliAuthPage({
4349
challenge: resolution.request.challenge,
4450
pairing: resolution.request.pairing,
4551
})
46-
redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
52+
redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
4753
}
4854

4955
return (

apps/sim/app/layout.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { BrandedLayout } from '@/components/branded-layout'
66
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
77
import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
88
import '@/app/_styles/globals.css'
9-
import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/env-flags'
9+
import {
10+
isChatEnabled,
11+
isHosted,
12+
isReactGrabEnabled,
13+
isReactScanEnabled,
14+
} from '@/lib/core/config/env-flags'
1015
import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate'
1116
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
1217
import { QueryProvider } from '@/app/_shell/providers/query-provider'
@@ -150,6 +155,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
150155
}
151156
152157
var activeTab = panelState && panelState.activeTab;
158+
// A session that used the Chat tab before it was turned off still
159+
// has 'copilot' persisted; without this the CSS hides every tab
160+
// body and the panel paints empty.
161+
if (activeTab === 'copilot' && !${isChatEnabled}) {
162+
activeTab = 'toolbar';
163+
}
153164
if (activeTab) {
154165
document.documentElement.setAttribute('data-panel-active-tab', activeTab);
155166
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import { notFound } from 'next/navigation'
2+
import { isChatEnabled } from '@/lib/core/config/env-flags'
3+
14
export default function ChatLayout({ children }: { children: React.ReactNode }) {
5+
if (!isChatEnabled) notFound()
6+
27
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
38
}

apps/sim/app/workspace/[workspaceId]/home/layout.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
import { redirect } from 'next/navigation'
2+
import { isChatEnabled } from '@/lib/core/config/env-flags'
13
import { inter } from '@/app/_styles/fonts/inter/inter'
24

3-
export default function HomeLayout({ children }: { children: React.ReactNode }) {
5+
/**
6+
* Redirects rather than 404s when Chat is disabled: this path is baked into
7+
* already-delivered invitation emails and into the invitation-accept API
8+
* contract, so it has to keep resolving. `/workspace/{id}` re-resolves the
9+
* landing route server-side, so the visitor lands on a workflow instead.
10+
*/
11+
export default async function HomeLayout({
12+
children,
13+
params,
14+
}: {
15+
children: React.ReactNode
16+
params: Promise<{ workspaceId: string }>
17+
}) {
18+
if (!isChatEnabled) {
19+
const { workspaceId } = await params
20+
redirect(`/workspace/${workspaceId}`)
21+
}
22+
423
return (
524
<div className={`flex h-full flex-1 flex-col overflow-hidden ${inter.variable}`}>
625
{children}

0 commit comments

Comments
 (0)