Skip to content

Commit 0c6e430

Browse files
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
1 parent e5ae445 commit 0c6e430

45 files changed

Lines changed: 517 additions & 200 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.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ 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. 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) |
6870
| `ADMIN_API_KEY` | Admin API key for GitOps operations |
6971
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
7072
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |

apps/sim/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ 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+
# 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
32+
2433
# Security (Required)
2534
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
2635
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.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'
@@ -1212,7 +1213,18 @@ export async function runScheduleTick(requestId: string): Promise<ScheduleTickRe
12121213
let iterations = 0
12131214
let remainingWorkflowBudget = SCHEDULE_WORKFLOW_ENQUEUE_LIMIT
12141215
let schedulesExhausted = false
1215-
let jobsExhausted = false
1216+
/**
1217+
* Prompt jobs run through the mothership, so without a key every claim ends in
1218+
* a 401. Skipping the claim entirely leaves the rows `active` and resumable;
1219+
* claiming them would burn each one through `MAX_CONSECUTIVE_FAILURES` and
1220+
* permanently disable a schedule the user can no longer see, let alone stop.
1221+
* Keyed on the credential rather than `CHAT_ENABLED` so jobs keep running for
1222+
* a deployment that only hid the UI.
1223+
*/
1224+
let jobsExhausted = !env.COPILOT_API_KEY
1225+
if (jobsExhausted) {
1226+
logger.info(`[${requestId}] COPILOT_API_KEY not set, skipping prompt job claims`)
1227+
}
12161228

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

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 { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
1116
import { QueryProvider } from '@/app/_shell/providers/query-provider'
1217
import { SessionProvider } from '@/app/_shell/providers/session-provider'
@@ -131,6 +136,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
131136
}
132137
133138
var activeTab = panelState && panelState.activeTab;
139+
// A session that used the Chat tab before it was turned off still
140+
// has 'copilot' persisted; without this the CSS hides every tab
141+
// body and the panel paints empty.
142+
if (activeTab === 'copilot' && !${isChatEnabled}) {
143+
activeTab = 'toolbar';
144+
}
134145
if (activeTab) {
135146
document.documentElement.setAttribute('data-panel-active-tab', activeTab);
136147
}
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}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Suspense } from 'react'
22
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
33
import type { Metadata } from 'next'
4+
import { redirect } from 'next/navigation'
45
import { getSession } from '@/lib/auth'
6+
import { isChatEnabled } from '@/lib/core/config/env-flags'
57
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
68
import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch'
79
import { Home } from './home'
@@ -14,6 +16,12 @@ export const metadata: Metadata = {
1416
export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) {
1517
const { workspaceId } = await params
1618

19+
// The layout redirects too, but pages and layouts resolve concurrently — without
20+
// this the prefetch below still fires on its way out.
21+
if (!isChatEnabled) {
22+
redirect(`/workspace/${workspaceId}`)
23+
}
24+
1725
const queryClient = getQueryClient()
1826
const listsPrefetch = prefetchHomeLists(queryClient, workspaceId)
1927

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
66
import Link from 'next/link'
77
import { useRouter } from 'next/navigation'
88
import { useQueryState } from 'nuqs'
9+
import { isChatEnabled } from '@/lib/core/config/env-flags'
910
import {
1011
blockTypeToIconMap,
1112
type Integration,
@@ -150,11 +151,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
150151
Add to Sim
151152
</Chip>
152153
)
153-
) : (
154+
) : isChatEnabled ? (
154155
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
155156
Add to Sim
156157
</Chip>
157-
)}
158+
) : null}
158159
</div>
159160
</div>
160161
{oauthService && (
@@ -236,7 +237,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
236237
/>
237238
)}
238239

239-
{matchingTemplates.length > 0 && (
240+
{/* Every template hands its prompt to Chat, so the section has no
241+
destination without it. */}
242+
{isChatEnabled && matchingTemplates.length > 0 && (
240243
<TemplatesSection
241244
integration={integration}
242245
templates={matchingTemplates}

apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Chip } from '@sim/emcn'
44
import { ArrowRight } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
6+
import { isChatEnabled } from '@/lib/core/config/env-flags'
67
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
78
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
89

@@ -29,17 +30,19 @@ export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
2930
return (
3031
<div className='relative'>
3132
<IntegrationsShowcase />
32-
<Chip
33-
active
34-
rightIcon={ArrowRight}
35-
onClick={() => {
36-
storeCuratedPrompt(prompt)
37-
router.push(`/workspace/${workspaceId}/home`)
38-
}}
39-
className='absolute right-0 bottom-0 mx-0'
40-
>
41-
Explore in chat
42-
</Chip>
33+
{isChatEnabled && (
34+
<Chip
35+
active
36+
rightIcon={ArrowRight}
37+
onClick={() => {
38+
storeCuratedPrompt(prompt)
39+
router.push(`/workspace/${workspaceId}/home`)
40+
}}
41+
className='absolute right-0 bottom-0 mx-0'
42+
>
43+
Explore in chat
44+
</Chip>
45+
)}
4346
</div>
4447
)
4548
}

0 commit comments

Comments
 (0)