diff --git a/.changeset/fresh-slack-manifest.md b/.changeset/fresh-slack-manifest.md new file mode 100644 index 000000000..7b680faac --- /dev/null +++ b/.changeset/fresh-slack-manifest.md @@ -0,0 +1,5 @@ +--- +"@roomote/web": minor +--- + +Add a guided Slack app manifest updater to Communication Settings so admins can apply current Roomote capabilities, permissions, events, and callback URLs with a fresh configuration token. diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 536225d9f..e69ed5952 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -37,6 +37,20 @@ If Slack is already configured, Settings → Communications shows the saved credentials and still offers **Create a new Slack app with a configuration token** when you need to replace them. +### Update an existing Slack app + +After connecting a workspace, use **Settings → Communications → Slack → Update +app** to bring the existing app manifest up to date with the current Roomote +configuration. Generate and paste a fresh app configuration token when +prompted. Roomote exports the app's current manifest, preserves custom fields, +adds or updates Roomote's required capabilities, scopes, events, redirect URLs, +and callback URLs, validates the result, and then applies it. The token is not +stored. + +If the update changes permissions, Roomote shows **Reinstall in Slack**. Finish +that approval step before testing the new capabilities because Slack does not +add scopes to existing installations automatically. + ### Alternative: env vars or an existing app If you already have a Slack app, or production secret management should own the @@ -153,11 +167,12 @@ Roomote-created apps enable Slack's current **Agent messaging experience** opens Fast-mode responses in threads, and allows Slack to render native loading states and streamed task cards. -For an existing manually configured app, open its Slack app settings, enable -the **Agents** feature, and select the Agent messaging experience. Add the -`assistant:write` scope and the `app_home_opened` and `app_context_changed` -events listed above, then reinstall the app and hard-refresh Slack. Keep -`message.im` enabled so direct messages continue reaching Roomote. +For an existing app, use **Update app** in Settings → Communications to apply +these settings. You can also configure them manually by enabling the **Agents** +feature, selecting the Agent messaging experience, adding the `assistant:write` +scope and the `app_home_opened` and `app_context_changed` events, and then +reinstalling the app. Hard-refresh Slack afterward. Keep `message.im` enabled +so direct messages continue reaching Roomote. ## Fast answers diff --git a/apps/web/src/components/settings/CommsProviderSection.test.tsx b/apps/web/src/components/settings/CommsProviderSection.test.tsx index 13e46b8ad..14fa764cc 100644 --- a/apps/web/src/components/settings/CommsProviderSection.test.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.test.tsx @@ -211,6 +211,12 @@ vi.mock('@/trpc/client', () => ({ mutationName: 'createSlackApp', }), }, + updateAppManifest: { + mutationOptions: (options: unknown) => ({ + ...(options as Record), + mutationName: 'updateSlackManifest', + }), + }, }, comms: { status: { queryKey: () => ['comms', 'status'] }, diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index 4243a3211..92b4be749 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -86,6 +86,7 @@ import { import { Section } from './Section'; import { TelegramLinkAccountStep } from './TelegramLinkAccountStep'; import { DiscordSetupStatus } from './DiscordSetupStatus'; +import { SlackManifestUpdateDialog } from './SlackManifestUpdateDialog'; function getProviderIconId(providerId: CommsProviderId): string { return providerId === 'microsoft' ? 'teams' : providerId; @@ -537,7 +538,10 @@ export function CommsProviderSection({ title={provider.label} action={ provider.id === 'slack' ? ( - +
+ + +
) : null } > diff --git a/apps/web/src/components/settings/SlackManifestUpdateDialog.test.tsx b/apps/web/src/components/settings/SlackManifestUpdateDialog.test.tsx new file mode 100644 index 000000000..f7600f11c --- /dev/null +++ b/apps/web/src/components/settings/SlackManifestUpdateDialog.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const { state, mutations } = vi.hoisted(() => ({ + state: { + installation: { appId: 'A0ROOMOTE' } as { appId: string } | null, + updateResult: { + success: true, + changed: true, + reinstallRequired: false, + appSettingsUrl: 'https://api.slack.com/apps/A0ROOMOTE', + } as + | { + success: true; + changed: boolean; + reinstallRequired: boolean; + appSettingsUrl: string; + } + | { success: false; error: string }, + }, + mutations: { + updateManifest: vi.fn(), + connectSlack: vi.fn(), + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: { + mutationName?: string; + onSuccess?: (result: typeof state.updateResult) => void; + }) => ({ + isPending: false, + mutate: (input: unknown) => { + if (options.mutationName === 'updateManifest') { + mutations.updateManifest(input); + options.onSuccess?.(state.updateResult); + } + }, + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + slack: { + updateAppManifest: { + mutationOptions: (options: unknown) => ({ + ...(options as Record), + mutationName: 'updateManifest', + }), + }, + }, + }), +})); + +vi.mock('@/hooks/slack', () => ({ + useSlackInstallation: () => ({ data: state.installation }), + useConnectSlack: () => ({ + isPending: false, + mutate: mutations.connectSlack, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { SlackManifestUpdateDialog } from './SlackManifestUpdateDialog'; + +describe('SlackManifestUpdateDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.installation = { appId: 'A0ROOMOTE' }; + state.updateResult = { + success: true, + changed: true, + reinstallRequired: false, + appSettingsUrl: 'https://api.slack.com/apps/A0ROOMOTE', + }; + }); + + it('only shows the update action for a connected Slack app', () => { + state.installation = null; + const { rerender } = render(); + + expect( + screen.queryByRole('button', { name: 'Update app' }), + ).not.toBeInTheDocument(); + + state.installation = { appId: 'A0ROOMOTE' }; + rerender(); + expect( + screen.getByRole('button', { name: 'Update app' }), + ).toBeInTheDocument(); + }); + + it('requests a fresh token and sends it only when updating', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Update app' })); + + expect( + screen.getByRole('heading', { name: 'Update Slack app' }), + ).toBeInTheDocument(); + expect(screen.getByText(/does not store it/i)).toBeInTheDocument(); + + const submit = screen.getByRole('button', { name: 'Update app' }); + expect(submit).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('App configuration token'), { + target: { value: 'xoxe.xoxp-fresh-token' }, + }); + fireEvent.click(submit); + + expect(mutations.updateManifest).toHaveBeenCalledWith({ + configToken: 'xoxe.xoxp-fresh-token', + }); + await waitFor(() => { + expect( + screen.queryByRole('heading', { name: 'Update Slack app' }), + ).not.toBeInTheDocument(); + }); + }); + + it('offers reinstallation when Slack reports permission changes', async () => { + state.updateResult = { + success: true, + changed: true, + reinstallRequired: true, + appSettingsUrl: 'https://api.slack.com/apps/A0ROOMOTE', + }; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Update app' })); + fireEvent.change(screen.getByLabelText('App configuration token'), { + target: { value: 'xoxe.xoxp-fresh-token' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Update app' })); + + expect( + await screen.findByRole('button', { name: 'Reinstall in Slack' }), + ).toBeInTheDocument(); + expect( + screen.getByText(/approve the updated permissions/i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText('App configuration token'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/settings/SlackManifestUpdateDialog.tsx b/apps/web/src/components/settings/SlackManifestUpdateDialog.tsx new file mode 100644 index 000000000..a8ebbe4dd --- /dev/null +++ b/apps/web/src/components/settings/SlackManifestUpdateDialog.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +import { useConnectSlack, useSlackInstallation } from '@/hooks/slack'; +import { SETTINGS_PATHS } from '@/lib/settings'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + ExternalLink, + Input, + Label, + RefreshCw, + Spinner, +} from '@/components/system'; + +export function SlackManifestUpdateDialog() { + const trpc = useTRPC(); + const slackInstallation = useSlackInstallation(); + const connectSlack = useConnectSlack(SETTINGS_PATHS.comms); + const [open, setOpen] = useState(false); + const [configToken, setConfigToken] = useState(''); + const [reinstallRequired, setReinstallRequired] = useState(false); + + const updateManifest = useMutation( + trpc.slack.updateAppManifest.mutationOptions({ + onSuccess: (result) => { + setConfigToken(''); + + if (!result.success) { + toast.error(result.error); + return; + } + + if (!result.changed) { + toast.success('Slack app is already up to date'); + setOpen(false); + return; + } + + if (result.reinstallRequired) { + setReinstallRequired(true); + toast.success('Slack app manifest updated'); + return; + } + + toast.success('Slack app updated'); + setOpen(false); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!slackInstallation.data) { + return null; + } + + const handleOpenChange = (nextOpen: boolean) => { + if (updateManifest.isPending || connectSlack.isPending) return; + setOpen(nextOpen); + if (!nextOpen) { + setConfigToken(''); + setReinstallRequired(false); + } + }; + + const handleReinstall = () => { + connectSlack.mutate(undefined, { + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => toast.error('Failed to start Slack app reinstallation.'), + }); + }; + + return ( + <> + + + + + Update Slack app + + Bring this Slack app's capabilities, permissions, events, and + callback URLs up to date with Roomote. Custom manifest settings + are preserved. + + + + {reinstallRequired ? ( +
+

The manifest was updated.

+

+ Slack needs you to approve the updated permissions before they + take effect. +

+
+ ) : ( +
+
+ + setConfigToken(event.target.value)} + disabled={updateManifest.isPending} + placeholder="xoxe.xoxp-…" + /> +
+

+ Generate a fresh token under Your App Configuration Tokens in + the{' '} + + Slack Apps portal + + + . Roomote uses it for this update and does not store it. +

+
+ )} + + + + {reinstallRequired ? ( + + ) : ( + + )} + +
+
+ + ); +} diff --git a/apps/web/src/trpc/commands/slack/index.ts b/apps/web/src/trpc/commands/slack/index.ts index 19f2dc3dc..649b0274b 100644 --- a/apps/web/src/trpc/commands/slack/index.ts +++ b/apps/web/src/trpc/commands/slack/index.ts @@ -36,6 +36,7 @@ import { } from '@/lib/server/slack-oauth-state'; export { createSlackAppFromManifestCommand } from './create-app-from-manifest'; +export { updateSlackAppManifestCommand } from './update-app-manifest'; interface SlackOAuthResponse { ok: boolean; diff --git a/apps/web/src/trpc/commands/slack/update-app-manifest.test.ts b/apps/web/src/trpc/commands/slack/update-app-manifest.test.ts new file mode 100644 index 000000000..ef72cd814 --- /dev/null +++ b/apps/web/src/trpc/commands/slack/update-app-manifest.test.ts @@ -0,0 +1,248 @@ +import type { UserAuthSuccess } from '@/types'; + +const { mockFetch, mockFindFirst } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockFindFirst: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + buildSlackApiUrl: (path: string) => `https://slack.example.test/api/${path}`, +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + slackInstallations: { findFirst: mockFindFirst }, + }, + }, + desc: vi.fn((value) => value), + eq: vi.fn(() => true), + slackInstallations: { + isActive: 'isActive', + updatedAt: 'updatedAt', + }, +})); + +vi.mock('@/lib/server', () => ({ + Env: { + R_APP_URL: 'http://localhost:3000/', + R_PUBLIC_URL: 'https://roomote.example.com/', + }, +})); + +vi.stubGlobal('fetch', mockFetch); + +import { + reconcileSlackAppManifest, + updateSlackAppManifestCommand, +} from './update-app-manifest'; +import { buildSlackAppManifest } from '@/lib/slack-app-manifest'; + +function buildMockAuth( + overrides: Partial = {}, +): UserAuthSuccess { + return { + success: true, + userType: 'user', + userId: 'slack-manifest-update-user', + isAdmin: true, + name: 'Slack Manifest Updater', + primaryEmail: 'slack@example.com', + resource: {}, + ...overrides, + } as UserAuthSuccess; +} + +function slackResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe('reconcileSlackAppManifest', () => { + it('adds Roomote requirements without removing custom manifest fields', () => { + const required = buildSlackAppManifest({ + publicOrigin: 'https://roomote.example.com', + }); + const current = { + display_information: { name: 'Custom Roomote' }, + features: { + bot_user: { display_name: 'Custom Bot', always_online: false }, + slash_commands: [{ command: '/custom' }], + }, + oauth_config: { + redirect_urls: ['https://custom.example.com/oauth'], + scopes: { bot: ['commands'], user: ['identity.basic'] }, + }, + settings: { + event_subscriptions: { + request_url: 'https://old.example.com/slack', + bot_events: ['workflow_step_execute'], + }, + }, + }; + + const result = reconcileSlackAppManifest({ current, required }); + + expect(result.display_information).toEqual({ name: 'Custom Roomote' }); + expect(result.features).toMatchObject({ + bot_user: { display_name: 'Custom Bot', always_online: false }, + slash_commands: [{ command: '/custom' }], + agent_view: { agent_description: 'Cloud coding agents for all' }, + }); + expect(result.oauth_config).toMatchObject({ + redirect_urls: [ + 'https://custom.example.com/oauth', + 'https://roomote.example.com/api/auth/oauth2/callback/slack', + 'https://roomote.example.com/api/slack/callback', + ], + scopes: { + user: ['identity.basic'], + bot: expect.arrayContaining(['commands', 'assistant:write']), + }, + }); + expect(result.settings).toMatchObject({ + event_subscriptions: { + request_url: 'https://roomote.example.com/api/webhooks/slack', + bot_events: expect.arrayContaining([ + 'workflow_step_execute', + 'app_context_changed', + ]), + }, + interactivity: { + is_enabled: true, + request_url: 'https://roomote.example.com/api/webhooks/slack', + }, + }); + }); +}); + +describe('updateSlackAppManifestCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindFirst.mockResolvedValue({ appId: 'A0ROOMOTE' }); + }); + + it('rejects non-admin users without calling Slack', async () => { + const result = await updateSlackAppManifestCommand( + buildMockAuth({ isAdmin: false }), + { configToken: 'xoxe.xoxp-token' }, + ); + + expect(result).toEqual({ success: false, error: 'Unauthorized' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('requires a connected Slack app', async () => { + mockFindFirst.mockResolvedValue(null); + + const result = await updateSlackAppManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + expect(result).toEqual({ + success: false, + error: 'Connect a Slack workspace before updating its app manifest.', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('exports, validates, and updates the full manifest', async () => { + mockFetch + .mockResolvedValueOnce( + slackResponse({ + ok: true, + manifest: { + display_information: { name: 'Custom Roomote' }, + features: { bot_user: { display_name: 'Custom Bot' } }, + oauth_config: { scopes: { bot: ['chat:write'] } }, + settings: {}, + }, + }), + ) + .mockResolvedValueOnce(slackResponse({ ok: true, errors: [] })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A0ROOMOTE', + permissions_updated: true, + }), + ); + + const result = await updateSlackAppManifestCommand(buildMockAuth(), { + configToken: ' xoxe.xoxp-token ', + }); + + expect(result).toEqual({ + success: true, + changed: true, + reinstallRequired: true, + appSettingsUrl: 'https://api.slack.com/apps/A0ROOMOTE', + }); + expect(mockFetch).toHaveBeenCalledTimes(3); + expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://slack.example.test/api/apps.manifest.export', + 'https://slack.example.test/api/apps.manifest.validate', + 'https://slack.example.test/api/apps.manifest.update', + ]); + + for (const [, init] of mockFetch.mock.calls) { + expect(init.headers.Authorization).toBe('Bearer xoxe.xoxp-token'); + } + + const validateBody = JSON.parse(mockFetch.mock.calls[1]![1].body) as { + app_id: string; + manifest: string; + }; + const updateBody = JSON.parse(mockFetch.mock.calls[2]![1].body) as { + app_id: string; + manifest: string; + }; + expect(validateBody).toEqual(updateBody); + expect(validateBody.app_id).toBe('A0ROOMOTE'); + expect(JSON.parse(validateBody.manifest)).toMatchObject({ + display_information: { name: 'Custom Roomote' }, + features: { + bot_user: { display_name: 'Custom Bot' }, + agent_view: { agent_description: 'Cloud coding agents for all' }, + }, + }); + }); + + it('does not update an app whose manifest is already current', async () => { + const manifest = buildSlackAppManifest({ + publicOrigin: 'https://roomote.example.com', + }); + mockFetch.mockResolvedValueOnce(slackResponse({ ok: true, manifest })); + + const result = await updateSlackAppManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-token', + }); + + expect(result).toEqual({ + success: true, + changed: false, + reinstallRequired: false, + appSettingsUrl: 'https://api.slack.com/apps/A0ROOMOTE', + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns a useful error for an expired configuration token', async () => { + mockFetch.mockResolvedValueOnce( + slackResponse({ ok: false, error: 'token_expired' }), + ); + + const result = await updateSlackAppManifestCommand(buildMockAuth(), { + configToken: 'xoxe.xoxp-expired', + }); + + expect(result).toEqual({ + success: false, + error: + 'Slack rejected the app configuration token. Generate a fresh token at api.slack.com/apps and try again.', + }); + }); +}); diff --git a/apps/web/src/trpc/commands/slack/update-app-manifest.ts b/apps/web/src/trpc/commands/slack/update-app-manifest.ts new file mode 100644 index 000000000..ba735ee0d --- /dev/null +++ b/apps/web/src/trpc/commands/slack/update-app-manifest.ts @@ -0,0 +1,298 @@ +import { buildSlackApiUrl } from '@roomote/slack'; +import { db, desc, eq, slackInstallations } from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; +import { Env } from '@/lib/server'; +import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; +import { buildSlackAppManifest } from '@/lib/slack-app-manifest'; + +type JsonRecord = Record; + +type SlackManifestError = { + message?: string; + pointer?: string; +}; + +type SlackManifestResponse = { + ok?: boolean; + error?: string; + errors?: SlackManifestError[]; + manifest?: unknown; + permissions_updated?: boolean; +}; + +type UpdateSlackAppManifestResult = + | { + success: true; + changed: boolean; + reinstallRequired: boolean; + appSettingsUrl: string; + } + | { success: false; error: string }; + +const CONFIG_TOKEN_ERRORS = new Set([ + 'invalid_auth', + 'not_authed', + 'token_expired', + 'token_revoked', + 'token_rotated', +]); + +const CONFIG_TOKEN_ERROR_MESSAGE = + 'Slack rejected the app configuration token. Generate a fresh token at api.slack.com/apps and try again.'; + +function isJsonRecord(value: unknown): value is JsonRecord { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function uniqueStrings(current: unknown, required: readonly string[]) { + const values = Array.isArray(current) + ? current.filter((value): value is string => typeof value === 'string') + : []; + + return [...new Set([...values, ...required])]; +} + +function mergeRecord(current: unknown, required: JsonRecord): JsonRecord { + const merged: JsonRecord = isJsonRecord(current) ? { ...current } : {}; + + for (const [key, requiredValue] of Object.entries(required)) { + if (isJsonRecord(requiredValue)) { + merged[key] = mergeRecord(merged[key], requiredValue); + } else { + merged[key] = requiredValue; + } + } + + return merged; +} + +/** + * Reconciles Roomote-owned Slack settings while retaining custom fields from + * the exported manifest. Additive arrays preserve custom scopes, events, and + * redirect URLs because apps.manifest.update replaces the entire manifest. + */ +export function reconcileSlackAppManifest({ + current, + required, +}: { + current: JsonRecord; + required: ReturnType; +}): JsonRecord { + const currentOauth = isJsonRecord(current.oauth_config) + ? current.oauth_config + : {}; + const currentScopes = isJsonRecord(currentOauth.scopes) + ? currentOauth.scopes + : {}; + const currentSettings = isJsonRecord(current.settings) + ? current.settings + : {}; + const currentEvents = isJsonRecord(currentSettings.event_subscriptions) + ? currentSettings.event_subscriptions + : {}; + + const requiredOperationalManifest: JsonRecord = { + features: { + app_home: required.features.app_home, + agent_view: required.features.agent_view, + }, + oauth_config: { + redirect_urls: uniqueStrings( + currentOauth.redirect_urls, + required.oauth_config.redirect_urls, + ), + scopes: { + ...currentScopes, + bot: uniqueStrings(currentScopes.bot, required.oauth_config.scopes.bot), + }, + pkce_enabled: required.oauth_config.pkce_enabled, + }, + settings: { + ...required.settings, + event_subscriptions: { + ...required.settings.event_subscriptions, + bot_events: uniqueStrings( + currentEvents.bot_events, + required.settings.event_subscriptions.bot_events, + ), + }, + }, + }; + + return mergeRecord(current, requiredOperationalManifest); +} + +function formatSlackError( + data: SlackManifestResponse | null, + fallback: string, +) { + if (data?.error && CONFIG_TOKEN_ERRORS.has(data.error)) { + return CONFIG_TOKEN_ERROR_MESSAGE; + } + + const manifestErrors = (data?.errors ?? []) + .map((error) => { + const message = error.message?.trim(); + if (!message) return null; + const pointer = error.pointer?.trim(); + return pointer ? `${message} (${pointer})` : message; + }) + .filter((message): message is string => message !== null); + + if (manifestErrors.length > 0) { + return `Slack rejected the updated app manifest: ${manifestErrors.join('; ')}`; + } + + return data?.error ? `Slack returned an error: ${data.error}` : fallback; +} + +async function callManifestApi({ + method, + configToken, + body, +}: { + method: + | 'apps.manifest.export' + | 'apps.manifest.validate' + | 'apps.manifest.update'; + configToken: string; + body: JsonRecord; +}): Promise<{ response: Response; data: SlackManifestResponse | null }> { + const response = await fetch(buildSlackApiUrl(method), { + method: 'POST', + headers: { + Authorization: `Bearer ${configToken}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(body), + }); + + let data: SlackManifestResponse | null = null; + try { + data = (await response.json()) as SlackManifestResponse; + } catch { + data = null; + } + + return { response, data }; +} + +export async function updateSlackAppManifestCommand( + auth: UserAuthSuccess, + input: { configToken: string }, +): Promise { + if (!auth.isAdmin) { + return { success: false, error: 'Unauthorized' }; + } + + const configToken = input.configToken.trim(); + if (!configToken) { + return { success: false, error: 'Enter a Slack app configuration token.' }; + } + + const installation = await db.query.slackInstallations.findFirst({ + where: eq(slackInstallations.isActive, true), + orderBy: [desc(slackInstallations.updatedAt)], + }); + + if (!installation) { + return { + success: false, + error: 'Connect a Slack workspace before updating its app manifest.', + }; + } + + const publicOrigin = getPublicAppUrl(Env).trim(); + if (!publicOrigin) { + return { + success: false, + error: + 'The deployment public URL is not configured, so the Slack app manifest cannot be built.', + }; + } + + const appSettingsUrl = `https://api.slack.com/apps/${encodeURIComponent(installation.appId)}`; + + try { + const exported = await callManifestApi({ + method: 'apps.manifest.export', + configToken, + body: { app_id: installation.appId }, + }); + + if (!exported.data?.ok || !isJsonRecord(exported.data.manifest)) { + return { + success: false, + error: formatSlackError( + exported.data, + `Failed to export the Slack app manifest (HTTP ${exported.response.status}).`, + ), + }; + } + + const currentManifest = exported.data.manifest; + const manifest = reconcileSlackAppManifest({ + current: currentManifest, + required: buildSlackAppManifest({ publicOrigin }), + }); + const manifestJson = JSON.stringify(manifest); + + if (JSON.stringify(currentManifest) === manifestJson) { + return { + success: true, + changed: false, + reinstallRequired: false, + appSettingsUrl, + }; + } + + const validated = await callManifestApi({ + method: 'apps.manifest.validate', + configToken, + body: { app_id: installation.appId, manifest: manifestJson }, + }); + + if (!validated.data?.ok) { + return { + success: false, + error: formatSlackError( + validated.data, + `Failed to validate the Slack app manifest (HTTP ${validated.response.status}).`, + ), + }; + } + + const updated = await callManifestApi({ + method: 'apps.manifest.update', + configToken, + body: { app_id: installation.appId, manifest: manifestJson }, + }); + + if (!updated.data?.ok) { + return { + success: false, + error: formatSlackError( + updated.data, + `Failed to update the Slack app manifest (HTTP ${updated.response.status}).`, + ), + }; + } + + return { + success: true, + changed: true, + reinstallRequired: updated.data.permissions_updated === true, + appSettingsUrl, + }; + } catch (error) { + console.error('[updateSlackAppManifestCommand] Failed:', error); + return { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to update the Slack app manifest.', + }; + } +} diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index ffdf0b648..53ecce645 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -115,6 +115,7 @@ import { exchangeSlackOAuthCodeCommand, connectSlackAppCommand, createSlackAppFromManifestCommand, + updateSlackAppManifestCommand, disconnectSlackAppCommand, getSlackInstallationCommand, startAuthenticateSlackAccountCommand, @@ -1285,6 +1286,12 @@ export const appRouter = createRouter({ createSlackAppFromManifestCommand(auth, input), ), + updateAppManifest: protectedProcedure + .input(z.object({ configToken: z.string().trim().min(1) })) + .mutation(({ ctx: { auth }, input }) => + updateSlackAppManifestCommand(auth, input), + ), + disconnectApp: protectedProcedure.mutation(({ ctx: { auth } }) => disconnectSlackAppCommand(auth), ), diff --git a/packages/slack/src/__tests__/mock-slack-server.test.ts b/packages/slack/src/__tests__/mock-slack-server.test.ts index c186c4743..023b776ce 100644 --- a/packages/slack/src/__tests__/mock-slack-server.test.ts +++ b/packages/slack/src/__tests__/mock-slack-server.test.ts @@ -648,6 +648,89 @@ describe('MockSlackServer', () => { } }); + it('exports, validates, and updates an existing app manifest with a config token', async () => { + const originalManifest = { + display_information: { name: 'Roomote' }, + oauth_config: { scopes: { bot: ['chat:write'] } }, + }; + const updatedManifest = { + ...originalManifest, + features: { agent_view: { agent_description: 'Coding agents' } }, + }; + const server = new MockSlackServer({ + state: { + team: { id: 'T1', domain: 'mock-roomote' }, + acceptedBotTokens: ['xoxb-mock-token'], + acceptedConfigTokens: ['xoxe.xoxp-config-token'], + manifestPermissionsUpdated: true, + createdManifests: [{ appId: 'A0MANIFEST', manifest: originalManifest }], + channels: [{ id: 'C1', name: 'product-debug', isMember: true }], + users: [{ id: 'U1', name: 'alex', displayName: 'Alex' }], + }, + }); + + try { + await server.start(); + const headers = { + authorization: 'Bearer xoxe.xoxp-config-token', + 'content-type': 'application/json', + }; + + const exportResponse = await fetch( + `${server.baseUrl}/api/apps.manifest.export`, + { + method: 'POST', + headers, + body: JSON.stringify({ app_id: 'A0MANIFEST' }), + }, + ); + await expect(exportResponse.json()).resolves.toEqual({ + ok: true, + manifest: originalManifest, + }); + + const validateResponse = await fetch( + `${server.baseUrl}/api/apps.manifest.validate`, + { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: 'A0MANIFEST', + manifest: JSON.stringify(updatedManifest), + }), + }, + ); + await expect(validateResponse.json()).resolves.toEqual({ + ok: true, + errors: [], + }); + + const updateResponse = await fetch( + `${server.baseUrl}/api/apps.manifest.update`, + { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: 'A0MANIFEST', + manifest: JSON.stringify(updatedManifest), + }), + }, + ); + await expect(updateResponse.json()).resolves.toEqual({ + ok: true, + app_id: 'A0MANIFEST', + permissions_updated: true, + }); + + expect(server.getState()).toMatchObject({ + createdManifests: [{ appId: 'A0MANIFEST', manifest: updatedManifest }], + updatedManifests: [{ appId: 'A0MANIFEST', manifest: updatedManifest }], + }); + } finally { + await server.stop(); + } + }); + it('rejects apps.manifest.create with invalid_manifest when the manifest is not JSON', async () => { const server = new MockSlackServer({ state: { diff --git a/packages/slack/src/mock-slack-server.ts b/packages/slack/src/mock-slack-server.ts index 9e0cdfd43..094dfcf9f 100644 --- a/packages/slack/src/mock-slack-server.ts +++ b/packages/slack/src/mock-slack-server.ts @@ -99,6 +99,10 @@ export type MockSlackState = { manifestCredentials?: MockSlackManifestCredentials; /** Apps created through `apps.manifest.create`, oldest first. */ createdManifests?: MockSlackCreatedManifest[]; + /** Manifest replacements applied through `apps.manifest.update`. */ + updatedManifests?: MockSlackCreatedManifest[]; + /** Controls whether manifest updates report that OAuth approval is needed. */ + manifestPermissionsUpdated?: boolean; }; export type MockSlackRoomoteTarget = { @@ -217,6 +221,23 @@ function maybeParseSlackFormValue(value: string): unknown { return value; } +function parseManifestRecord(value: unknown): JsonRecord | null { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as JsonRecord) + : null; + } catch { + return null; + } + } + + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + function parseRequestBody( request: IncomingMessage, bodyText: string, @@ -455,7 +476,7 @@ export class MockSlackServer { // App-config endpoints authenticate with configuration tokens instead of // bot tokens, so they skip the bot check and validate in their handler. if ( - url.pathname !== '/api/apps.manifest.create' && + !url.pathname.startsWith('/api/apps.manifest.') && !this.isAuthorized(request) ) { json(response, 401, { ok: false, error: 'invalid_auth' }); @@ -711,6 +732,100 @@ export class MockSlackServer { return; } + case 'POST apps.manifest.export': { + if (!this.isConfigTokenAuthorized(request)) { + json(response, 200, { ok: false, error: 'invalid_auth' }); + return; + } + + const appId = + typeof jsonBody.app_id === 'string' ? jsonBody.app_id : ''; + const app = (this.state.createdManifests ?? []).find( + (entry) => entry.appId === appId, + ); + + if (!app) { + json(response, 200, { ok: false, error: 'app_not_found' }); + return; + } + + json(response, 200, { ok: true, manifest: app.manifest }); + return; + } + + case 'POST apps.manifest.validate': { + if (!this.isConfigTokenAuthorized(request)) { + json(response, 200, { ok: false, error: 'invalid_auth' }); + return; + } + + const manifest = parseManifestRecord(jsonBody.manifest); + if (!manifest) { + json(response, 200, { + ok: false, + error: 'invalid_manifest', + errors: [ + { + message: 'manifest must be a JSON object', + pointer: '/manifest', + }, + ], + }); + return; + } + + json(response, 200, { ok: true, errors: [] }); + return; + } + + case 'POST apps.manifest.update': { + if (!this.isConfigTokenAuthorized(request)) { + json(response, 200, { ok: false, error: 'invalid_auth' }); + return; + } + + const appId = + typeof jsonBody.app_id === 'string' ? jsonBody.app_id : ''; + const manifest = parseManifestRecord(jsonBody.manifest); + const manifests = this.state.createdManifests ?? []; + const appIndex = manifests.findIndex((entry) => entry.appId === appId); + + if (appIndex < 0) { + json(response, 200, { ok: false, error: 'app_not_found' }); + return; + } + + if (!manifest) { + json(response, 200, { + ok: false, + error: 'invalid_manifest', + errors: [ + { + message: 'manifest must be a JSON object', + pointer: '/manifest', + }, + ], + }); + return; + } + + const updatedManifest = { appId, manifest }; + this.state.createdManifests = manifests.map((entry, index) => + index === appIndex ? updatedManifest : entry, + ); + this.state.updatedManifests = [ + ...(this.state.updatedManifests ?? []), + updatedManifest, + ]; + + json(response, 200, { + ok: true, + app_id: appId, + permissions_updated: this.state.manifestPermissionsUpdated ?? false, + }); + return; + } + case 'POST apps.manifest.delete': { // Real Slack reports Web API failures as HTTP 200 with `ok: false`. if (!this.isConfigTokenAuthorized(request)) {