From d61e2bf73b761d5e26a4484d922a962fa432e254 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 3 Aug 2026 15:08:54 +0000 Subject: [PATCH 1/7] feat: add bulk integration disable action --- .../components/settings/Integrations.test.tsx | 35 ++++++++ .../src/components/settings/Integrations.tsx | 72 ++++++++++++++++- apps/web/src/hooks/mcp-connections/index.ts | 1 + .../useDisableAllIntegrations.ts | 41 ++++++++++ .../mcp-connections/disable-all.test.ts | 81 +++++++++++++++++++ .../trpc/commands/mcp-connections/index.ts | 29 +++++++ apps/web/src/trpc/routers/_app.ts | 5 ++ 7 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts create mode 100644 apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 45563600c..45f132960 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -86,6 +86,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ connectLinear: vi.fn(), disconnectLinear: vi.fn(), setDeploymentEnabled: vi.fn(), + disableAllIntegrations: vi.fn(), connectMcp: vi.fn(), disconnectMcp: vi.fn(), setDisabledTools: vi.fn(), @@ -216,6 +217,10 @@ vi.mock('@/hooks/mcp-connections', () => ({ mutate: mutations.setDeploymentEnabled, variables: undefined, }), + useDisableAllIntegrations: () => ({ + isPending: false, + mutate: mutations.disableAllIntegrations, + }), useConnectMcp: () => ({ isPending: false, mutate: mutations.connectMcp, @@ -823,6 +828,9 @@ describe('Integrations settings', () => { expect( screen.getByRole('button', { name: 'Disable Linear' }), ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Disable all' }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Connect and enable Better Stack' }), ).toBeInTheDocument(); @@ -855,6 +863,33 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('confirms before disabling every integration', () => { + mutations.disableAllIntegrations.mockImplementation((_variables, options) => + options?.onSuccess?.(), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Disable all' })); + + expect( + screen.getByRole('heading', { name: 'Disable all integrations?' }), + ).toBeInTheDocument(); + expect( + screen.getByText(/removes all personal and deployment connections/i), + ).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Disable all' })[0]!); + + expect(mutations.disableAllIntegrations).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(toast.success).toHaveBeenCalledWith('All integrations disabled.'); + }); + it('connects and enables an org-scoped MCP from the integrations page', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index ef159afd2..269cfc37d 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -21,6 +21,7 @@ import { import { useAsanaConnection, useConnectMcp, + useDisableAllIntegrations, useDisconnectMcp, useGrafanaConnection, useDeploymentMcpEnablements, @@ -589,17 +590,22 @@ function IntegrationSection({ title, items, emptyState, + action, }: { id: string; title: string; items: IntegrationItem[]; emptyState?: ReactNode; + action?: ReactNode; }) { return (
-

- {title} -

+
+

+ {title} +

+ {action} +
{items.length > 0 ? (
@@ -1142,6 +1148,7 @@ export function Integrations() { integrationName: string; } | null>(null); const [isLinearOauthSetupOpen, setIsLinearOauthSetupOpen] = useState(false); + const [isDisableAllDialogOpen, setIsDisableAllDialogOpen] = useState(false); const linearInstallation = useLinearInstallation(); const connectLinear = useConnectLinear(`${pathname}?service=linear`); @@ -1158,6 +1165,7 @@ export function Integrations() { isAdmin && (linearOauthUnavailable || isLinearOauthSetupOpen), ); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); + const disableAllIntegrations = useDisableAllIntegrations(); const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); const disconnectMcp = useDisconnectMcp(); @@ -2113,10 +2121,68 @@ export function Integrations() { deepLinkDialogItem.onAction?.(); }} /> + + + + Disable all integrations? + + This disconnects every integration for this Roomote instance and + removes all personal and deployment connections. This cannot be + undone. + + + + + + + + 0 ? ( + + ) : null + } emptyState={

You haven't connected any integrations yet. diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index d7133e7d4..f431ad298 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -6,6 +6,7 @@ export { useMcpOauthReadiness } from './useMcpOauthReadiness'; // Mutations export { useSetDeploymentMcpEnabled } from './useSetDeploymentMcpEnabled'; +export { useDisableAllIntegrations } from './useDisableAllIntegrations'; export { useConnectMcp } from './useConnectMcp'; export { useDisconnectMcp } from './useDisconnectMcp'; export { useAsanaConnection } from './useAsanaConnection'; diff --git a/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts b/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts new file mode 100644 index 000000000..e79b42f49 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts @@ -0,0 +1,41 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useDisableAllIntegrations() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.mcpConnections.disableAll.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.userConnections.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.linear.installation.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.linkedAccounts.linear.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.snowflakeConnection.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.asanaConnection.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.grafanaConnection.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.vercelConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts b/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts new file mode 100644 index 000000000..2d734de3f --- /dev/null +++ b/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts @@ -0,0 +1,81 @@ +import { + db, + deploymentMcpEnablements, + inArray, + mcpConnections, + userFactory, +} from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { disableAllIntegrationsCommand } from './index'; + +const TEST_MCP_IDS = ['linear', 'notion']; + +describe('disableAllIntegrationsCommand', () => { + afterEach(async () => { + await db + .delete(mcpConnections) + .where(inArray(mcpConnections.mcpId, TEST_MCP_IDS)); + await db + .delete(deploymentMcpEnablements) + .where(inArray(deploymentMcpEnablements.mcpId, TEST_MCP_IDS)); + }); + + it('rejects non-admin users', async () => { + await expect( + disableAllIntegrationsCommand({ isAdmin: false } as UserAuthSuccess), + ).rejects.toThrow('Unauthorized'); + }); + + it('disables enablements and removes every curated connection', async () => { + const admin = await userFactory.create({ role: 'admin' }); + const member = await userFactory.create({ role: 'member' }); + await db.insert(deploymentMcpEnablements).values( + TEST_MCP_IDS.map((mcpId) => ({ + mcpId, + enabled: true, + enabledByUserId: admin.id, + })), + ); + await db.insert(mcpConnections).values([ + { + mcpId: 'linear', + connectionRole: 'linear_org_install', + authStatus: 'authenticated', + }, + { + mcpId: 'linear', + connectionRole: 'linear_user_link', + userId: member.id, + authStatus: 'authenticated', + }, + { + mcpId: 'notion', + userId: member.id, + authStatus: 'authenticated', + }, + ]); + + await disableAllIntegrationsCommand({ + userId: admin.id, + isAdmin: true, + } as UserAuthSuccess); + + const enablements = await db.query.deploymentMcpEnablements.findMany({ + where: inArray(deploymentMcpEnablements.mcpId, TEST_MCP_IDS), + }); + const remainingConnections = await db.query.mcpConnections.findMany({ + where: inArray(mcpConnections.mcpId, TEST_MCP_IDS), + }); + + expect(enablements).toHaveLength(2); + expect(enablements.every((enablement) => !enablement.enabled)).toBe(true); + expect( + enablements.every( + (enablement) => enablement.enabledByUserId === admin.id, + ), + ).toBe(true); + expect(remainingConnections).toEqual([]); + }); +}); diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 30d5d799e..081862ce8 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -619,6 +619,35 @@ export async function setDeploymentMcpEnabledCommand( return result!; } +/** + * Admin: disable every curated integration and remove its connections. + */ +export async function disableAllIntegrationsCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const mcpIds = getMcpIntegrationIds(); + if (mcpIds.length === 0) { + return { success: true }; + } + + await db.transaction(async (tx) => { + await tx + .update(deploymentMcpEnablements) + .set({ + enabled: false, + enabledByUserId: auth.userId, + updatedAt: new Date(), + }) + .where(inArray(deploymentMcpEnablements.mcpId, mcpIds)); + + await tx + .delete(mcpConnections) + .where(inArray(mcpConnections.mcpId, mcpIds)); + }); + + return { success: true }; +} + /** * Get MCP connections visible to the current user. * This includes user-scoped connections plus deployment-scoped connections. diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 958b0180c..60433e476 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -189,6 +189,7 @@ import { takeOverBrowserControlCommand, } from '../commands/sandbox-session'; import { + disableAllIntegrationsCommand, getDeploymentMcpEnablementsCommand, getMcpOauthReadinessCommand, setDeploymentMcpEnabledCommand, @@ -1556,6 +1557,10 @@ export const appRouter = createRouter({ setDeploymentMcpEnabledCommand(auth, input), ), + disableAll: protectedProcedure.mutation(({ ctx: { auth } }) => + disableAllIntegrationsCommand(auth), + ), + userConnections: protectedProcedure.query(({ ctx: { auth } }) => getUserMcpConnectionsCommand(auth), ), From 880614e387dd7a9fbe49a20c18f52e9adab8ee48 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 3 Aug 2026 16:52:58 +0000 Subject: [PATCH 2/7] fix: prevent integrations when deployment disables them --- .env.production.example | 3 + .../linear-active-run-priority.test.ts | 31 +++++-- apps/api/src/handlers/linear/index.ts | 6 +- apps/api/src/handlers/mcp/index.ts | 18 +++- apps/docs/environment-variables.mdx | 1 + apps/docs/integrations/index.mdx | 7 ++ .../callback/__tests__/route.test.ts | 16 ++++ .../src/app/api/mcp-oauth/callback/route.ts | 4 + .../[connectionId]/__tests__/route.test.ts | 18 ++++ .../initiate/[connectionId]/route.ts | 6 ++ .../app/api/mcp-oauth/replay/[token]/route.ts | 7 ++ .../components/settings/Integrations.test.tsx | 42 +++------ .../src/components/settings/Integrations.tsx | 89 +++++-------------- apps/web/src/hooks/mcp-connections/index.ts | 2 +- .../useCuratedIntegrationsAvailability.ts | 11 +++ .../useDisableAllIntegrations.ts | 41 --------- .../lib/server/curated-integrations.test.ts | 17 ++++ .../src/lib/server/curated-integrations.ts | 12 +++ apps/web/src/lib/server/env.ts | 2 + .../src/trpc/commands/linear/oauth-setup.ts | 2 + .../mcp-connections/disable-all.test.ts | 81 ----------------- .../trpc/commands/mcp-connections/index.ts | 49 ++++------ apps/web/src/trpc/routers/_app.ts | 10 +-- docker-compose.production.yml | 1 + docker-compose.self-host.yml | 1 + packages/env/src/__tests__/index.test.ts | 19 ++++ packages/env/src/index.ts | 26 ++++++ .../server/routers/mcp-connections.test.ts | 21 +++++ .../sdk/src/server/routers/mcp-connections.ts | 9 ++ 29 files changed, 290 insertions(+), 262 deletions(-) create mode 100644 apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts delete mode 100644 apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts create mode 100644 apps/web/src/lib/server/curated-integrations.test.ts create mode 100644 apps/web/src/lib/server/curated-integrations.ts delete mode 100644 apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts diff --git a/.env.production.example b/.env.production.example index b1354fd8f..c8f6fa797 100644 --- a/.env.production.example +++ b/.env.production.example @@ -143,6 +143,9 @@ DEFAULT_COMPUTE_PROVIDER=docker # GITLAB_WEBHOOK_SIGNING_TOKEN= # Optional integrations. +# Set to false to prevent curated integrations from being configured or used. +# Existing connections remain stored and become available again if re-enabled. +# R_CURATED_INTEGRATIONS_ENABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts index 0325079b0..51a7d051c 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts @@ -16,6 +16,13 @@ const { createLinearAgentRunMock } = vi.hoisted(() => ({ .mockResolvedValue({ status: 'ok', runId: 77, taskId: 'task-77' }), })); +const envState = vi.hoisted(() => ({ + R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret', + R_APP_URL: 'https://app.roomote.example', + PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', + R_CURATED_INTEGRATIONS_ENABLED: true, +})); + const { findLinearDeploymentMcpConnectionByIdentityMock, findLinearUserMcpConnectionByIdentityMock, @@ -40,11 +47,7 @@ vi.mock('@roomote/env', async (importOriginal) => { return { ...actual, - Env: { - R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret', - R_APP_URL: 'https://app.roomote.example', - PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', - }, + Env: envState, }; }); @@ -276,6 +279,24 @@ describe('CLO-1133: active task run takes priority over routing confirmation and app = new Hono(); app.route('/linear', linear); vi.clearAllMocks(); + envState.R_CURATED_INTEGRATIONS_ENABLED = true; + }); + + it('acknowledges without processing when curated integrations are disabled', async () => { + envState.R_CURATED_INTEGRATIONS_ENABLED = false; + const { rawBody, headers } = createSignedRequest(makePayload()); + + const response = await app.request('/linear', { + method: 'POST', + headers, + body: rawBody, + }); + + expect(response.status).toBe(204); + expect(createLinearAgentRunMock).not.toHaveBeenCalled(); + expect( + findLinearDeploymentMcpConnectionByIdentityMock, + ).not.toHaveBeenCalled(); }); it('delivers free-text reply to active task run even when routing confirmation key exists in Redis', async () => { diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts index 88dbe8533..6012b9ad0 100644 --- a/apps/api/src/handlers/linear/index.ts +++ b/apps/api/src/handlers/linear/index.ts @@ -12,7 +12,7 @@ import { PRODUCT_NAME, restoreSnapshotResumeVisiblePromptFields, } from '@roomote/types'; -import { Env } from '@roomote/env'; +import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; import { type RoutingDebugInfo, enqueueTask, @@ -254,6 +254,10 @@ export const linear = new Hono(); * The handler must emit a "thought" activity within 10 seconds to acknowledge receipt. */ linear.post('/', async (c) => { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return c.body(null, 204); + } + const headers = c.req.header(); const rawBody = await c.req.text(); diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index 9ede9cd0b..5601c05f6 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -1,4 +1,5 @@ -import { Hono } from 'hono'; +import { Hono, type MiddlewareHandler } from 'hono'; +import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; import { isNativeMcpIntegration, MCP_INTEGRATIONS } from '@roomote/types'; import type { Variables } from '../../types'; @@ -19,6 +20,21 @@ import { vercelMcp } from './vercel'; export const mcp = new Hono<{ Variables: Variables }>(); +const requireCuratedIntegrations: MiddlewareHandler<{ + Variables: Variables; +}> = async (c, next) => { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return c.notFound(); + } + + await next(); +}; + +for (const integration of MCP_INTEGRATIONS) { + mcp.use(`/${integration.id}`, requireCuratedIntegrations); + mcp.use(`/${integration.id}/*`, requireCuratedIntegrations); +} + mcp.route('/asana', asanaMcp); mcp.route('/grafana', grafanaMcp); mcp.route('/linear', linearMcp); diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 6ee6f6445..ccab6077b 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -125,6 +125,7 @@ as per-task auth tokens or workspace paths. | `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. | | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | +| `R_CURATED_INTEGRATIONS_ENABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog. Defaults to `true`. Set to `false` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored and become available again if the policy is re-enabled. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index afecc1d97..13e19139f 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -14,6 +14,13 @@ This section does not list provider categories that have their own setup paths: communications providers, source-control providers, inference providers, and sandbox providers. Configure those from the provider-specific docs instead. +Deployment operators can prevent every integration in this curated catalog +from being configured or used by setting +`R_CURATED_INTEGRATIONS_ENABLED=false` and restarting Roomote. Existing +connections remain stored but inactive, so setting the value back to `true` +restores them. This policy does not affect the separate provider categories +above or MCP servers defined on an environment. + ## Connection patterns You will usually see one of these setup models: diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts index abf30c14c..8deb543f8 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts @@ -171,6 +171,22 @@ describe('GET /api/mcp-oauth/callback', () => { expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); }); + it('rejects a pending callback when integrations become disabled', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:13000', + R_PUBLIC_URL: 'https://customer.example', + R_CURATED_INTEGRATIONS_ENABLED: false, + }); + + const response = await GET(buildRequest('?code=auth-code&state=state-1')); + + expect(response.headers.get('location')).toBe( + 'https://customer.example/settings?mcp=error&reason=callback_failed', + ); + expect(consumeOAuthStateMock).not.toHaveBeenCalled(); + expect(exchangeCodeForTokensMock).not.toHaveBeenCalled(); + }); + it('falls back to R_APP_URL for token exchange redirect_uri when R_PUBLIC_URL is unset', async () => { bootstrapWebRuntimeEnvMock.mockResolvedValue({ R_APP_URL: 'http://localhost:13000', diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts index c8e42bb25..c649b27d3 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/route.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts @@ -166,6 +166,10 @@ export async function GET(request: NextRequest) { const redirectToResult = (result: McpOAuthResult) => redirectWithMcpResult(webUrl, redirectPath, result, state); + if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + return redirectToResult({ status: 'error', reason: 'callback_failed' }); + } + // Handle OAuth errors if (error) { const reason: McpOAuthErrorReason = diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index 21eda8459..43602dfad 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -181,6 +181,24 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { }); }); + it('rejects OAuth before authentication when integrations are disabled', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:13000', + R_PUBLIC_URL: 'https://customer.example', + R_CURATED_INTEGRATIONS_ENABLED: false, + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + expect(response.headers.get('location')).toBe( + 'https://customer.example/settings?mcp=error&reason=disabled', + ); + expect(authorizeMock).not.toHaveBeenCalled(); + expect(mcpConnectionsFindFirstMock).not.toHaveBeenCalled(); + }); + it('uses the Linear API OAuth flow instead of Linear MCP OAuth', async () => { getMcpIntegrationOauthEndpointsMock.mockReturnValue({ authorizationEndpoint: 'https://linear.app/oauth/authorize', diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts index 126234dd9..6410606ee 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts @@ -160,6 +160,12 @@ export async function GET( DEFAULT_REDIRECT_PATH; const replayToken = requestUrl.searchParams.get('replayToken'); + if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + return NextResponse.redirect( + withMcpQuery(webUrl, redirectPath, 'error', 'disabled'), + ); + } + const authResult = await authorize(); if (!authResult.success) { return NextResponse.redirect( diff --git a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts index b47041d2c..b2ebd6714 100644 --- a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts @@ -27,6 +27,13 @@ export async function GET( const webEnv = await bootstrapWebRuntimeEnv(); const webUrl = getPublicAppUrl(webEnv); const { token } = await params; + + if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + return NextResponse.redirect( + new URL('/error?message=Integrations are disabled', webUrl), + ); + } + const replay = await getMcpOauthReplay(token); if (!replay) { diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 45f132960..9d2ce8baf 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -12,6 +12,7 @@ import { toast } from 'sonner'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; const state = vi.hoisted(() => ({ + integrationsEnabled: true, deploymentEnablements: [] as Array<{ mcpId: string; enabled: boolean }>, oauthReadiness: [{ mcpId: 'linear', status: 'ready' as const }] as Array<{ mcpId: string; @@ -86,7 +87,6 @@ const { mutations, selectMock } = vi.hoisted(() => ({ connectLinear: vi.fn(), disconnectLinear: vi.fn(), setDeploymentEnabled: vi.fn(), - disableAllIntegrations: vi.fn(), connectMcp: vi.fn(), disconnectMcp: vi.fn(), setDisabledTools: vi.fn(), @@ -193,6 +193,9 @@ vi.mock('@/hooks/linear', () => ({ })); vi.mock('@/hooks/mcp-connections', () => ({ + useCuratedIntegrationsAvailability: () => ({ + data: { enabled: state.integrationsEnabled }, + }), useDeploymentMcpEnablements: () => ({ data: state.deploymentEnablements, }), @@ -217,10 +220,6 @@ vi.mock('@/hooks/mcp-connections', () => ({ mutate: mutations.setDeploymentEnabled, variables: undefined, }), - useDisableAllIntegrations: () => ({ - isPending: false, - mutate: mutations.disableAllIntegrations, - }), useConnectMcp: () => ({ isPending: false, mutate: mutations.connectMcp, @@ -434,6 +433,7 @@ describe('Integrations settings', () => { vi.clearAllMocks(); window.history.replaceState(null, '', '/settings/integrations'); state.deploymentEnablements = []; + state.integrationsEnabled = true; state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }]; state.userConnections = []; state.mcpTools = null; @@ -828,9 +828,6 @@ describe('Integrations settings', () => { expect( screen.getByRole('button', { name: 'Disable Linear' }), ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Disable all' }), - ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Connect and enable Better Stack' }), ).toBeInTheDocument(); @@ -863,31 +860,20 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); - it('confirms before disabling every integration', () => { - mutations.disableAllIntegrations.mockImplementation((_variables, options) => - options?.onSuccess?.(), - ); - render(); + it('shows operator policy instead of integration controls when disabled', () => { + state.integrationsEnabled = false; - fireEvent.click(screen.getByRole('button', { name: 'Disable all' })); + render(); expect( - screen.getByRole('heading', { name: 'Disable all integrations?' }), + screen.getByText('Integrations disabled by deployment operator'), ).toBeInTheDocument(); expect( - screen.getByText(/removes all personal and deployment connections/i), - ).toBeInTheDocument(); - - fireEvent.click(screen.getAllByRole('button', { name: 'Disable all' })[0]!); - - expect(mutations.disableAllIntegrations).toHaveBeenCalledWith( - undefined, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - }), - ); - expect(toast.success).toHaveBeenCalledWith('All integrations disabled.'); + screen.queryByRole('heading', { name: 'Connected' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Disable Linear' }), + ).not.toBeInTheDocument(); }); it('connects and enables an org-scoped MCP from the integrations page', () => { diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 269cfc37d..d1850f2c5 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -21,7 +21,7 @@ import { import { useAsanaConnection, useConnectMcp, - useDisableAllIntegrations, + useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, useDeploymentMcpEnablements, @@ -44,6 +44,9 @@ import { } from '@/types'; import { + Alert, + AlertDescription, + AlertTitle, BasicTooltip, Button, Card, @@ -590,22 +593,17 @@ function IntegrationSection({ title, items, emptyState, - action, }: { id: string; title: string; items: IntegrationItem[]; emptyState?: ReactNode; - action?: ReactNode; }) { return (

-
-

- {title} -

- {action} -
+

+ {title} +

{items.length > 0 ? (
@@ -1148,13 +1146,13 @@ export function Integrations() { integrationName: string; } | null>(null); const [isLinearOauthSetupOpen, setIsLinearOauthSetupOpen] = useState(false); - const [isDisableAllDialogOpen, setIsDisableAllDialogOpen] = useState(false); const linearInstallation = useLinearInstallation(); const connectLinear = useConnectLinear(`${pathname}?service=linear`); const disconnectLinear = useDisconnectLinear(); const deploymentEnablements = useDeploymentMcpEnablements(); + const integrationsAvailability = useCuratedIntegrationsAvailability(); const oauthReadiness = useMcpOauthReadiness(); const linearOauthStatus = oauthReadiness.data?.find( (entry) => entry.mcpId === 'linear', @@ -1165,7 +1163,6 @@ export function Integrations() { isAdmin && (linearOauthUnavailable || isLinearOauthSetupOpen), ); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); - const disableAllIntegrations = useDisableAllIntegrations(); const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); const disconnectMcp = useDisconnectMcp(); @@ -1996,6 +1993,18 @@ export function Integrations() { }); }; + if (integrationsAvailability.data?.enabled === false) { + return ( + + Integrations disabled by deployment operator + + Curated integrations cannot be connected or used on this Roomote + instance. + + + ); + } + return (
- - - - Disable all integrations? - - This disconnects every integration for this Roomote instance and - removes all personal and deployment connections. This cannot be - undone. - - - - - - - - 0 ? ( - - ) : null - } emptyState={

You haven't connected any integrations yet. diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index f431ad298..cd40b54e7 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -1,12 +1,12 @@ // Queries export { useDeploymentMcpEnablements } from './useDeploymentMcpEnablements'; +export { useCuratedIntegrationsAvailability } from './useCuratedIntegrationsAvailability'; export { useUserMcpConnections } from './useUserMcpConnections'; export { useMcpConnectionTools } from './useMcpConnectionTools'; export { useMcpOauthReadiness } from './useMcpOauthReadiness'; // Mutations export { useSetDeploymentMcpEnabled } from './useSetDeploymentMcpEnabled'; -export { useDisableAllIntegrations } from './useDisableAllIntegrations'; export { useConnectMcp } from './useConnectMcp'; export { useDisconnectMcp } from './useDisconnectMcp'; export { useAsanaConnection } from './useAsanaConnection'; diff --git a/apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts b/apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts new file mode 100644 index 000000000..b0ad570a6 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useCuratedIntegrationsAvailability.ts @@ -0,0 +1,11 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useCuratedIntegrationsAvailability() { + const trpc = useTRPC(); + + return useQuery(trpc.mcpConnections.availability.queryOptions()); +} diff --git a/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts b/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts deleted file mode 100644 index e79b42f49..000000000 --- a/apps/web/src/hooks/mcp-connections/useDisableAllIntegrations.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { useTRPC } from '@/trpc/client'; - -export function useDisableAllIntegrations() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - return useMutation( - trpc.mcpConnections.disableAll.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.userConnections.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.linear.installation.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.linkedAccounts.linear.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.snowflakeConnection.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.asanaConnection.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.grafanaConnection.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.mcpConnections.vercelConnection.queryKey(), - }); - }, - }), - ); -} diff --git a/apps/web/src/lib/server/curated-integrations.test.ts b/apps/web/src/lib/server/curated-integrations.test.ts new file mode 100644 index 000000000..e8a64896e --- /dev/null +++ b/apps/web/src/lib/server/curated-integrations.test.ts @@ -0,0 +1,17 @@ +import { + CURATED_INTEGRATIONS_DISABLED_MESSAGE, + assertCuratedIntegrationsEnabled, +} from './curated-integrations'; + +describe('assertCuratedIntegrationsEnabled', () => { + it('allows the default and enabled values', () => { + expect(() => assertCuratedIntegrationsEnabled(undefined)).not.toThrow(); + expect(() => assertCuratedIntegrationsEnabled(true)).not.toThrow(); + }); + + it('rejects operator-disabled integrations', () => { + expect(() => assertCuratedIntegrationsEnabled(false)).toThrow( + CURATED_INTEGRATIONS_DISABLED_MESSAGE, + ); + }); +}); diff --git a/apps/web/src/lib/server/curated-integrations.ts b/apps/web/src/lib/server/curated-integrations.ts new file mode 100644 index 000000000..bf180e598 --- /dev/null +++ b/apps/web/src/lib/server/curated-integrations.ts @@ -0,0 +1,12 @@ +import { Env, areCuratedIntegrationsEnabled } from './env'; + +export const CURATED_INTEGRATIONS_DISABLED_MESSAGE = + 'Integrations are disabled by the deployment operator.'; + +export function assertCuratedIntegrationsEnabled( + value: string | boolean | undefined = Env.R_CURATED_INTEGRATIONS_ENABLED, +) { + if (!areCuratedIntegrationsEnabled(value)) { + throw new Error(CURATED_INTEGRATIONS_DISABLED_MESSAGE); + } +} diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 5904b2348..af358602e 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import * as dotenvx from '@dotenvx/dotenvx'; import { + areCuratedIntegrationsEnabled, createRoomoteEnv, getAllowedDevOrigins as getSharedAllowedDevOrigins, getArtifactSigningKey, @@ -195,6 +196,7 @@ function getWebRuntimeEnv(): RoomoteEnv { } export { + areCuratedIntegrationsEnabled, getArtifactSigningKey, getArtifactSigningKeyPrevious, getBetterAuthSecret, diff --git a/apps/web/src/trpc/commands/linear/oauth-setup.ts b/apps/web/src/trpc/commands/linear/oauth-setup.ts index e134c6070..abfaeab9d 100644 --- a/apps/web/src/trpc/commands/linear/oauth-setup.ts +++ b/apps/web/src/trpc/commands/linear/oauth-setup.ts @@ -11,6 +11,7 @@ import { import { LINEAR_ORG_CONNECTION_ROLE } from '@roomote/sdk/server'; import { Env } from '@/lib/server/env'; +import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrations'; import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; import type { UserAuthSuccess } from '@/types'; @@ -185,6 +186,7 @@ export async function saveLinearOauthSetupCommand( input: SaveLinearOauthSetupInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const runtimeEnv = getLinearRuntimeEnv(); const currentValues = await Promise.all( diff --git a/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts b/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts deleted file mode 100644 index 2d734de3f..000000000 --- a/apps/web/src/trpc/commands/mcp-connections/disable-all.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - db, - deploymentMcpEnablements, - inArray, - mcpConnections, - userFactory, -} from '@roomote/db/server'; - -import type { UserAuthSuccess } from '@/types'; - -import { disableAllIntegrationsCommand } from './index'; - -const TEST_MCP_IDS = ['linear', 'notion']; - -describe('disableAllIntegrationsCommand', () => { - afterEach(async () => { - await db - .delete(mcpConnections) - .where(inArray(mcpConnections.mcpId, TEST_MCP_IDS)); - await db - .delete(deploymentMcpEnablements) - .where(inArray(deploymentMcpEnablements.mcpId, TEST_MCP_IDS)); - }); - - it('rejects non-admin users', async () => { - await expect( - disableAllIntegrationsCommand({ isAdmin: false } as UserAuthSuccess), - ).rejects.toThrow('Unauthorized'); - }); - - it('disables enablements and removes every curated connection', async () => { - const admin = await userFactory.create({ role: 'admin' }); - const member = await userFactory.create({ role: 'member' }); - await db.insert(deploymentMcpEnablements).values( - TEST_MCP_IDS.map((mcpId) => ({ - mcpId, - enabled: true, - enabledByUserId: admin.id, - })), - ); - await db.insert(mcpConnections).values([ - { - mcpId: 'linear', - connectionRole: 'linear_org_install', - authStatus: 'authenticated', - }, - { - mcpId: 'linear', - connectionRole: 'linear_user_link', - userId: member.id, - authStatus: 'authenticated', - }, - { - mcpId: 'notion', - userId: member.id, - authStatus: 'authenticated', - }, - ]); - - await disableAllIntegrationsCommand({ - userId: admin.id, - isAdmin: true, - } as UserAuthSuccess); - - const enablements = await db.query.deploymentMcpEnablements.findMany({ - where: inArray(deploymentMcpEnablements.mcpId, TEST_MCP_IDS), - }); - const remainingConnections = await db.query.mcpConnections.findMany({ - where: inArray(mcpConnections.mcpId, TEST_MCP_IDS), - }); - - expect(enablements).toHaveLength(2); - expect(enablements.every((enablement) => !enablement.enabled)).toBe(true); - expect( - enablements.every( - (enablement) => enablement.enabledByUserId === admin.id, - ), - ).toBe(true); - expect(remainingConnections).toEqual([]); - }); -}); diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 081862ce8..3598daa12 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -34,7 +34,8 @@ import { getValidAccessToken } from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; import type { StaticOauthReadiness } from '@/lib/server/mcp-static-oauth'; import { getDeploymentStaticOauthReadiness } from '@/lib/server/deployment-static-oauth'; -import { Env } from '@/lib/server/env'; +import { Env, areCuratedIntegrationsEnabled } from '@/lib/server/env'; +import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrations'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { SaveAsanaConnectionInput, @@ -521,6 +522,12 @@ export async function getDeploymentMcpEnablementsCommand( }); } +export function getCuratedIntegrationsAvailabilityCommand() { + return { + enabled: areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED), + }; +} + /** * Return public-safe OAuth setup status for integrations that require a * deployment-configured client. Credential names and values never leave the @@ -553,6 +560,10 @@ export async function setDeploymentMcpEnabledCommand( ) { assertAdmin(auth); + if (input.enabled) { + assertCuratedIntegrationsEnabled(); + } + if (!ALL_DEPLOYMENT_CONTROLLED_APP_IDS.has(input.mcpId)) { throw new Error(`Unknown deployment-controlled app: ${input.mcpId}`); } @@ -619,35 +630,6 @@ export async function setDeploymentMcpEnabledCommand( return result!; } -/** - * Admin: disable every curated integration and remove its connections. - */ -export async function disableAllIntegrationsCommand(auth: UserAuthSuccess) { - assertAdmin(auth); - - const mcpIds = getMcpIntegrationIds(); - if (mcpIds.length === 0) { - return { success: true }; - } - - await db.transaction(async (tx) => { - await tx - .update(deploymentMcpEnablements) - .set({ - enabled: false, - enabledByUserId: auth.userId, - updatedAt: new Date(), - }) - .where(inArray(deploymentMcpEnablements.mcpId, mcpIds)); - - await tx - .delete(mcpConnections) - .where(inArray(mcpConnections.mcpId, mcpIds)); - }); - - return { success: true }; -} - /** * Get MCP connections visible to the current user. * This includes user-scoped connections plus deployment-scoped connections. @@ -817,6 +799,7 @@ export async function saveSnowflakeConnectionCommand( input: SaveSnowflakeConnectionCommandInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -966,6 +949,7 @@ export async function saveAsanaConnectionCommand( input: SaveAsanaConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1049,6 +1033,7 @@ export async function saveVercelConnectionCommand( input: SaveVercelConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1136,6 +1121,7 @@ export async function saveGrafanaConnectionCommand( input: SaveGrafanaConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1221,6 +1207,7 @@ export async function listDeploymentMcpIntegrationToolsCommand( input: { mcpId: string }, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const integration = getMcpIntegration(input.mcpId); if (!integration) { throw new Error(`Unknown MCP integration: ${input.mcpId}`); @@ -1247,6 +1234,7 @@ export async function setDeploymentDisabledMcpIntegrationToolsCommand( input: { mcpId: string; disabledTools: string[] }, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const integration = getMcpIntegration(input.mcpId); if (!integration) { throw new Error(`Unknown MCP integration: ${input.mcpId}`); @@ -1288,6 +1276,7 @@ export async function connectMcpCommand( auth: UserAuthSuccess, input: { mcpId: string; redirectTo?: string; role?: McpConnectionRole }, ) { + assertCuratedIntegrationsEnabled(); const integration = getMcpIntegration(input.mcpId); if (!integration) { throw new Error(`Unknown MCP integration: ${input.mcpId}`); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 60433e476..f3c18f437 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -189,8 +189,8 @@ import { takeOverBrowserControlCommand, } from '../commands/sandbox-session'; import { - disableAllIntegrationsCommand, getDeploymentMcpEnablementsCommand, + getCuratedIntegrationsAvailabilityCommand, getMcpOauthReadinessCommand, setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, @@ -1543,6 +1543,10 @@ export const appRouter = createRouter({ }), mcpConnections: createRouter({ + availability: protectedProcedure.query(() => + getCuratedIntegrationsAvailabilityCommand(), + ), + deploymentEnablements: protectedProcedure.query(({ ctx: { auth } }) => getDeploymentMcpEnablementsCommand(auth), ), @@ -1557,10 +1561,6 @@ export const appRouter = createRouter({ setDeploymentMcpEnabledCommand(auth, input), ), - disableAll: protectedProcedure.mutation(({ ctx: { auth } }) => - disableAllIntegrationsCommand(auth), - ), - userConnections: protectedProcedure.query(({ ctx: { auth } }) => getUserMcpConnectionsCommand(auth), ), diff --git a/docker-compose.production.yml b/docker-compose.production.yml index f9c377755..66f9d7349 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -22,6 +22,7 @@ x-roomote-production-env: &roomote-production-env R_APP_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} + R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-true} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 7565540c8..f5bef26e8 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -21,6 +21,7 @@ x-roomote-env: &roomote-env S3_BUCKET_ARTIFACTS: ${S3_BUCKET_ARTIFACTS:-roomote-artifacts} R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} + R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-true} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: ${PREVIEW_PROXY_BASE_URL:-http://localhost:18081} PREVIEW_DOMAINS: ${PREVIEW_DOMAINS:-localhost,127.0.0.1,roomotepreview.localhost} diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index ce498505e..b3541442a 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { Env, + areCuratedIntegrationsEnabled, assertSecureBootBinding, createRoomoteEnv, getActiveInsecureLocalSecrets, @@ -199,6 +200,24 @@ describe('Env', () => { expect(isRoomoteCloudEnabled('false')).toBe(false); }); + it('enables curated integrations by default and accepts an operator override', () => { + const runtimeEnv = { ...process.env }; + delete runtimeEnv.SKIP_ENV_VALIDATION; + delete runtimeEnv.R_CURATED_INTEGRATIONS_ENABLED; + + expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_ENABLED).toBe( + true, + ); + expect( + createRoomoteEnv({ + ...runtimeEnv, + R_CURATED_INTEGRATIONS_ENABLED: 'false', + }).R_CURATED_INTEGRATIONS_ENABLED, + ).toBe(false); + expect(areCuratedIntegrationsEnabled(undefined)).toBe(true); + expect(areCuratedIntegrationsEnabled('0')).toBe(false); + }); + it('accepts valid Ping instance IDs and rejects invalid ones', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 5a6cbab9d..d09e641a6 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -61,6 +61,13 @@ function optInBoolean() { .transform((value) => value === 'true' || value === '1'); } +function optOutBoolean() { + return z + .enum(['true', 'false', '1', '0']) + .default('true') + .transform((value) => value === 'true' || value === '1'); +} + const serverSchema = { R_APP_ENV: z.enum(['development', 'preview', 'production']).optional(), APP_ENV: z.enum(['development', 'preview', 'production']).optional(), @@ -118,6 +125,9 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), + // Operator policy for the curated Settings > Integrations catalog. Existing + // connections remain stored but cannot be configured or used while disabled. + R_CURATED_INTEGRATIONS_ENABLED: optOutBoolean(), R_INTERCOM_APP_ID: z.string().min(1).optional(), R_POSTHOG_PROJECT_KEY: z.string().min(1).optional(), R_POSTHOG_HOST: z.string().url().optional(), @@ -566,6 +576,22 @@ export function isRoomoteCloudEnabled( ); } +/** Whether the operator permits curated integrations on this deployment. */ +export function areCuratedIntegrationsEnabled( + value: string | boolean | undefined, +): boolean { + if (value === false) { + return false; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return normalized !== 'false' && normalized !== '0'; + } + + return true; +} + /** * Whether the deployment opted into generating missing auth keypairs at boot * and persisting them in the database (`R_AUTO_GENERATE_KEYS=true`). diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index aa70944dd..78aeec33c 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -1,5 +1,15 @@ import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +const mockEnv = vi.hoisted(() => ({ + R_CURATED_INTEGRATIONS_ENABLED: true, +})); + +vi.mock('@roomote/env', () => ({ + Env: mockEnv, + areCuratedIntegrationsEnabled: (value: boolean | undefined) => + value !== false, +})); + const { mockFindTaskRun, mockFindEnablements, @@ -178,6 +188,7 @@ function buildEnabledOnlyRow(mcpId: string) { describe('mcpConnectionsRouter.getMcpServerConfigs', () => { beforeEach(() => { vi.clearAllMocks(); + mockEnv.R_CURATED_INTEGRATIONS_ENABLED = true; mockFindTaskRun.mockResolvedValue({ actingUserId: null, }); @@ -186,6 +197,16 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { mockOrderBy.mockResolvedValue([buildJoinedConnectionRow()]); }); + it('returns no curated servers when the operator disables integrations', async () => { + mockEnv.R_CURATED_INTEGRATIONS_ENABLED = false; + + const result = await createCaller().getMcpServerConfigs(); + + expect(result).toEqual({ servers: {} }); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockGetValidAccessToken).not.toHaveBeenCalled(); + }); + it('returns Notion proxy config without raw OAuth bearer token', async () => { mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 7f070ea47..543c2e34f 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; import { db, desc, @@ -64,6 +65,10 @@ export const mcpConnectionsRouter = router({ }), ) .query(async ({ input }) => { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return false; + } + // Deployment-scoped enablement: valid for any authenticated // principal, including deployment-service-principal run tokens. const enablement = await db.query.deploymentMcpEnablements.findFirst({ @@ -133,6 +138,10 @@ export const mcpConnectionsRouter = router({ * Returns a map of sanitized server names to { url, headers }. */ getMcpServerConfigs: authenticatedProcedure.query(async ({ ctx }) => { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return { servers: {} }; + } + const actorContext = await resolveActorScopedUserContext(ctx.auth); const connectionFilters = []; From 0a0c91bcdbd006ca8608b5d282b537e35b09e6e7 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 3 Aug 2026 17:02:03 +0000 Subject: [PATCH 3/7] fix: enforce integration policy for active Linear flows --- apps/api/src/handlers/mcp/routing.ts | 15 +++- .../server/routers/linear-sessions.test.ts | 68 +++++++++++++++++++ .../sdk/src/server/routers/linear-sessions.ts | 5 ++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/server/routers/linear-sessions.test.ts diff --git a/apps/api/src/handlers/mcp/routing.ts b/apps/api/src/handlers/mcp/routing.ts index 5b41be4f3..31038552e 100644 --- a/apps/api/src/handlers/mcp/routing.ts +++ b/apps/api/src/handlers/mcp/routing.ts @@ -1,4 +1,5 @@ -import { Hono } from 'hono'; +import { Hono, type MiddlewareHandler } from 'hono'; +import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; import type { Variables } from '../../types'; @@ -15,7 +16,19 @@ import { roomoteMcp } from './roomote'; */ export const mcpRouting = new Hono<{ Variables: Variables }>(); +const requireCuratedIntegrations: MiddlewareHandler<{ + Variables: Variables; +}> = async (c, next) => { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return c.notFound(); + } + + await next(); +}; + mcpRouting.route('/roomote', roomoteMcp); +mcpRouting.use('/linear', requireCuratedIntegrations); +mcpRouting.use('/linear/*', requireCuratedIntegrations); mcpRouting.route( '/linear', createLinearMcp({ diff --git a/packages/sdk/src/server/routers/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts new file mode 100644 index 000000000..bd89d213c --- /dev/null +++ b/packages/sdk/src/server/routers/linear-sessions.test.ts @@ -0,0 +1,68 @@ +import type { AuthTokenContext } from '@roomote/types'; + +const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted( + () => ({ + envState: { R_CURATED_INTEGRATIONS_ENABLED: true }, + findConnectionMock: vi.fn(), + getValidAccessTokenMock: vi.fn(), + }), +); + +vi.mock('@roomote/env', () => ({ + Env: envState, + areCuratedIntegrationsEnabled: (value: boolean | undefined) => + value !== false, +})); + +vi.mock('@roomote/db/server', () => ({ + db: { query: { taskRuns: { findFirst: vi.fn() } } }, + taskRuns: { id: 'taskRuns.id' }, + eq: vi.fn(), +})); + +vi.mock('@roomote/linear', () => ({ + createLinearClient: vi.fn(), + drainLinearMessagesToResumeRun: vi.fn(), +})); + +vi.mock('../lib/mcp/data', () => ({ + getValidAccessToken: getValidAccessTokenMock, +})); + +vi.mock('../lib/mcp/linear-connections', () => ({ + findLinearDeploymentMcpConnection: findConnectionMock, + getLinearDeploymentMetadata: vi.fn(), +})); + +import { linearSessionsRouter } from './linear-sessions'; + +function createCaller() { + const auth: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + + return linearSessionsRouter.createCaller({ auth, req: undefined }); +} + +describe('linearSessionsRouter operator policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + envState.R_CURATED_INTEGRATIONS_ENABLED = true; + }); + + it('blocks existing Linear sessions when curated integrations are disabled', async () => { + envState.R_CURATED_INTEGRATIONS_ENABLED = false; + + await expect(createCaller().hasActiveConnection()).resolves.toBe(false); + await expect( + createCaller().emitThought({ + sessionId: 'session-1', + content: 'Working', + }), + ).rejects.toThrow('Linear connection not found.'); + expect(findConnectionMock).not.toHaveBeenCalled(); + expect(getValidAccessTokenMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/server/routers/linear-sessions.ts b/packages/sdk/src/server/routers/linear-sessions.ts index 1ba826784..44f583038 100644 --- a/packages/sdk/src/server/routers/linear-sessions.ts +++ b/packages/sdk/src/server/routers/linear-sessions.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; import { TaskPayloadKind } from '@roomote/types'; import { db, taskRuns, eq } from '@roomote/db/server'; @@ -21,6 +22,10 @@ import { } from '../trpc'; async function findActiveConnection() { + if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + return null; + } + return findLinearDeploymentMcpConnection(); } From 811d461897ca5c49ce7f121a5bfc66249e814021 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 3 Aug 2026 17:23:37 +0000 Subject: [PATCH 4/7] test: update Linear env mock for integration policy --- apps/web/src/trpc/commands/linear/index.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index 2799424c6..f30b6839c 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -7,7 +7,7 @@ const { resolveDeploymentEnvVarMock, upsertDeploymentEnvironmentVariablesMock, } = vi.hoisted(() => ({ - envState: {} as Record, + envState: {} as Record, deletedConnectionsState: [] as Array<{ id: string }>, persistedEnvVarNamesState: [] as string[], deleteDeploymentEnvironmentVariablesMock: vi.fn(), @@ -16,7 +16,11 @@ const { upsertDeploymentEnvironmentVariablesMock: vi.fn(), })); -vi.mock('@/lib/server/env', () => ({ Env: envState })); +vi.mock('@/lib/server/env', () => ({ + Env: envState, + areCuratedIntegrationsEnabled: (value: string | boolean | undefined) => + value !== false && value !== 'false' && value !== '0', +})); vi.mock('@/lib/server/get-public-app-url', () => ({ getPublicAppUrl: () => 'https://roomote.example', })); From ffb8ed74d6f81648b3e2cf0e3364e779dae86527 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 3 Aug 2026 12:37:17 -0500 Subject: [PATCH 5/7] feat: disable curated integrations by default Flip R_CURATED_INTEGRATIONS_ENABLED to an opt-in policy so curated integrations stay off unless the operator explicitly enables them. --- .env.local.example | 4 +++ .env.production.example | 7 ++--- .../linear-routing-confirmation.test.ts | 1 + apps/docs/environment-variables.mdx | 2 +- apps/docs/integrations/index.mdx | 12 ++++----- .../lib/server/curated-integrations.test.ts | 9 ++++--- .../src/trpc/commands/linear/index.test.ts | 3 ++- docker-compose.production.yml | 2 +- docker-compose.self-host.yml | 2 +- packages/env/src/__tests__/index.test.ts | 11 ++++---- packages/env/src/index.ts | 27 +++++++------------ .../server/routers/linear-sessions.test.ts | 2 +- .../server/routers/mcp-connections.test.ts | 2 +- 13 files changed, 44 insertions(+), 40 deletions(-) diff --git a/.env.local.example b/.env.local.example index 19ffb0f6d..3b0ac6b72 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,6 +22,10 @@ # already have an auth token. # NGROK_AUTH_TOKEN=your-ngrok-token +# Curated integrations (Settings > Integrations) are disabled by default. +# Enable them locally so they can be configured and tested. +R_CURATED_INTEGRATIONS_ENABLED=true + # Optional: local integration credentials. Leave unset until the integration is # configured for this local instance. # R_ALLOWED_EMAILS=you@example.com,teammate@example.com diff --git a/.env.production.example b/.env.production.example index c8f6fa797..794cf9eea 100644 --- a/.env.production.example +++ b/.env.production.example @@ -143,9 +143,10 @@ DEFAULT_COMPUTE_PROVIDER=docker # GITLAB_WEBHOOK_SIGNING_TOKEN= # Optional integrations. -# Set to false to prevent curated integrations from being configured or used. -# Existing connections remain stored and become available again if re-enabled. -# R_CURATED_INTEGRATIONS_ENABLED=true +# Curated integrations are disabled by default. Set to true to allow them to +# be configured and used. Existing connections remain stored while disabled +# and become available again when re-enabled. +# R_CURATED_INTEGRATIONS_ENABLED=false # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts index 826b51aec..791cfd024 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts @@ -45,6 +45,7 @@ vi.mock('@roomote/env', async (importOriginal) => { R_APP_URL: 'https://app.roomote.example', PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', TRPC_URL: 'https://api.roomote.example', + R_CURATED_INTEGRATIONS_ENABLED: true, }, }; }); diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index ccab6077b..d6ca06e72 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -125,7 +125,7 @@ as per-task auth tokens or workspace paths. | `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. | | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | -| `R_CURATED_INTEGRATIONS_ENABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog. Defaults to `true`. Set to `false` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored and become available again if the policy is re-enabled. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | +| `R_CURATED_INTEGRATIONS_ENABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog. Defaults to `false`. Set to `true` and restart Roomote to allow those integrations to be configured and used. Existing connections remain stored while disabled and become available again when the policy is enabled. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 13e19139f..910594b33 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -14,12 +14,12 @@ This section does not list provider categories that have their own setup paths: communications providers, source-control providers, inference providers, and sandbox providers. Configure those from the provider-specific docs instead. -Deployment operators can prevent every integration in this curated catalog -from being configured or used by setting -`R_CURATED_INTEGRATIONS_ENABLED=false` and restarting Roomote. Existing -connections remain stored but inactive, so setting the value back to `true` -restores them. This policy does not affect the separate provider categories -above or MCP servers defined on an environment. +This curated catalog is disabled by default. Deployment operators enable it +by setting `R_CURATED_INTEGRATIONS_ENABLED=true` and restarting Roomote. +While disabled, none of these integrations can be configured or used; +existing connections remain stored but inactive and are restored when the +policy is enabled. This policy does not affect the separate provider +categories above or MCP servers defined on an environment. ## Connection patterns diff --git a/apps/web/src/lib/server/curated-integrations.test.ts b/apps/web/src/lib/server/curated-integrations.test.ts index e8a64896e..3d31ea1d7 100644 --- a/apps/web/src/lib/server/curated-integrations.test.ts +++ b/apps/web/src/lib/server/curated-integrations.test.ts @@ -4,12 +4,15 @@ import { } from './curated-integrations'; describe('assertCuratedIntegrationsEnabled', () => { - it('allows the default and enabled values', () => { - expect(() => assertCuratedIntegrationsEnabled(undefined)).not.toThrow(); + it('allows explicitly enabled values', () => { expect(() => assertCuratedIntegrationsEnabled(true)).not.toThrow(); + expect(() => assertCuratedIntegrationsEnabled('true')).not.toThrow(); }); - it('rejects operator-disabled integrations', () => { + it('rejects the default and disabled values', () => { + expect(() => assertCuratedIntegrationsEnabled(undefined)).toThrow( + CURATED_INTEGRATIONS_DISABLED_MESSAGE, + ); expect(() => assertCuratedIntegrationsEnabled(false)).toThrow( CURATED_INTEGRATIONS_DISABLED_MESSAGE, ); diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index f30b6839c..8fb9c7d6f 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -19,7 +19,7 @@ const { vi.mock('@/lib/server/env', () => ({ Env: envState, areCuratedIntegrationsEnabled: (value: string | boolean | undefined) => - value !== false && value !== 'false' && value !== '0', + value === true || value === 'true' || value === '1', })); vi.mock('@/lib/server/get-public-app-url', () => ({ getPublicAppUrl: () => 'https://roomote.example', @@ -83,6 +83,7 @@ describe('Linear OAuth setup', () => { for (const key of Object.keys(envState)) { delete envState[key]; } + envState.R_CURATED_INTEGRATIONS_ENABLED = true; deletedConnectionsState.splice(0); persistedEnvVarNamesState.splice(0); getPersistedEnvironmentVariableNamesMock.mockImplementation( diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 66f9d7349..aa59cb4a4 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -22,7 +22,7 @@ x-roomote-production-env: &roomote-production-env R_APP_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} - R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-true} + R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index f5bef26e8..45a143bd7 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -21,7 +21,7 @@ x-roomote-env: &roomote-env S3_BUCKET_ARTIFACTS: ${S3_BUCKET_ARTIFACTS:-roomote-artifacts} R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} - R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-true} + R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: ${PREVIEW_PROXY_BASE_URL:-http://localhost:18081} PREVIEW_DOMAINS: ${PREVIEW_DOMAINS:-localhost,127.0.0.1,roomotepreview.localhost} diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index b3541442a..fe2500681 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -200,21 +200,22 @@ describe('Env', () => { expect(isRoomoteCloudEnabled('false')).toBe(false); }); - it('enables curated integrations by default and accepts an operator override', () => { + it('disables curated integrations by default and accepts an operator opt-in', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; delete runtimeEnv.R_CURATED_INTEGRATIONS_ENABLED; expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_ENABLED).toBe( - true, + false, ); expect( createRoomoteEnv({ ...runtimeEnv, - R_CURATED_INTEGRATIONS_ENABLED: 'false', + R_CURATED_INTEGRATIONS_ENABLED: 'true', }).R_CURATED_INTEGRATIONS_ENABLED, - ).toBe(false); - expect(areCuratedIntegrationsEnabled(undefined)).toBe(true); + ).toBe(true); + expect(areCuratedIntegrationsEnabled(undefined)).toBe(false); + expect(areCuratedIntegrationsEnabled('1')).toBe(true); expect(areCuratedIntegrationsEnabled('0')).toBe(false); }); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index d09e641a6..d80e95e57 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -61,13 +61,6 @@ function optInBoolean() { .transform((value) => value === 'true' || value === '1'); } -function optOutBoolean() { - return z - .enum(['true', 'false', '1', '0']) - .default('true') - .transform((value) => value === 'true' || value === '1'); -} - const serverSchema = { R_APP_ENV: z.enum(['development', 'preview', 'production']).optional(), APP_ENV: z.enum(['development', 'preview', 'production']).optional(), @@ -125,9 +118,10 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), - // Operator policy for the curated Settings > Integrations catalog. Existing - // connections remain stored but cannot be configured or used while disabled. - R_CURATED_INTEGRATIONS_ENABLED: optOutBoolean(), + // Operator policy for the curated Settings > Integrations catalog. Disabled + // by default; operators opt in explicitly. Existing connections remain + // stored but cannot be configured or used while disabled. + R_CURATED_INTEGRATIONS_ENABLED: optInBoolean(), R_INTERCOM_APP_ID: z.string().min(1).optional(), R_POSTHOG_PROJECT_KEY: z.string().min(1).optional(), R_POSTHOG_HOST: z.string().url().optional(), @@ -576,20 +570,19 @@ export function isRoomoteCloudEnabled( ); } -/** Whether the operator permits curated integrations on this deployment. */ +/** + * Whether the operator permits curated integrations on this deployment. + * Disabled unless explicitly enabled. + */ export function areCuratedIntegrationsEnabled( value: string | boolean | undefined, ): boolean { - if (value === false) { - return false; - } - if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); - return normalized !== 'false' && normalized !== '0'; + return normalized === 'true' || normalized === '1'; } - return true; + return value === true; } /** diff --git a/packages/sdk/src/server/routers/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts index bd89d213c..74e7196cc 100644 --- a/packages/sdk/src/server/routers/linear-sessions.test.ts +++ b/packages/sdk/src/server/routers/linear-sessions.test.ts @@ -11,7 +11,7 @@ const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted( vi.mock('@roomote/env', () => ({ Env: envState, areCuratedIntegrationsEnabled: (value: boolean | undefined) => - value !== false, + value === true, })); vi.mock('@roomote/db/server', () => ({ diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 78aeec33c..f8a81f12d 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -7,7 +7,7 @@ const mockEnv = vi.hoisted(() => ({ vi.mock('@roomote/env', () => ({ Env: mockEnv, areCuratedIntegrationsEnabled: (value: boolean | undefined) => - value !== false, + value === true, })); const { From 609ca3a571399137be1bd984b33a1fa309e1b753 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 3 Aug 2026 12:50:31 -0500 Subject: [PATCH 6/7] test: fix formatting and enable integrations policy in route-policy test --- .../src/__tests__/route-policy-enforcement.test.ts | 11 +++++++++++ .../sdk/src/server/routers/linear-sessions.test.ts | 3 +-- .../sdk/src/server/routers/mcp-connections.test.ts | 3 +-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 85c1490c4..761a7ca74 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -7,6 +7,17 @@ const redisState = vi.hoisted(() => ({ shouldThrow: false, })); +// The curated-integrations policy defaults to disabled; enable it so webhook +// requests reach handler-level verification instead of the policy short-circuit. +vi.mock('@roomote/env', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + Env: { ...actual.Env, R_CURATED_INTEGRATIONS_ENABLED: true }, + }; +}); + vi.mock('@roomote/redis', async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/sdk/src/server/routers/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts index 74e7196cc..f86a4f784 100644 --- a/packages/sdk/src/server/routers/linear-sessions.test.ts +++ b/packages/sdk/src/server/routers/linear-sessions.test.ts @@ -10,8 +10,7 @@ const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted( vi.mock('@roomote/env', () => ({ Env: envState, - areCuratedIntegrationsEnabled: (value: boolean | undefined) => - value === true, + areCuratedIntegrationsEnabled: (value: boolean | undefined) => value === true, })); vi.mock('@roomote/db/server', () => ({ diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index f8a81f12d..64a536fb7 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -6,8 +6,7 @@ const mockEnv = vi.hoisted(() => ({ vi.mock('@roomote/env', () => ({ Env: mockEnv, - areCuratedIntegrationsEnabled: (value: boolean | undefined) => - value === true, + areCuratedIntegrationsEnabled: (value: boolean | undefined) => value === true, })); const { From 40e56e6a84b000c0d07496e52ac9de616060270a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 3 Aug 2026 13:13:57 -0500 Subject: [PATCH 7/7] feat: invert policy to R_CURATED_INTEGRATIONS_DISABLED opt-out Curated integrations stay enabled for all deployments by default; operators set R_CURATED_INTEGRATIONS_DISABLED=true to switch the catalog off. --- .env.local.example | 4 ---- .env.production.example | 7 +++---- .../__tests__/route-policy-enforcement.test.ts | 11 ----------- .../linear-active-run-priority.test.ts | 6 +++--- .../linear-routing-confirmation.test.ts | 1 - apps/api/src/handlers/linear/index.ts | 4 ++-- apps/api/src/handlers/mcp/index.ts | 4 ++-- apps/api/src/handlers/mcp/routing.ts | 4 ++-- apps/docs/environment-variables.mdx | 2 +- apps/docs/integrations/index.mdx | 12 ++++++------ .../mcp-oauth/callback/__tests__/route.test.ts | 2 +- .../src/app/api/mcp-oauth/callback/route.ts | 2 +- .../[connectionId]/__tests__/route.test.ts | 2 +- .../mcp-oauth/initiate/[connectionId]/route.ts | 2 +- .../app/api/mcp-oauth/replay/[token]/route.ts | 2 +- .../lib/server/curated-integrations.test.ts | 12 ++++++------ .../web/src/lib/server/curated-integrations.ts | 9 ++++++--- apps/web/src/lib/server/env.ts | 4 ++-- .../web/src/trpc/commands/linear/index.test.ts | 3 +-- .../src/trpc/commands/mcp-connections/index.ts | 6 ++++-- docker-compose.production.yml | 2 +- docker-compose.self-host.yml | 2 +- packages/env/src/__tests__/index.test.ts | 18 +++++++++--------- packages/env/src/index.ts | 12 ++++++------ .../src/server/routers/linear-sessions.test.ts | 9 +++++---- .../sdk/src/server/routers/linear-sessions.ts | 4 ++-- .../src/server/routers/mcp-connections.test.ts | 9 +++++---- .../sdk/src/server/routers/mcp-connections.ts | 6 +++--- 28 files changed, 75 insertions(+), 86 deletions(-) diff --git a/.env.local.example b/.env.local.example index 3b0ac6b72..19ffb0f6d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,10 +22,6 @@ # already have an auth token. # NGROK_AUTH_TOKEN=your-ngrok-token -# Curated integrations (Settings > Integrations) are disabled by default. -# Enable them locally so they can be configured and tested. -R_CURATED_INTEGRATIONS_ENABLED=true - # Optional: local integration credentials. Leave unset until the integration is # configured for this local instance. # R_ALLOWED_EMAILS=you@example.com,teammate@example.com diff --git a/.env.production.example b/.env.production.example index 794cf9eea..041299408 100644 --- a/.env.production.example +++ b/.env.production.example @@ -143,10 +143,9 @@ DEFAULT_COMPUTE_PROVIDER=docker # GITLAB_WEBHOOK_SIGNING_TOKEN= # Optional integrations. -# Curated integrations are disabled by default. Set to true to allow them to -# be configured and used. Existing connections remain stored while disabled -# and become available again when re-enabled. -# R_CURATED_INTEGRATIONS_ENABLED=false +# Set to true to prevent curated integrations from being configured or used. +# Existing connections remain stored and become available again once unset. +# R_CURATED_INTEGRATIONS_DISABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 761a7ca74..85c1490c4 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -7,17 +7,6 @@ const redisState = vi.hoisted(() => ({ shouldThrow: false, })); -// The curated-integrations policy defaults to disabled; enable it so webhook -// requests reach handler-level verification instead of the policy short-circuit. -vi.mock('@roomote/env', async (importOriginal) => { - const actual = await importOriginal(); - - return { - ...actual, - Env: { ...actual.Env, R_CURATED_INTEGRATIONS_ENABLED: true }, - }; -}); - vi.mock('@roomote/redis', async (importOriginal) => { const actual = await importOriginal(); diff --git a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts index 51a7d051c..ca119b35e 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts @@ -20,7 +20,7 @@ const envState = vi.hoisted(() => ({ R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret', R_APP_URL: 'https://app.roomote.example', PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', - R_CURATED_INTEGRATIONS_ENABLED: true, + R_CURATED_INTEGRATIONS_DISABLED: false, })); const { @@ -279,11 +279,11 @@ describe('CLO-1133: active task run takes priority over routing confirmation and app = new Hono(); app.route('/linear', linear); vi.clearAllMocks(); - envState.R_CURATED_INTEGRATIONS_ENABLED = true; + envState.R_CURATED_INTEGRATIONS_DISABLED = false; }); it('acknowledges without processing when curated integrations are disabled', async () => { - envState.R_CURATED_INTEGRATIONS_ENABLED = false; + envState.R_CURATED_INTEGRATIONS_DISABLED = true; const { rawBody, headers } = createSignedRequest(makePayload()); const response = await app.request('/linear', { diff --git a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts index 791cfd024..826b51aec 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-routing-confirmation.test.ts @@ -45,7 +45,6 @@ vi.mock('@roomote/env', async (importOriginal) => { R_APP_URL: 'https://app.roomote.example', PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', TRPC_URL: 'https://api.roomote.example', - R_CURATED_INTEGRATIONS_ENABLED: true, }, }; }); diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts index 6012b9ad0..f6a7cea4d 100644 --- a/apps/api/src/handlers/linear/index.ts +++ b/apps/api/src/handlers/linear/index.ts @@ -12,7 +12,7 @@ import { PRODUCT_NAME, restoreSnapshotResumeVisiblePromptFields, } from '@roomote/types'; -import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { type RoutingDebugInfo, enqueueTask, @@ -254,7 +254,7 @@ export const linear = new Hono(); * The handler must emit a "thought" activity within 10 seconds to acknowledge receipt. */ linear.post('/', async (c) => { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return c.body(null, 204); } diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index 5601c05f6..b71b3b316 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -1,5 +1,5 @@ import { Hono, type MiddlewareHandler } from 'hono'; -import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { isNativeMcpIntegration, MCP_INTEGRATIONS } from '@roomote/types'; import type { Variables } from '../../types'; @@ -23,7 +23,7 @@ export const mcp = new Hono<{ Variables: Variables }>(); const requireCuratedIntegrations: MiddlewareHandler<{ Variables: Variables; }> = async (c, next) => { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return c.notFound(); } diff --git a/apps/api/src/handlers/mcp/routing.ts b/apps/api/src/handlers/mcp/routing.ts index 31038552e..3210a0e86 100644 --- a/apps/api/src/handlers/mcp/routing.ts +++ b/apps/api/src/handlers/mcp/routing.ts @@ -1,5 +1,5 @@ import { Hono, type MiddlewareHandler } from 'hono'; -import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import type { Variables } from '../../types'; @@ -19,7 +19,7 @@ export const mcpRouting = new Hono<{ Variables: Variables }>(); const requireCuratedIntegrations: MiddlewareHandler<{ Variables: Variables; }> = async (c, next) => { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return c.notFound(); } diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index d6ca06e72..2f540d8d5 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -125,7 +125,7 @@ as per-task auth tokens or workspace paths. | `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. | | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | -| `R_CURATED_INTEGRATIONS_ENABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog. Defaults to `false`. Set to `true` and restart Roomote to allow those integrations to be configured and used. Existing connections remain stored while disabled and become available again when the policy is enabled. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | +| `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 910594b33..24fd1d9c9 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -14,12 +14,12 @@ This section does not list provider categories that have their own setup paths: communications providers, source-control providers, inference providers, and sandbox providers. Configure those from the provider-specific docs instead. -This curated catalog is disabled by default. Deployment operators enable it -by setting `R_CURATED_INTEGRATIONS_ENABLED=true` and restarting Roomote. -While disabled, none of these integrations can be configured or used; -existing connections remain stored but inactive and are restored when the -policy is enabled. This policy does not affect the separate provider -categories above or MCP servers defined on an environment. +Deployment operators can prevent every integration in this curated catalog +from being configured or used by setting +`R_CURATED_INTEGRATIONS_DISABLED=true` and restarting Roomote. Existing +connections remain stored but inactive, so unsetting the value restores +them. This policy does not affect the separate provider categories above or +MCP servers defined on an environment. ## Connection patterns diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts index 8deb543f8..b6bc24a51 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts @@ -175,7 +175,7 @@ describe('GET /api/mcp-oauth/callback', () => { bootstrapWebRuntimeEnvMock.mockResolvedValue({ R_APP_URL: 'http://localhost:13000', R_PUBLIC_URL: 'https://customer.example', - R_CURATED_INTEGRATIONS_ENABLED: false, + R_CURATED_INTEGRATIONS_DISABLED: true, }); const response = await GET(buildRequest('?code=auth-code&state=state-1')); diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts index c649b27d3..1a3e11550 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/route.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts @@ -166,7 +166,7 @@ export async function GET(request: NextRequest) { const redirectToResult = (result: McpOAuthResult) => redirectWithMcpResult(webUrl, redirectPath, result, state); - if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { return redirectToResult({ status: 'error', reason: 'callback_failed' }); } diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index 43602dfad..2c4201233 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -185,7 +185,7 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { bootstrapWebRuntimeEnvMock.mockResolvedValue({ R_APP_URL: 'http://localhost:13000', R_PUBLIC_URL: 'https://customer.example', - R_CURATED_INTEGRATIONS_ENABLED: false, + R_CURATED_INTEGRATIONS_DISABLED: true, }); const response = await GET(buildRequest(), { diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts index 6410606ee..638f6d120 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts @@ -160,7 +160,7 @@ export async function GET( DEFAULT_REDIRECT_PATH; const replayToken = requestUrl.searchParams.get('replayToken'); - if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { return NextResponse.redirect( withMcpQuery(webUrl, redirectPath, 'error', 'disabled'), ); diff --git a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts index b2ebd6714..05cb74077 100644 --- a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts @@ -28,7 +28,7 @@ export async function GET( const webUrl = getPublicAppUrl(webEnv); const { token } = await params; - if (webEnv.R_CURATED_INTEGRATIONS_ENABLED === false) { + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { return NextResponse.redirect( new URL('/error?message=Integrations are disabled', webUrl), ); diff --git a/apps/web/src/lib/server/curated-integrations.test.ts b/apps/web/src/lib/server/curated-integrations.test.ts index 3d31ea1d7..23a090174 100644 --- a/apps/web/src/lib/server/curated-integrations.test.ts +++ b/apps/web/src/lib/server/curated-integrations.test.ts @@ -4,16 +4,16 @@ import { } from './curated-integrations'; describe('assertCuratedIntegrationsEnabled', () => { - it('allows explicitly enabled values', () => { - expect(() => assertCuratedIntegrationsEnabled(true)).not.toThrow(); - expect(() => assertCuratedIntegrationsEnabled('true')).not.toThrow(); + it('allows the default and non-disabled values', () => { + expect(() => assertCuratedIntegrationsEnabled(undefined)).not.toThrow(); + expect(() => assertCuratedIntegrationsEnabled(false)).not.toThrow(); }); - it('rejects the default and disabled values', () => { - expect(() => assertCuratedIntegrationsEnabled(undefined)).toThrow( + it('rejects operator-disabled integrations', () => { + expect(() => assertCuratedIntegrationsEnabled(true)).toThrow( CURATED_INTEGRATIONS_DISABLED_MESSAGE, ); - expect(() => assertCuratedIntegrationsEnabled(false)).toThrow( + expect(() => assertCuratedIntegrationsEnabled('true')).toThrow( CURATED_INTEGRATIONS_DISABLED_MESSAGE, ); }); diff --git a/apps/web/src/lib/server/curated-integrations.ts b/apps/web/src/lib/server/curated-integrations.ts index bf180e598..558c03920 100644 --- a/apps/web/src/lib/server/curated-integrations.ts +++ b/apps/web/src/lib/server/curated-integrations.ts @@ -1,12 +1,15 @@ -import { Env, areCuratedIntegrationsEnabled } from './env'; +import { Env, areCuratedIntegrationsDisabled } from './env'; export const CURATED_INTEGRATIONS_DISABLED_MESSAGE = 'Integrations are disabled by the deployment operator.'; export function assertCuratedIntegrationsEnabled( - value: string | boolean | undefined = Env.R_CURATED_INTEGRATIONS_ENABLED, + disabledFlag: + | string + | boolean + | undefined = Env.R_CURATED_INTEGRATIONS_DISABLED, ) { - if (!areCuratedIntegrationsEnabled(value)) { + if (areCuratedIntegrationsDisabled(disabledFlag)) { throw new Error(CURATED_INTEGRATIONS_DISABLED_MESSAGE); } } diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index af358602e..f988cd908 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import * as dotenvx from '@dotenvx/dotenvx'; import { - areCuratedIntegrationsEnabled, + areCuratedIntegrationsDisabled, createRoomoteEnv, getAllowedDevOrigins as getSharedAllowedDevOrigins, getArtifactSigningKey, @@ -196,7 +196,7 @@ function getWebRuntimeEnv(): RoomoteEnv { } export { - areCuratedIntegrationsEnabled, + areCuratedIntegrationsDisabled, getArtifactSigningKey, getArtifactSigningKeyPrevious, getBetterAuthSecret, diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts index 8fb9c7d6f..ca32d9429 100644 --- a/apps/web/src/trpc/commands/linear/index.test.ts +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -18,7 +18,7 @@ const { vi.mock('@/lib/server/env', () => ({ Env: envState, - areCuratedIntegrationsEnabled: (value: string | boolean | undefined) => + areCuratedIntegrationsDisabled: (value: string | boolean | undefined) => value === true || value === 'true' || value === '1', })); vi.mock('@/lib/server/get-public-app-url', () => ({ @@ -83,7 +83,6 @@ describe('Linear OAuth setup', () => { for (const key of Object.keys(envState)) { delete envState[key]; } - envState.R_CURATED_INTEGRATIONS_ENABLED = true; deletedConnectionsState.splice(0); persistedEnvVarNamesState.splice(0); getPersistedEnvironmentVariableNamesMock.mockImplementation( diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 3598daa12..5d12237bf 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -34,7 +34,7 @@ import { getValidAccessToken } from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; import type { StaticOauthReadiness } from '@/lib/server/mcp-static-oauth'; import { getDeploymentStaticOauthReadiness } from '@/lib/server/deployment-static-oauth'; -import { Env, areCuratedIntegrationsEnabled } from '@/lib/server/env'; +import { Env, areCuratedIntegrationsDisabled } from '@/lib/server/env'; import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrations'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { @@ -524,7 +524,9 @@ export async function getDeploymentMcpEnablementsCommand( export function getCuratedIntegrationsAvailabilityCommand() { return { - enabled: areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED), + enabled: !areCuratedIntegrationsDisabled( + Env.R_CURATED_INTEGRATIONS_DISABLED, + ), }; } diff --git a/docker-compose.production.yml b/docker-compose.production.yml index aa59cb4a4..994a91aa9 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -22,7 +22,7 @@ x-roomote-production-env: &roomote-production-env R_APP_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} - R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-false} + R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 45a143bd7..b2f30730e 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -21,7 +21,7 @@ x-roomote-env: &roomote-env S3_BUCKET_ARTIFACTS: ${S3_BUCKET_ARTIFACTS:-roomote-artifacts} R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} - R_CURATED_INTEGRATIONS_ENABLED: ${R_CURATED_INTEGRATIONS_ENABLED:-false} + R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: ${PREVIEW_PROXY_BASE_URL:-http://localhost:18081} PREVIEW_DOMAINS: ${PREVIEW_DOMAINS:-localhost,127.0.0.1,roomotepreview.localhost} diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index fe2500681..98a991ae6 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { Env, - areCuratedIntegrationsEnabled, + areCuratedIntegrationsDisabled, assertSecureBootBinding, createRoomoteEnv, getActiveInsecureLocalSecrets, @@ -200,23 +200,23 @@ describe('Env', () => { expect(isRoomoteCloudEnabled('false')).toBe(false); }); - it('disables curated integrations by default and accepts an operator opt-in', () => { + it('enables curated integrations by default and accepts an operator opt-out', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; - delete runtimeEnv.R_CURATED_INTEGRATIONS_ENABLED; + delete runtimeEnv.R_CURATED_INTEGRATIONS_DISABLED; - expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_ENABLED).toBe( + expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_DISABLED).toBe( false, ); expect( createRoomoteEnv({ ...runtimeEnv, - R_CURATED_INTEGRATIONS_ENABLED: 'true', - }).R_CURATED_INTEGRATIONS_ENABLED, + R_CURATED_INTEGRATIONS_DISABLED: 'true', + }).R_CURATED_INTEGRATIONS_DISABLED, ).toBe(true); - expect(areCuratedIntegrationsEnabled(undefined)).toBe(false); - expect(areCuratedIntegrationsEnabled('1')).toBe(true); - expect(areCuratedIntegrationsEnabled('0')).toBe(false); + expect(areCuratedIntegrationsDisabled(undefined)).toBe(false); + expect(areCuratedIntegrationsDisabled('1')).toBe(true); + expect(areCuratedIntegrationsDisabled('0')).toBe(false); }); it('accepts valid Ping instance IDs and rejects invalid ones', () => { diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index d80e95e57..7fce23605 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -118,10 +118,10 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), - // Operator policy for the curated Settings > Integrations catalog. Disabled - // by default; operators opt in explicitly. Existing connections remain + // Operator policy for the curated Settings > Integrations catalog. Enabled + // by default; operators opt out explicitly. Existing connections remain // stored but cannot be configured or used while disabled. - R_CURATED_INTEGRATIONS_ENABLED: optInBoolean(), + R_CURATED_INTEGRATIONS_DISABLED: optInBoolean(), R_INTERCOM_APP_ID: z.string().min(1).optional(), R_POSTHOG_PROJECT_KEY: z.string().min(1).optional(), R_POSTHOG_HOST: z.string().url().optional(), @@ -571,10 +571,10 @@ export function isRoomoteCloudEnabled( } /** - * Whether the operator permits curated integrations on this deployment. - * Disabled unless explicitly enabled. + * Whether the operator has switched off curated integrations on this + * deployment. Enabled unless explicitly disabled. */ -export function areCuratedIntegrationsEnabled( +export function areCuratedIntegrationsDisabled( value: string | boolean | undefined, ): boolean { if (typeof value === 'string') { diff --git a/packages/sdk/src/server/routers/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts index f86a4f784..7a49875a1 100644 --- a/packages/sdk/src/server/routers/linear-sessions.test.ts +++ b/packages/sdk/src/server/routers/linear-sessions.test.ts @@ -2,7 +2,7 @@ import type { AuthTokenContext } from '@roomote/types'; const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted( () => ({ - envState: { R_CURATED_INTEGRATIONS_ENABLED: true }, + envState: { R_CURATED_INTEGRATIONS_DISABLED: false }, findConnectionMock: vi.fn(), getValidAccessTokenMock: vi.fn(), }), @@ -10,7 +10,8 @@ const { envState, findConnectionMock, getValidAccessTokenMock } = vi.hoisted( vi.mock('@roomote/env', () => ({ Env: envState, - areCuratedIntegrationsEnabled: (value: boolean | undefined) => value === true, + areCuratedIntegrationsDisabled: (value: boolean | undefined) => + value === true, })); vi.mock('@roomote/db/server', () => ({ @@ -48,11 +49,11 @@ function createCaller() { describe('linearSessionsRouter operator policy', () => { beforeEach(() => { vi.clearAllMocks(); - envState.R_CURATED_INTEGRATIONS_ENABLED = true; + envState.R_CURATED_INTEGRATIONS_DISABLED = false; }); it('blocks existing Linear sessions when curated integrations are disabled', async () => { - envState.R_CURATED_INTEGRATIONS_ENABLED = false; + envState.R_CURATED_INTEGRATIONS_DISABLED = true; await expect(createCaller().hasActiveConnection()).resolves.toBe(false); await expect( diff --git a/packages/sdk/src/server/routers/linear-sessions.ts b/packages/sdk/src/server/routers/linear-sessions.ts index 44f583038..392ad5840 100644 --- a/packages/sdk/src/server/routers/linear-sessions.ts +++ b/packages/sdk/src/server/routers/linear-sessions.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { TaskPayloadKind } from '@roomote/types'; import { db, taskRuns, eq } from '@roomote/db/server'; @@ -22,7 +22,7 @@ import { } from '../trpc'; async function findActiveConnection() { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return null; } diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 64a536fb7..80ee051c6 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -1,12 +1,13 @@ import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; const mockEnv = vi.hoisted(() => ({ - R_CURATED_INTEGRATIONS_ENABLED: true, + R_CURATED_INTEGRATIONS_DISABLED: false, })); vi.mock('@roomote/env', () => ({ Env: mockEnv, - areCuratedIntegrationsEnabled: (value: boolean | undefined) => value === true, + areCuratedIntegrationsDisabled: (value: boolean | undefined) => + value === true, })); const { @@ -187,7 +188,7 @@ function buildEnabledOnlyRow(mcpId: string) { describe('mcpConnectionsRouter.getMcpServerConfigs', () => { beforeEach(() => { vi.clearAllMocks(); - mockEnv.R_CURATED_INTEGRATIONS_ENABLED = true; + mockEnv.R_CURATED_INTEGRATIONS_DISABLED = false; mockFindTaskRun.mockResolvedValue({ actingUserId: null, }); @@ -197,7 +198,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }); it('returns no curated servers when the operator disables integrations', async () => { - mockEnv.R_CURATED_INTEGRATIONS_ENABLED = false; + mockEnv.R_CURATED_INTEGRATIONS_DISABLED = true; const result = await createCaller().getMcpServerConfigs(); diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 543c2e34f..ac82b931b 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { Env, areCuratedIntegrationsEnabled } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { db, desc, @@ -65,7 +65,7 @@ export const mcpConnectionsRouter = router({ }), ) .query(async ({ input }) => { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return false; } @@ -138,7 +138,7 @@ export const mcpConnectionsRouter = router({ * Returns a map of sanitized server names to { url, headers }. */ getMcpServerConfigs: authenticatedProcedure.query(async ({ ctx }) => { - if (!areCuratedIntegrationsEnabled(Env.R_CURATED_INTEGRATIONS_ENABLED)) { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { return { servers: {} }; }