diff --git a/.env.production.example b/.env.production.example index b1354fd8f..041299408 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 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/handlers/linear/__tests__/linear-active-run-priority.test.ts b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts index 0325079b0..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 @@ -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_DISABLED: false, +})); + 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_DISABLED = false; + }); + + it('acknowledges without processing when curated integrations are disabled', async () => { + envState.R_CURATED_INTEGRATIONS_DISABLED = true; + 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..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 } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } 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 (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + 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..b71b3b316 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, areCuratedIntegrationsDisabled } 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 (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + 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/api/src/handlers/mcp/routing.ts b/apps/api/src/handlers/mcp/routing.ts index 5b41be4f3..3210a0e86 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, areCuratedIntegrationsDisabled } 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 (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return c.notFound(); + } + + await next(); +}; + mcpRouting.route('/roomote', roomoteMcp); +mcpRouting.use('/linear', requireCuratedIntegrations); +mcpRouting.use('/linear/*', requireCuratedIntegrations); mcpRouting.route( '/linear', createLinearMcp({ diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 6ee6f6445..2f540d8d5 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_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 afecc1d97..24fd1d9c9 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_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 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..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 @@ -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_DISABLED: true, + }); + + 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..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,6 +166,10 @@ export async function GET(request: NextRequest) { const redirectToResult = (result: McpOAuthResult) => redirectWithMcpResult(webUrl, redirectPath, result, state); + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { + 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..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 @@ -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_DISABLED: true, + }); + + 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..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,6 +160,12 @@ export async function GET( DEFAULT_REDIRECT_PATH; const replayToken = requestUrl.searchParams.get('replayToken'); + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { + 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..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 @@ -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_DISABLED === true) { + 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 45563600c..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; @@ -192,6 +193,9 @@ vi.mock('@/hooks/linear', () => ({ })); vi.mock('@/hooks/mcp-connections', () => ({ + useCuratedIntegrationsAvailability: () => ({ + data: { enabled: state.integrationsEnabled }, + }), useDeploymentMcpEnablements: () => ({ data: state.deploymentEnablements, }), @@ -429,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; @@ -855,6 +860,22 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows operator policy instead of integration controls when disabled', () => { + state.integrationsEnabled = false; + + render(); + + expect( + screen.getByText('Integrations disabled by deployment operator'), + ).toBeInTheDocument(); + expect( + 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', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index ef159afd2..d1850f2c5 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, + useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, useDeploymentMcpEnablements, @@ -43,6 +44,9 @@ import { } from '@/types'; import { + Alert, + AlertDescription, + AlertTitle, BasicTooltip, Button, Card, @@ -1148,6 +1152,7 @@ export function Integrations() { const disconnectLinear = useDisconnectLinear(); const deploymentEnablements = useDeploymentMcpEnablements(); + const integrationsAvailability = useCuratedIntegrationsAvailability(); const oauthReadiness = useMcpOauthReadiness(); const linearOauthStatus = oauthReadiness.data?.find( (entry) => entry.mcpId === 'linear', @@ -1988,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 (
{ + it('allows the default and non-disabled values', () => { + expect(() => assertCuratedIntegrationsEnabled(undefined)).not.toThrow(); + expect(() => assertCuratedIntegrationsEnabled(false)).not.toThrow(); + }); + + it('rejects operator-disabled integrations', () => { + expect(() => assertCuratedIntegrationsEnabled(true)).toThrow( + CURATED_INTEGRATIONS_DISABLED_MESSAGE, + ); + 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 new file mode 100644 index 000000000..558c03920 --- /dev/null +++ b/apps/web/src/lib/server/curated-integrations.ts @@ -0,0 +1,15 @@ +import { Env, areCuratedIntegrationsDisabled } from './env'; + +export const CURATED_INTEGRATIONS_DISABLED_MESSAGE = + 'Integrations are disabled by the deployment operator.'; + +export function assertCuratedIntegrationsEnabled( + disabledFlag: + | string + | boolean + | undefined = Env.R_CURATED_INTEGRATIONS_DISABLED, +) { + 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 5904b2348..f988cd908 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 { + areCuratedIntegrationsDisabled, createRoomoteEnv, getAllowedDevOrigins as getSharedAllowedDevOrigins, getArtifactSigningKey, @@ -195,6 +196,7 @@ function getWebRuntimeEnv(): RoomoteEnv { } export { + 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 2799424c6..ca32d9429 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, + areCuratedIntegrationsDisabled: (value: string | boolean | undefined) => + value === true || value === 'true' || value === '1', +})); vi.mock('@/lib/server/get-public-app-url', () => ({ getPublicAppUrl: () => 'https://roomote.example', })); 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/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 30d5d799e..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,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, 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 { SaveAsanaConnectionInput, @@ -521,6 +522,14 @@ export async function getDeploymentMcpEnablementsCommand( }); } +export function getCuratedIntegrationsAvailabilityCommand() { + return { + enabled: !areCuratedIntegrationsDisabled( + Env.R_CURATED_INTEGRATIONS_DISABLED, + ), + }; +} + /** * Return public-safe OAuth setup status for integrations that require a * deployment-configured client. Credential names and values never leave the @@ -553,6 +562,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}`); } @@ -788,6 +801,7 @@ export async function saveSnowflakeConnectionCommand( input: SaveSnowflakeConnectionCommandInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -937,6 +951,7 @@ export async function saveAsanaConnectionCommand( input: SaveAsanaConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1020,6 +1035,7 @@ export async function saveVercelConnectionCommand( input: SaveVercelConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1107,6 +1123,7 @@ export async function saveGrafanaConnectionCommand( input: SaveGrafanaConnectionInput, ) { assertAdmin(auth); + assertCuratedIntegrationsEnabled(); const existingConnection = await db.query.mcpConnections.findFirst({ where: and( @@ -1192,6 +1209,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}`); @@ -1218,6 +1236,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}`); @@ -1259,6 +1278,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 958b0180c..f3c18f437 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -190,6 +190,7 @@ import { } from '../commands/sandbox-session'; import { getDeploymentMcpEnablementsCommand, + getCuratedIntegrationsAvailabilityCommand, getMcpOauthReadinessCommand, setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, @@ -1542,6 +1543,10 @@ export const appRouter = createRouter({ }), mcpConnections: createRouter({ + availability: protectedProcedure.query(() => + getCuratedIntegrationsAvailabilityCommand(), + ), + deploymentEnablements: protectedProcedure.query(({ ctx: { auth } }) => getDeploymentMcpEnablementsCommand(auth), ), diff --git a/docker-compose.production.yml b/docker-compose.production.yml index f9c377755..994a91aa9 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_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 7565540c8..b2f30730e 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_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 ce498505e..98a991ae6 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { Env, + areCuratedIntegrationsDisabled, assertSecureBootBinding, createRoomoteEnv, getActiveInsecureLocalSecrets, @@ -199,6 +200,25 @@ describe('Env', () => { expect(isRoomoteCloudEnabled('false')).toBe(false); }); + 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_DISABLED; + + expect(createRoomoteEnv(runtimeEnv).R_CURATED_INTEGRATIONS_DISABLED).toBe( + false, + ); + expect( + createRoomoteEnv({ + ...runtimeEnv, + R_CURATED_INTEGRATIONS_DISABLED: 'true', + }).R_CURATED_INTEGRATIONS_DISABLED, + ).toBe(true); + 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', () => { 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..7fce23605 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -118,6 +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. Enabled + // by default; operators opt out explicitly. Existing connections remain + // stored but cannot be configured or used while disabled. + 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(), @@ -566,6 +570,21 @@ export function isRoomoteCloudEnabled( ); } +/** + * Whether the operator has switched off curated integrations on this + * deployment. Enabled unless explicitly disabled. + */ +export function areCuratedIntegrationsDisabled( + value: string | boolean | undefined, +): boolean { + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === '1'; + } + + return value === 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/linear-sessions.test.ts b/packages/sdk/src/server/routers/linear-sessions.test.ts new file mode 100644 index 000000000..7a49875a1 --- /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_DISABLED: false }, + findConnectionMock: vi.fn(), + getValidAccessTokenMock: vi.fn(), + }), +); + +vi.mock('@roomote/env', () => ({ + Env: envState, + areCuratedIntegrationsDisabled: (value: boolean | undefined) => + value === true, +})); + +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_DISABLED = false; + }); + + it('blocks existing Linear sessions when curated integrations are disabled', async () => { + envState.R_CURATED_INTEGRATIONS_DISABLED = true; + + 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..392ad5840 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, areCuratedIntegrationsDisabled } 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 (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return null; + } + return findLinearDeploymentMcpConnection(); } diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index aa70944dd..80ee051c6 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_DISABLED: false, +})); + +vi.mock('@roomote/env', () => ({ + Env: mockEnv, + areCuratedIntegrationsDisabled: (value: boolean | undefined) => + value === true, +})); + const { mockFindTaskRun, mockFindEnablements, @@ -178,6 +188,7 @@ function buildEnabledOnlyRow(mcpId: string) { describe('mcpConnectionsRouter.getMcpServerConfigs', () => { beforeEach(() => { vi.clearAllMocks(); + mockEnv.R_CURATED_INTEGRATIONS_DISABLED = false; 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_DISABLED = true; + + 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..ac82b931b 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, areCuratedIntegrationsDisabled } from '@roomote/env'; import { db, desc, @@ -64,6 +65,10 @@ export const mcpConnectionsRouter = router({ }), ) .query(async ({ input }) => { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + 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 (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return { servers: {} }; + } + const actorContext = await resolveActorScopedUserContext(ctx.auth); const connectionFilters = [];