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 213e58fd8..8afb86e0f 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 @@ -13,6 +13,7 @@ const { findLinearUserMcpConnectionByIdentityMock, getValidAccessTokenMock, createMcpOauthReplayMock, + resolveDeploymentEnvVarMock, } = vi.hoisted(() => ({ redisMock: { eval: vi.fn().mockResolvedValue(null), @@ -31,6 +32,7 @@ const { findLinearUserMcpConnectionByIdentityMock: vi.fn(), getValidAccessTokenMock: vi.fn(), createMcpOauthReplayMock: vi.fn(), + resolveDeploymentEnvVarMock: vi.fn().mockResolvedValue('test-linear-secret'), })); vi.mock('@roomote/env', async (importOriginal) => { @@ -154,6 +156,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { }, linearAuthTokens: {}, webhooks: { id: 'id', deliveryId: 'deliveryId' }, + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, eq: vi.fn(), and: vi.fn(), }; @@ -293,6 +296,11 @@ describe('linear routed task startup', () => { ); expect(response.status).toBe(200); + expect(resolveDeploymentEnvVarMock).toHaveBeenCalledWith( + 'R_LINEAR_WEBHOOK_SECRET', + db, + { R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret' }, + ); expect(emitThought).toHaveBeenCalledWith( 'session-1', 'Getting started...', diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts index 34d15a0b0..9295dd730 100644 --- a/apps/api/src/handlers/linear/index.ts +++ b/apps/api/src/handlers/linear/index.ts @@ -23,7 +23,11 @@ import { import { buildTaskStartingText } from '@roomote/communication/chat-messages'; import { getRedis } from '@roomote/redis'; import { postRouterDebugMessage } from '@roomote/slack'; -import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server'; +import { + db, + resolveDeploymentEnvVar, + setTrustedRunActingUserOnSuccess, +} from '@roomote/db/server'; import { createMcpOauthReplay, findLinearDeploymentMcpConnectionByIdentity, @@ -302,7 +306,11 @@ linear.post('/', async (c) => { // Verify webhook signature const signature = headers['linear-signature'] ?? ''; - const webhookSecret = Env.R_LINEAR_WEBHOOK_SECRET; + const webhookSecret = await resolveDeploymentEnvVar( + 'R_LINEAR_WEBHOOK_SECRET', + db, + { R_LINEAR_WEBHOOK_SECRET: Env.R_LINEAR_WEBHOOK_SECRET }, + ); if (!webhookSecret) { console.error('[LinearWebhook] R_LINEAR_WEBHOOK_SECRET not configured'); diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 64aac38ab..c1cb5ed0e 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -14,16 +14,20 @@ priority, or discussion. ## Setup -For self-hosted deployments, create a Linear OAuth application before -connecting the workspace. Configure its callback as -`/api/mcp-oauth/callback`, then set -`R_LINEAR_CLIENT_ID`, `R_LINEAR_CLIENT_SECRET`, and -`R_LINEAR_WEBHOOK_SECRET` on the Roomote deployment. - - - In Roomote, go to **Settings > Integrations** and connect your Linear - workspace. + + On a self-hosted deployment, an administrator goes to **Settings > + Integrations**, selects **Set it up**, and then selects **Create Linear + app** to open the pre-filled manifest. The app name defaults to the same + `roomote-` convention used by the GitHub setup. After + creating the private app, copy its client ID, client secret, and webhook + secret back into Roomote. Roomote encrypts the saved credentials. + + + Select **Enable Linear**, approve the app in Linear, and return to Roomote. + Deployments that provide `R_LINEAR_CLIENT_ID`, + `R_LINEAR_CLIENT_SECRET`, and `R_LINEAR_WEBHOOK_SECRET` in the runtime + environment continue to use those values and skip the in-app setup. Link your Linear identity when prompted so Roomote can associate issue 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 a0d53adb3..ff75e25e7 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 @@ -18,7 +18,7 @@ const { isSelfServeMcpIntegrationMock, mcpConnectionsFindFirstMock, registerOAuthClientMock, - resolveStaticOauthClientInformationMock, + resolveDeploymentStaticOauthClientInformationMock, storeClientInformationMock, storeOAuthStateWithIdMock, } = vi.hoisted(() => ({ @@ -41,7 +41,7 @@ const { isSelfServeMcpIntegrationMock: vi.fn(), mcpConnectionsFindFirstMock: vi.fn(), registerOAuthClientMock: vi.fn(), - resolveStaticOauthClientInformationMock: vi.fn(), + resolveDeploymentStaticOauthClientInformationMock: vi.fn(), storeClientInformationMock: vi.fn(), storeOAuthStateWithIdMock: vi.fn(), })); @@ -54,8 +54,9 @@ vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ bootstrapWebRuntimeEnv: bootstrapWebRuntimeEnvMock, })); -vi.mock('@/lib/server/mcp-static-oauth', () => ({ - resolveStaticOauthClientInformation: resolveStaticOauthClientInformationMock, +vi.mock('@/lib/server/deployment-static-oauth', () => ({ + resolveDeploymentStaticOauthClientInformation: + resolveDeploymentStaticOauthClientInformationMock, })); vi.mock('@roomote/db/server', () => ({ @@ -153,7 +154,9 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { generateCodeChallengeMock.mockResolvedValue('challenge-value'); generateStateMock.mockReturnValue('state-value'); storeOAuthStateWithIdMock.mockResolvedValue(undefined); - resolveStaticOauthClientInformationMock.mockReturnValue(undefined); + resolveDeploymentStaticOauthClientInformationMock.mockResolvedValue( + undefined, + ); registerOAuthClientMock.mockResolvedValue({ client_id: 'fresh-client', }); @@ -217,7 +220,7 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { getMcpIntegrationOauthScopesMock.mockReturnValue(['read', 'write']); getMcpIntegrationOauthScopeSeparatorMock.mockReturnValue(','); getClientInformationMock.mockResolvedValue(undefined); - resolveStaticOauthClientInformationMock.mockReturnValue({ + resolveDeploymentStaticOauthClientInformationMock.mockResolvedValue({ client_id: 'linear-client', client_secret: 'linear-secret', token_endpoint_auth_method: 'client_secret_post', @@ -240,6 +243,35 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { expect(registerOAuthClientMock).not.toHaveBeenCalled(); }); + it('replaces a legacy registered client with configured deployment credentials', async () => { + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://linear.app/oauth/authorize', + tokenEndpoint: 'https://api.linear.app/oauth/token', + }); + getClientInformationMock.mockResolvedValue({ + client_id: 'legacy-dynamic-client', + client_secret: 'legacy-secret', + }); + resolveDeploymentStaticOauthClientInformationMock.mockResolvedValue({ + client_id: 'configured-client', + client_secret: 'configured-secret', + token_endpoint_auth_method: 'client_secret_post', + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + expect(getClientInformationMock).not.toHaveBeenCalled(); + expect(storeClientInformationMock).toHaveBeenCalledWith( + CONNECTION_ID, + expect.objectContaining({ client_id: 'configured-client' }), + PUBLIC_CALLBACK, + ); + const authUrl = new URL(response.headers.get('location')!); + expect(authUrl.searchParams.get('client_id')).toBe('configured-client'); + }); + it('re-registers when stored client was registered against a different callback', async () => { getClientInformationMock.mockResolvedValue(undefined); 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 60271b41a..126234dd9 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 @@ -34,7 +34,7 @@ import { import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; -import { resolveStaticOauthClientInformation } from '@/lib/server/mcp-static-oauth'; +import { resolveDeploymentStaticOauthClientInformation } from '@/lib/server/deployment-static-oauth'; export const runtime = 'nodejs'; @@ -147,13 +147,6 @@ function getOAuthServerMetadataOverride( }; } -function getStaticClientInformation( - env: unknown, - integration: McpIntegration, -): OAuthClientInformation | undefined { - return resolveStaticOauthClientInformation(env, integration); -} - export async function GET( request: Request, { params }: { params: Promise<{ connectionId: string }> }, @@ -229,22 +222,20 @@ export async function GET( connection.connectionRole, ); - let clientInfo: OAuthClientInformation | undefined = - await getClientInformation(connectionId, { + const staticClientInfo = + await resolveDeploymentStaticOauthClientInformation(webEnv, integration); + let clientInfo: OAuthClientInformation | undefined; + + if (staticClientInfo) { + // Configured deployment credentials are authoritative. Replacing the + // stored client also migrates connections created by the old dynamic + // registration flow before their next authorization or token refresh. + await storeClientInformation(connectionId, staticClientInfo, redirectUri); + clientInfo = staticClientInfo; + } else { + clientInfo = await getClientInformation(connectionId, { expectedRedirectUri: redirectUri, }); - - if (!clientInfo) { - const staticClientInfo = getStaticClientInformation(webEnv, integration); - - if (staticClientInfo) { - await storeClientInformation( - connectionId, - staticClientInfo, - redirectUri, - ); - clientInfo = staticClientInfo; - } } if (!clientInfo && serverMetadata.registration_endpoint) { diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index a0a6ba42b..6ebbe083e 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -57,6 +57,16 @@ const state = vi.hoisted(() => ({ linearInstallation: { linearOrganizationName: 'Roomote', } as null | { linearOrganizationName?: string }, + linearOauthSetup: { + callbackUrl: 'https://roomote.example/api/mcp-oauth/callback', + webhookUrl: 'https://roomote.example/api/webhooks/linear', + manifestUrl: 'https://linear.app/settings/api/applications/new?manifest=x', + fields: { + clientId: { configured: false, managedByEnvironment: false }, + clientSecret: { configured: false, managedByEnvironment: false }, + webhookSecret: { configured: false, managedByEnvironment: false }, + }, + }, linearRedirectPath: '', searchParams: '', })); @@ -73,6 +83,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ saveGrafanaConnection: vi.fn(), saveSnowflakeConnection: vi.fn(), saveVercelConnection: vi.fn(), + saveLinearOauthSetup: vi.fn(), }, selectMock: { latestOnValueChange: null as null | ((value: string) => void), @@ -156,6 +167,14 @@ vi.mock('@/hooks/linear', () => ({ isPending: false, mutate: mutations.disconnectLinear, }), + useLinearOauthSetup: () => ({ + data: state.linearOauthSetup, + isPending: false, + }), + useSaveLinearOauthSetup: () => ({ + isPending: false, + mutate: mutations.saveLinearOauthSetup, + }), })); vi.mock('@/hooks/mcp-connections', () => ({ @@ -254,11 +273,18 @@ vi.mock('@/components/system', () => ({ BrandIcon: ({ name }: { name: string }) => ( ), - Button: ({ children, ...props }: ButtonHTMLAttributes) => ( - - ), + Button: ({ + children, + asChild, + ...props + }: ButtonHTMLAttributes & { asChild?: boolean }) => + asChild ? ( + children + ) : ( + + ), Card: ({ children, ...props @@ -270,6 +296,7 @@ vi.mock('@/components/system', () => ({ CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, Check: () => , + Copy: () => , Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
{children}
: null, DialogContent: ({ children }: { children: ReactNode }) => ( @@ -288,6 +315,7 @@ vi.mock('@/components/system', () => ({ Eye: () => , EyeOff: () => , EthernetPort: () => , + ExternalLink: () => , Info: () => , InfoTooltip: ({ content }: { content: string }) => {content}, Input: ({ ...props }: InputHTMLAttributes) => ( @@ -418,15 +446,13 @@ describe('Integrations settings', () => { .getByRole('heading', { name: 'Linear' }) .closest('[id="integration-linear"]'); - expect(linearCard).toHaveTextContent( - 'Linear OAuth is not configured for this deployment. View setup guide.', - ); + expect(linearCard).toHaveTextContent('Not configured.'); expect( screen.queryByRole('button', { name: 'Enable Linear' }), ).not.toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'View setup guide' }), - ).toHaveAttribute('href', 'https://docs.roomote.dev/integrations/linear'); + screen.getByRole('button', { name: 'Set up Linear' }), + ).toBeInTheDocument(); }); it('distinguishes incomplete Linear OAuth setup for admins', () => { @@ -439,9 +465,7 @@ describe('Integrations settings', () => { .getByRole('heading', { name: 'Linear' }) .closest('[id="integration-linear"]'); - expect(linearCard).toHaveTextContent( - 'Linear OAuth setup is incomplete. Finish configuring both client credentials before connecting. View setup guide.', - ); + expect(linearCard).toHaveTextContent('Configuration incomplete.'); }); it('asks non-admins to contact an administrator when OAuth is unavailable', () => { @@ -452,12 +476,56 @@ describe('Integrations settings', () => { render(); expect( - screen.getByText( - 'Linear is not configured for this deployment. Ask an administrator to finish its OAuth setup.', - ), + screen.getByText('Not configured. Ask an administrator to set it up.'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Set up Linear' }), + ).not.toBeInTheDocument(); + }); + + it('opens the guided Linear OAuth setup for admins', () => { + state.linearInstallation = null; + state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Set up Linear' })); + + expect( + screen.getByRole('heading', { name: 'Set up Linear' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Create Linear app' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Client ID')).toBeInTheDocument(); + expect(screen.getByLabelText('Client secret')).toBeInTheDocument(); + expect(screen.getByLabelText('Webhook secret')).toBeInTheDocument(); + }); + + it('only offers app creation while Linear is unconfigured', () => { + state.linearInstallation = null; + state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }]; + + render(); + + expect( + screen.queryByRole('button', { name: 'Set up Linear' }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Enable Linear' }), + ).toBeInTheDocument(); + }); + + it('offers setup for a legacy workspace when deployment credentials are missing', () => { + state.linearInstallation = { linearOrganizationName: 'Legacy workspace' }; + state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; + + render(); + + expect( + screen.getByRole('button', { name: 'Set up Linear' }), ).toBeInTheDocument(); expect( - screen.queryByRole('link', { name: 'View setup guide' }), + screen.queryByRole('button', { name: 'Disable Linear' }), ).not.toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 1ec0c0685..a6640085e 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -17,6 +17,7 @@ import { useConnectLinear, useDisconnectLinear, useLinearInstallation, + useLinearOauthSetup, } from '@/hooks/linear'; import { useAsanaConnection, @@ -35,7 +36,6 @@ import { useVercelConnection, } from '@/hooks/mcp-connections'; import { useAuthorizedUser } from '@/hooks/useUser'; -import { DOCS_LINEAR_INTEGRATION_URL } from '@/lib/docs'; import { SETTINGS_PATHS } from '@/lib/settings'; import { saveAsanaConnectionSchema, @@ -77,6 +77,7 @@ import { } from '@/components/system'; import { McpToolManagementDialog } from './McpToolManagementDialog'; import { McpIcon } from './McpIcon'; +import { LinearOauthSetupDialog } from './LinearOauthSetupDialog'; const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { asana: @@ -155,28 +156,10 @@ function getLinearOauthSetupStatus( isAdmin: boolean, ): ReactNode { if (!isAdmin) { - return 'Linear is not configured for this deployment. Ask an administrator to finish its OAuth setup.'; + return 'Not configured. Ask an administrator to set it up.'; } - const message = - status === 'partial' - ? 'Linear OAuth setup is incomplete. Finish configuring both client credentials before connecting.' - : 'Linear OAuth is not configured for this deployment.'; - - return ( - <> - {message}{' '} - - View setup guide - - . - - ); + return status === 'partial' ? 'Configuration incomplete.' : 'Not configured.'; } type AdminConfiguredIntegrationItemOptions = { @@ -1149,6 +1132,7 @@ export function Integrations() { mcpId: string; integrationName: string; } | null>(null); + const [isLinearOauthSetupOpen, setIsLinearOauthSetupOpen] = useState(false); const linearInstallation = useLinearInstallation(); const connectLinear = useConnectLinear(`${pathname}?service=linear`); @@ -1156,6 +1140,14 @@ export function Integrations() { const deploymentEnablements = useDeploymentMcpEnablements(); const oauthReadiness = useMcpOauthReadiness(); + const linearOauthStatus = oauthReadiness.data?.find( + (entry) => entry.mcpId === 'linear', + )?.status; + const linearOauthUnavailable = + linearOauthStatus === 'missing' || linearOauthStatus === 'partial'; + const linearOauthSetup = useLinearOauthSetup( + isAdmin && linearOauthUnavailable, + ); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); @@ -1297,13 +1289,7 @@ export function Integrations() { const userConnectionMap = new Map( (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); - const oauthReadinessMap = new Map( - (oauthReadiness.data ?? []).map((entry) => [entry.mcpId, entry.status]), - ); - const linearOauthStatus = oauthReadinessMap.get('linear'); - const linearOauthUnavailable = - !linearInstallation.data && - (linearOauthStatus === 'missing' || linearOauthStatus === 'partial'); + const canSetUpLinearOauth = isAdmin && linearOauthUnavailable; const openMcpToolDialog = (integration: McpIntegrationDefinition) => setToolDialogState({ mcpId: integration.id, @@ -1351,6 +1337,16 @@ export function Integrations() { statusIcon: linearOauthUnavailable ? ( ) : undefined, + headerAction: canSetUpLinearOauth + ? { + label: 'Set it up', + ariaLabel: 'Set up Linear', + onAction: () => setIsLinearOauthSetupOpen(true), + isPending: + linearOauthSetup.isPending || linearOauthSetup.data == null, + icon: , + } + : undefined, onAction: linearOauthUnavailable ? undefined : () => { @@ -1616,7 +1612,10 @@ export function Integrations() { grafanaConnection.isPending, linearInstallation.data, linearInstallation.isPending, - oauthReadiness.data, + linearOauthSetup.data, + linearOauthSetup.isPending, + linearOauthStatus, + linearOauthUnavailable, oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, @@ -1974,6 +1973,11 @@ export function Integrations() { } }} /> + { + await navigator.clipboard.writeText(value); + toast.success(`${label} copied.`); + }; + + return ( +
+ +
+ + +
+
+ ); +} + +function CredentialField({ + id, + label, + value, + status, + secret = false, + onChange, +}: { + id: string; + label: string; + value: string; + status: SetupFieldStatus; + secret?: boolean; + onChange: (value: string) => void; +}) { + const helperText = status.managedByEnvironment + ? 'Managed by the deployment environment.' + : status.configured + ? 'Already saved. Leave blank to keep the current value.' + : 'Required.'; + + return ( +
+ + onChange(event.target.value)} + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + data-1p-ignore + /> +

{helperText}

+
+ ); +} + +export function LinearOauthSetupDialog({ + open, + onOpenChange, + setup, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + setup: LinearOauthSetupDetails | undefined; +}) { + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const saveSetup = useSaveLinearOauthSetup(); + + useEffect(() => { + if (open) { + setForm(EMPTY_FORM); + setFormError(null); + } + }, [open]); + + const updateField = (field: keyof LinearOauthForm, value: string) => { + setForm((current) => ({ ...current, [field]: value })); + setFormError(null); + }; + + const save = () => { + saveSetup.mutate(form, { + onSuccess: (result) => { + toast.success( + result.requiresReconnect + ? 'Linear OAuth credentials saved. Enable Linear to reconnect the workspace.' + : 'Linear OAuth credentials saved.', + ); + onOpenChange(false); + }, + onError: (error) => { + setFormError( + error instanceof Error + ? error.message + : 'Failed to save Linear OAuth credentials.', + ); + }, + }); + }; + + const publicUrlUsesHttps = setup?.webhookUrl.startsWith('https://') ?? true; + + return ( + + + + Set up Linear + + Create a Linear app for this deployment, then connect it to your + workspace. + + + + {!setup ? ( +
+ + +
+ ) : ( +
+ {!publicUrlUsesHttps ? ( + + + + Linear requires a public HTTPS webhook URL. Configure + R_PUBLIC_URL with your deployment's HTTPS address before + creating the app. + + + ) : null} + +
+
+

+ Create a Linear app for this deployment. +

+

+ Self-hosted Roomote needs its own Linear app. The manifest + pre-fills the callback, webhook, and agent event settings. + Review it in Linear, then create the app. +

+
+ +
+ + +
+
+ +
+
+

Then save the credentials.

+

+ Copy these values from the new app's settings. Roomote + encrypts values saved here. After they are saved, Enable + Linear connects the app to your workspace. +

+
+
+ updateField('clientId', value)} + /> + updateField('clientSecret', value)} + /> + updateField('webhookSecret', value)} + /> +
+ {formError ? ( +

{formError}

+ ) : null} +
+
+ )} + + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/hooks/linear/index.ts b/apps/web/src/hooks/linear/index.ts index c977697a6..91524f325 100644 --- a/apps/web/src/hooks/linear/index.ts +++ b/apps/web/src/hooks/linear/index.ts @@ -1,4 +1,6 @@ export { useLinearInstallation } from './useLinearInstallation'; export { useConnectLinear } from './useConnectLinear'; export { useDisconnectLinear } from './useDisconnectLinear'; +export { useLinearOauthSetup } from './useLinearOauthSetup'; +export { useSaveLinearOauthSetup } from './useSaveLinearOauthSetup'; export { useAuthenticateLinearAccount } from './useAuthenticateLinearAccount'; diff --git a/apps/web/src/hooks/linear/useLinearOauthSetup.ts b/apps/web/src/hooks/linear/useLinearOauthSetup.ts new file mode 100644 index 000000000..1eea0d8cd --- /dev/null +++ b/apps/web/src/hooks/linear/useLinearOauthSetup.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useLinearOauthSetup(enabled = true) { + const trpc = useTRPC(); + + return useQuery({ + ...trpc.linear.oauthSetup.queryOptions(), + enabled, + }); +} diff --git a/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts b/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts new file mode 100644 index 000000000..2a3fcb314 --- /dev/null +++ b/apps/web/src/hooks/linear/useSaveLinearOauthSetup.ts @@ -0,0 +1,31 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useSaveLinearOauthSetup() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.linear.saveOauthSetup.mutationOptions({ + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.linear.oauthSetup.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.oauthReadiness.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.linear.installation.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }), + ]); + }, + }), + ); +} diff --git a/apps/web/src/lib/server/deployment-app-name.test.ts b/apps/web/src/lib/server/deployment-app-name.test.ts new file mode 100644 index 000000000..ea5ee28e7 --- /dev/null +++ b/apps/web/src/lib/server/deployment-app-name.test.ts @@ -0,0 +1,21 @@ +import { buildDeploymentAppName } from './deployment-app-name'; + +describe('buildDeploymentAppName', () => { + it('uses the same hostname-derived default across provider manifests', () => { + expect(buildDeploymentAppName('https://customer.example.com')).toBe( + 'roomote-customer-example-com', + ); + expect(buildDeploymentAppName('https://roomote.example.com')).toBe( + 'roomote-example-com', + ); + }); + + it('keeps the shared name within GitHub app naming limits', () => { + const name = buildDeploymentAppName( + 'https://a-very-long-customer-deployment-name.example.com', + ); + + expect(name.length).toBeLessThanOrEqual(34); + expect(name.endsWith('-')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/server/deployment-app-name.ts b/apps/web/src/lib/server/deployment-app-name.ts new file mode 100644 index 000000000..434eaf577 --- /dev/null +++ b/apps/web/src/lib/server/deployment-app-name.ts @@ -0,0 +1,25 @@ +const DEPLOYMENT_APP_NAME_MAX_LENGTH = 34; +const DEPLOYMENT_APP_NAME_PREFIX = 'roomote-'; + +export const DEPLOYMENT_APP_DESCRIPTION = 'Cloud coding agents for all'; + +/** + * Build the shared default name used for deployment-owned provider apps. + * GitHub's 34-character limit is the strictest supported limit, so keeping + * the shared name within it produces the same name across providers. + */ +export function buildDeploymentAppName(publicOrigin: string): string { + const host = new URL(publicOrigin).hostname + .replace(/[^a-zA-Z0-9-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + if (!host) { + return 'roomote'; + } + + const candidate = host.startsWith('roomote') + ? host + : `${DEPLOYMENT_APP_NAME_PREFIX}${host}`; + + return candidate.slice(0, DEPLOYMENT_APP_NAME_MAX_LENGTH).replace(/-+$/g, ''); +} diff --git a/apps/web/src/lib/server/deployment-static-oauth.test.ts b/apps/web/src/lib/server/deployment-static-oauth.test.ts new file mode 100644 index 000000000..1323fa489 --- /dev/null +++ b/apps/web/src/lib/server/deployment-static-oauth.test.ts @@ -0,0 +1,52 @@ +const { resolveDeploymentEnvVarMock } = vi.hoisted(() => ({ + resolveDeploymentEnvVarMock: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { query: { environmentVariables: { findMany: vi.fn() } } }, + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, +})); + +import { + getDeploymentStaticOauthReadiness, + resolveDeploymentStaticOauthClientInformation, +} from './deployment-static-oauth'; + +const LINEAR_INTEGRATION = { + id: 'linear', + oauthClientEnv: { + clientIdEnv: 'R_LINEAR_CLIENT_ID', + clientSecretEnv: 'R_LINEAR_CLIENT_SECRET', + tokenEndpointAuthMethod: 'client_secret_post' as const, + }, +}; + +describe('deployment static OAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves a client stored in the encrypted deployment environment', async () => { + resolveDeploymentEnvVarMock.mockImplementation(async (name: string) => + name === 'R_LINEAR_CLIENT_ID' ? 'saved-client' : 'saved-secret', + ); + + await expect( + resolveDeploymentStaticOauthClientInformation({}, LINEAR_INTEGRATION), + ).resolves.toEqual({ + client_id: 'saved-client', + client_secret: 'saved-secret', + token_endpoint_auth_method: 'client_secret_post', + }); + }); + + it('reports a partially configured deployment', async () => { + resolveDeploymentEnvVarMock.mockImplementation(async (name: string) => + name === 'R_LINEAR_CLIENT_ID' ? 'saved-client' : null, + ); + + await expect( + getDeploymentStaticOauthReadiness({}, LINEAR_INTEGRATION), + ).resolves.toBe('partial'); + }); +}); diff --git a/apps/web/src/lib/server/deployment-static-oauth.ts b/apps/web/src/lib/server/deployment-static-oauth.ts new file mode 100644 index 000000000..35d6afd34 --- /dev/null +++ b/apps/web/src/lib/server/deployment-static-oauth.ts @@ -0,0 +1,60 @@ +import { + db, + resolveDeploymentEnvVar, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import type { McpIntegration } from '@roomote/types'; + +import { + getStaticOauthEnvKeys, + getStaticOauthReadiness, + resolveStaticOauthClientInformation, +} from './mcp-static-oauth'; + +async function resolveDeploymentStaticOauthEnv( + runtimeEnv: unknown, + integration: Pick, + executor: DatabaseOrTransaction = db, +): Promise> { + const keys = getStaticOauthEnvKeys(integration); + const runtimeRecord = + typeof runtimeEnv === 'object' && runtimeEnv !== null + ? (runtimeEnv as Partial>) + : {}; + const entries = await Promise.all( + keys.map(async (key) => { + const value = await resolveDeploymentEnvVar(key, executor, runtimeRecord); + return value ? ([key, value] as const) : null; + }), + ); + + return Object.fromEntries(entries.filter((entry) => entry !== null)); +} + +export async function getDeploymentStaticOauthReadiness( + runtimeEnv: unknown, + integration: Pick, + executor: DatabaseOrTransaction = db, +) { + const resolvedEnv = await resolveDeploymentStaticOauthEnv( + runtimeEnv, + integration, + executor, + ); + + return getStaticOauthReadiness(resolvedEnv, integration); +} + +export async function resolveDeploymentStaticOauthClientInformation( + runtimeEnv: unknown, + integration: Pick, + executor: DatabaseOrTransaction = db, +) { + const resolvedEnv = await resolveDeploymentStaticOauthEnv( + runtimeEnv, + integration, + executor, + ); + + return resolveStaticOauthClientInformation(resolvedEnv, integration); +} diff --git a/apps/web/src/lib/server/mcp-static-oauth.ts b/apps/web/src/lib/server/mcp-static-oauth.ts index 4faa87bb7..9ef3be6ab 100644 --- a/apps/web/src/lib/server/mcp-static-oauth.ts +++ b/apps/web/src/lib/server/mcp-static-oauth.ts @@ -155,6 +155,19 @@ function getStaticOauthEnvCandidates( ]; } +export function getStaticOauthEnvKeys( + integration: Pick, +): string[] { + return Array.from( + new Set( + getStaticOauthEnvCandidates(integration).flatMap((candidate) => [ + candidate.clientIdEnv, + ...(candidate.clientSecretEnv ? [candidate.clientSecretEnv] : []), + ]), + ), + ); +} + function resolveStaticOauthIntegration( env: unknown, integration: Pick, diff --git a/apps/web/src/trpc/commands/github/mutations.ts b/apps/web/src/trpc/commands/github/mutations.ts index bb9945cc5..8135a4768 100644 --- a/apps/web/src/trpc/commands/github/mutations.ts +++ b/apps/web/src/trpc/commands/github/mutations.ts @@ -22,6 +22,10 @@ import type { UserAuthSuccess } from '@/types'; import { Env } from '@/lib/server'; import { encodeRecord } from '@/lib'; import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; +import { + buildDeploymentAppName, + DEPLOYMENT_APP_DESCRIPTION, +} from '@/lib/server/deployment-app-name'; import { createSignedGitHubAuthState, decodeSignedGitHubAuthState, @@ -81,8 +85,6 @@ const GITHUB_APP_DEFAULT_EVENTS = [ 'workflow_run', ] as const; -const GITHUB_APP_MANIFEST_DESCRIPTION = 'Cloud coding agents for all'; - type GitHubAppManifest = { name: string; description: string; @@ -207,32 +209,12 @@ function getGitHubWebhookUrl() { return new URL('/api/webhooks/github', webhookBaseUrl).toString(); } -function buildGitHubManifestName() { - // GitHub enforces a 34-character maximum on GitHub App names. - const GITHUB_APP_NAME_MAX_LENGTH = 34; - const prefix = 'roomote-'; - - const host = new URL(getPublicAppUrl(Env)).hostname - .replace(/[^a-zA-Z0-9-]+/g, '-') - .replace(/^-+|-+$/g, ''); - - if (!host) { - return 'roomote'; - } - - // Optionally prefix with `roomote-` when the deployment hostname does not - // already start with `roomote`, then trim the final name to GitHub's limit. - const candidate = host.startsWith('roomote') ? host : `${prefix}${host}`; - - return candidate.slice(0, GITHUB_APP_NAME_MAX_LENGTH).replace(/-+$/g, ''); -} - function buildGitHubAppManifest(): GitHubAppManifest { const callbackUrl = getGitHubCallbackUrl(); return { - name: buildGitHubManifestName(), - description: GITHUB_APP_MANIFEST_DESCRIPTION, + name: buildDeploymentAppName(getPublicAppUrl(Env)), + description: DEPLOYMENT_APP_DESCRIPTION, url: callbackUrl, redirect_url: callbackUrl, setup_url: callbackUrl, diff --git a/apps/web/src/trpc/commands/linear/index.test.ts b/apps/web/src/trpc/commands/linear/index.test.ts new file mode 100644 index 000000000..98eec1925 --- /dev/null +++ b/apps/web/src/trpc/commands/linear/index.test.ts @@ -0,0 +1,209 @@ +const { + envState, + deletedConnectionsState, + resolveDeploymentEnvVarMock, + upsertDeploymentEnvironmentVariablesMock, +} = vi.hoisted(() => ({ + envState: {} as Record, + deletedConnectionsState: [] as Array<{ id: string }>, + resolveDeploymentEnvVarMock: vi.fn(), + upsertDeploymentEnvironmentVariablesMock: vi.fn(), +})); + +vi.mock('@/lib/server/env', () => ({ Env: envState })); +vi.mock('@/lib/server/get-public-app-url', () => ({ + getPublicAppUrl: () => 'https://roomote.example', +})); +vi.mock('../environment-variables', () => ({ + upsertDeploymentEnvironmentVariables: + upsertDeploymentEnvironmentVariablesMock, +})); +vi.mock('@roomote/db/server', () => ({ + db: { + transaction: async (operation: (tx: unknown) => Promise) => + operation({ + delete: () => ({ + where: () => ({ + returning: async () => deletedConnectionsState, + }), + }), + insert: () => ({ + values: () => ({ + onConflictDoUpdate: async () => undefined, + }), + }), + }), + }, + resolveDeploymentEnvVar: resolveDeploymentEnvVarMock, + and: vi.fn(), + eq: vi.fn(), + isNull: vi.fn(), + mcpConnections: { + id: 'id', + mcpId: 'mcpId', + connectionRole: 'connectionRole', + userId: 'userId', + }, + deploymentMcpEnablements: { mcpId: 'mcpId' }, +})); +vi.mock('@roomote/sdk/server', () => ({ + findLinearDeploymentMcpConnection: vi.fn(), + getLinearDeploymentMetadata: vi.fn(), + LINEAR_ORG_CONNECTION_ROLE: 'linear_org_install', +})); + +import { + getLinearOauthSetupCommand, + saveLinearOauthSetupCommand, +} from './index'; + +const ADMIN = { + userId: 'admin-1', + isAdmin: true, +} as Parameters[0]; + +describe('Linear OAuth setup', () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(envState)) { + delete envState[key]; + } + deletedConnectionsState.splice(0); + resolveDeploymentEnvVarMock.mockResolvedValue(null); + }); + + it('builds a private Linear app manifest for this deployment', async () => { + const setup = await getLinearOauthSetupCommand(ADMIN); + const setupUrl = new URL(setup.manifestUrl); + const manifest = JSON.parse(setupUrl.searchParams.get('manifest')!); + + expect(setup.callbackUrl).toBe( + 'https://roomote.example/api/mcp-oauth/callback', + ); + expect(setup.webhookUrl).toBe( + 'https://roomote.example/api/webhooks/linear', + ); + expect(manifest).toMatchObject({ + schemaVersion: '1.0.0', + distribution: 'private', + display: { description: 'Cloud coding agents for all' }, + oauth: { + client_name: 'roomote-example', + redirect_uris: [setup.callbackUrl], + }, + webhook: { + enabled: true, + url: setup.webhookUrl, + resourceTypes: ['AgentSessionEvent'], + }, + }); + }); + + it('requires an administrator to view or save app setup', async () => { + const nonAdmin = { ...ADMIN, isAdmin: false }; + + await expect(getLinearOauthSetupCommand(nonAdmin)).rejects.toThrow( + 'Unauthorized', + ); + await expect( + saveLinearOauthSetupCommand(nonAdmin, { + clientId: 'client-id', + clientSecret: 'client-secret', + webhookSecret: 'webhook-secret', + }), + ).rejects.toThrow('Unauthorized'); + }); + + it('saves all three credentials in the encrypted deployment store', async () => { + await saveLinearOauthSetupCommand(ADMIN, { + clientId: ' client-id ', + clientSecret: ' client-secret ', + webhookSecret: ' webhook-secret ', + }); + + expect(upsertDeploymentEnvironmentVariablesMock).toHaveBeenCalledWith( + expect.anything(), + { + userId: 'admin-1', + values: [ + { name: 'R_LINEAR_CLIENT_ID', value: 'client-id' }, + { name: 'R_LINEAR_CLIENT_SECRET', value: 'client-secret' }, + { name: 'R_LINEAR_WEBHOOK_SECRET', value: 'webhook-secret' }, + ], + }, + ); + }); + + it('requires an existing workspace to reconnect when its OAuth client changes', async () => { + deletedConnectionsState.push({ id: 'legacy-linear-connection' }); + + const result = await saveLinearOauthSetupCommand(ADMIN, { + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + webhookSecret: 'new-webhook-secret', + }); + + expect(result.requiresReconnect).toBe(true); + }); + + it('keeps the workspace connected when only the webhook secret changes', async () => { + deletedConnectionsState.push({ id: 'current-linear-connection' }); + resolveDeploymentEnvVarMock.mockResolvedValue('already-configured'); + + const result = await saveLinearOauthSetupCommand(ADMIN, { + clientId: '', + clientSecret: '', + webhookSecret: 'rotated-webhook-secret', + }); + + expect(result.requiresReconnect).toBe(false); + }); + + it('keeps saved values when an administrator leaves their fields blank', async () => { + resolveDeploymentEnvVarMock.mockResolvedValue('already-configured'); + + await saveLinearOauthSetupCommand(ADMIN, { + clientId: '', + clientSecret: '', + webhookSecret: '', + }); + + expect(upsertDeploymentEnvironmentVariablesMock).toHaveBeenCalledWith( + expect.anything(), + { userId: 'admin-1', values: [] }, + ); + }); + + it('does not copy runtime-managed credentials into the database', async () => { + envState.R_LINEAR_CLIENT_ID = 'runtime-client'; + envState.R_LINEAR_CLIENT_SECRET = 'runtime-secret'; + envState.R_LINEAR_WEBHOOK_SECRET = 'runtime-webhook-secret'; + resolveDeploymentEnvVarMock.mockImplementation( + async (name: string, _db: unknown, runtimeEnv: Record) => + runtimeEnv[name] ?? null, + ); + + await saveLinearOauthSetupCommand(ADMIN, { + clientId: 'ignored-client', + clientSecret: 'ignored-secret', + webhookSecret: 'ignored-webhook-secret', + }); + + expect(upsertDeploymentEnvironmentVariablesMock).toHaveBeenCalledWith( + expect.anything(), + { userId: 'admin-1', values: [] }, + ); + }); + + it('requires every value that is not already configured', async () => { + await expect( + saveLinearOauthSetupCommand(ADMIN, { + clientId: 'client-id', + clientSecret: '', + webhookSecret: '', + }), + ).rejects.toThrow('client secret, webhook secret'); + + expect(upsertDeploymentEnvironmentVariablesMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/linear/index.ts b/apps/web/src/trpc/commands/linear/index.ts index dbb00625e..ae1a763c5 100644 --- a/apps/web/src/trpc/commands/linear/index.ts +++ b/apps/web/src/trpc/commands/linear/index.ts @@ -1,19 +1,18 @@ -import { - and, - db, - eq, - isNull, - mcpConnections, - deploymentMcpEnablements, -} from '@roomote/db/server'; +import { db } from '@roomote/db/server'; import { findLinearDeploymentMcpConnection, getLinearDeploymentMetadata, - LINEAR_ORG_CONNECTION_ROLE, } from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; +import { clearLinearDeploymentConnection } from './oauth-setup'; + +export { + getLinearOauthSetupCommand, + saveLinearOauthSetupCommand, +} from './oauth-setup'; + type LinearInstallationSummary = { id: string; authStatus: 'pending' | 'authenticated' | 'error' | null; @@ -55,31 +54,7 @@ export async function disconnectLinearAppCommand( }; } - await db - .delete(mcpConnections) - .where( - and( - eq(mcpConnections.mcpId, 'linear'), - eq(mcpConnections.connectionRole, LINEAR_ORG_CONNECTION_ROLE), - isNull(mcpConnections.userId), - ), - ); - - await db - .insert(deploymentMcpEnablements) - .values({ - mcpId: 'linear', - enabled: false, - enabledByUserId: auth.userId, - }) - .onConflictDoUpdate({ - target: deploymentMcpEnablements.mcpId, - set: { - enabled: false, - enabledByUserId: auth.userId, - updatedAt: new Date(), - }, - }); + await clearLinearDeploymentConnection(db, auth.userId); return { success: true }; } catch (error) { diff --git a/apps/web/src/trpc/commands/linear/oauth-setup.ts b/apps/web/src/trpc/commands/linear/oauth-setup.ts new file mode 100644 index 000000000..007715f0e --- /dev/null +++ b/apps/web/src/trpc/commands/linear/oauth-setup.ts @@ -0,0 +1,231 @@ +import { + and, + db, + deploymentMcpEnablements, + eq, + isNull, + mcpConnections, + resolveDeploymentEnvVar, + type DatabaseOrTransaction, +} from '@roomote/db/server'; +import { LINEAR_ORG_CONNECTION_ROLE } from '@roomote/sdk/server'; + +import { Env } from '@/lib/server/env'; +import { + buildDeploymentAppName, + DEPLOYMENT_APP_DESCRIPTION, +} from '@/lib/server/deployment-app-name'; +import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; +import type { UserAuthSuccess } from '@/types'; + +import { upsertDeploymentEnvironmentVariables } from '../environment-variables'; + +const LINEAR_OAUTH_ENV_FIELDS = { + clientId: 'R_LINEAR_CLIENT_ID', + clientSecret: 'R_LINEAR_CLIENT_SECRET', + webhookSecret: 'R_LINEAR_WEBHOOK_SECRET', +} as const; + +type LinearOauthSetupField = keyof typeof LINEAR_OAUTH_ENV_FIELDS; + +type SaveLinearOauthSetupInput = Record; + +function assertAdmin(auth: Pick) { + if (!auth.isAdmin) { + throw new Error('Unauthorized'); + } +} + +function getRuntimeEnvValue(name: string): string | null { + const value = (Env as unknown as Record)[name]; + if (typeof value !== 'string') { + return null; + } + + const normalizedValue = value.trim(); + return normalizedValue || null; +} + +function getLinearRuntimeEnv(): Partial> { + return Object.fromEntries( + Object.values(LINEAR_OAUTH_ENV_FIELDS).flatMap((envName) => { + const value = getRuntimeEnvValue(envName); + return value ? [[envName, value]] : []; + }), + ); +} + +function buildLinearOauthSetup(publicOrigin: string) { + const callbackUrl = new URL( + '/api/mcp-oauth/callback', + publicOrigin, + ).toString(); + const webhookUrl = new URL('/api/webhooks/linear', publicOrigin).toString(); + const manifest = { + $schema: 'https://linear.app/.well-known/oauth-app-manifest.schema.json', + schemaVersion: '1.0.0', + distribution: 'private', + display: { + description: DEPLOYMENT_APP_DESCRIPTION, + }, + developer: { name: 'Roomote' }, + oauth: { + client_name: buildDeploymentAppName(publicOrigin), + client_uri: publicOrigin, + redirect_uris: [callbackUrl], + grant_types: ['authorization_code'], + }, + webhook: { + enabled: true, + url: webhookUrl, + resourceTypes: ['AgentSessionEvent'], + }, + }; + const url = new URL('https://linear.app/settings/api/applications/new'); + url.searchParams.set('manifest', JSON.stringify(manifest)); + return { + callbackUrl, + webhookUrl, + manifestUrl: url.toString(), + }; +} + +export async function clearLinearDeploymentConnection( + executor: DatabaseOrTransaction, + userId: string, +): Promise { + const deletedConnections = await executor + .delete(mcpConnections) + .where( + and( + eq(mcpConnections.mcpId, 'linear'), + eq(mcpConnections.connectionRole, LINEAR_ORG_CONNECTION_ROLE), + isNull(mcpConnections.userId), + ), + ) + .returning({ id: mcpConnections.id }); + + await executor + .insert(deploymentMcpEnablements) + .values({ + mcpId: 'linear', + enabled: false, + enabledByUserId: userId, + }) + .onConflictDoUpdate({ + target: deploymentMcpEnablements.mcpId, + set: { + enabled: false, + enabledByUserId: userId, + updatedAt: new Date(), + }, + }); + + return deletedConnections.length > 0; +} + +export async function getLinearOauthSetupCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const publicOrigin = getPublicAppUrl(Env); + const runtimeEnv = getLinearRuntimeEnv(); + const urls = buildLinearOauthSetup(publicOrigin); + const fieldEntries = await Promise.all( + Object.entries(LINEAR_OAUTH_ENV_FIELDS).map(async ([field, envName]) => { + const runtimeValue = getRuntimeEnvValue(envName); + const effectiveValue = await resolveDeploymentEnvVar( + envName, + db, + runtimeEnv, + ); + + return [ + field, + { + configured: Boolean(effectiveValue), + managedByEnvironment: Boolean(runtimeValue), + }, + ] as const; + }), + ); + + return { + ...urls, + fields: Object.fromEntries(fieldEntries) as Record< + LinearOauthSetupField, + { configured: boolean; managedByEnvironment: boolean } + >, + }; +} + +export async function saveLinearOauthSetupCommand( + auth: UserAuthSuccess, + input: SaveLinearOauthSetupInput, +) { + assertAdmin(auth); + const runtimeEnv = getLinearRuntimeEnv(); + + const currentValues = await Promise.all( + Object.entries(LINEAR_OAUTH_ENV_FIELDS).map(async ([field, envName]) => [ + field, + await resolveDeploymentEnvVar(envName, db, runtimeEnv), + ]), + ); + const currentByField = Object.fromEntries(currentValues) as Record< + LinearOauthSetupField, + string | null + >; + const labels: Record = { + clientId: 'client ID', + clientSecret: 'client secret', + webhookSecret: 'webhook secret', + }; + const missingFields = ( + Object.keys(LINEAR_OAUTH_ENV_FIELDS) as LinearOauthSetupField[] + ).filter((field) => !input[field].trim() && !currentByField[field]); + + if (missingFields.length > 0) { + throw new Error( + `Enter the Linear ${missingFields.map((field) => labels[field]).join(', ')}.`, + ); + } + + const values = ( + Object.entries(LINEAR_OAUTH_ENV_FIELDS) as Array< + [LinearOauthSetupField, string] + > + ).flatMap(([field, envName]) => { + const value = input[field].trim(); + return value && !getRuntimeEnvValue(envName) + ? [{ name: envName, value }] + : []; + }); + const clientCredentialsChanged = (['clientId', 'clientSecret'] as const).some( + (field) => { + const value = input[field].trim(); + const envName = LINEAR_OAUTH_ENV_FIELDS[field]; + return ( + Boolean(value) && + !getRuntimeEnvValue(envName) && + value !== currentByField[field] + ); + }, + ); + let requiresReconnect = false; + + await db.transaction(async (tx) => { + await upsertDeploymentEnvironmentVariables(tx, { + userId: auth.userId, + values, + }); + + if (clientCredentialsChanged) { + requiresReconnect = await clearLinearDeploymentConnection( + tx, + auth.userId, + ); + } + }); + + return { success: true as const, requiresReconnect }; +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 89fa8a46f..30d5d799e 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -32,10 +32,8 @@ import { encrypt } from '@roomote/db/encryption'; import { getValidAccessToken } from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; -import { - getStaticOauthReadiness, - type StaticOauthReadiness, -} from '@/lib/server/mcp-static-oauth'; +import type { StaticOauthReadiness } from '@/lib/server/mcp-static-oauth'; +import { getDeploymentStaticOauthReadiness } from '@/lib/server/deployment-static-oauth'; import { Env } from '@/lib/server/env'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { @@ -87,8 +85,10 @@ function getStaticOauthSetupError( return `${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this deployment to configure OAuth before connecting.`; } -function assertStaticOauthReady(integration: McpIntegration): void { - const readiness = getStaticOauthReadiness(Env, integration); +async function assertStaticOauthReady( + integration: McpIntegration, +): Promise { + const readiness = await getDeploymentStaticOauthReadiness(Env, integration); const error = getStaticOauthSetupError(integration, readiness); if (error) { @@ -527,11 +527,21 @@ export async function getDeploymentMcpEnablementsCommand( * server. */ export async function getMcpOauthReadinessCommand(_auth: UserAuthSuccess) { - return MCP_INTEGRATIONS.flatMap((integration) => { - const status = getStaticOauthReadiness(Env, integration); + const readiness = await Promise.all( + MCP_INTEGRATIONS.map(async (integration) => ({ + mcpId: integration.id, + status: await getDeploymentStaticOauthReadiness(Env, integration), + })), + ); - return status === 'not_required' ? [] : [{ mcpId: integration.id, status }]; - }); + return readiness.filter( + ( + entry, + ): entry is { + mcpId: string; + status: Exclude; + } => entry.status !== 'not_required', + ); } /** @@ -579,7 +589,7 @@ export async function setDeploymentMcpEnabledCommand( integration?.oauthClientEnv && !isDeploymentScopedMcpIntegration(input.mcpId) ) { - assertStaticOauthReady(integration); + await assertStaticOauthReady(integration); } const [result] = await db @@ -1262,7 +1272,7 @@ export async function connectMcpCommand( ); } - assertStaticOauthReady(integration); + await assertStaticOauthReady(integration); if ( input.redirectTo && diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 03635dbdc..e72d6ac80 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -112,6 +112,8 @@ import { import { getLinearInstallationCommand, disconnectLinearAppCommand, + getLinearOauthSetupCommand, + saveLinearOauthSetupCommand, } from '../commands/linear'; import { getTeamsIntegrationStatusCommand } from '../commands/teams'; import { @@ -1146,6 +1148,22 @@ export const appRouter = createRouter({ disconnectApp: protectedProcedure.mutation(({ ctx: { auth } }) => disconnectLinearAppCommand(auth), ), + + oauthSetup: protectedProcedure.query(({ ctx: { auth } }) => + getLinearOauthSetupCommand(auth), + ), + + saveOauthSetup: protectedProcedure + .input( + z.object({ + clientId: z.string().max(1_000), + clientSecret: z.string().max(10_000), + webhookSecret: z.string().max(10_000), + }), + ) + .mutation(({ ctx: { auth }, input }) => + saveLinearOauthSetupCommand(auth, input), + ), }), teams: createRouter({