diff --git a/packages/@n8n/api-types/src/agents/__tests__/slack-managed-app-settings.dto.test.ts b/packages/@n8n/api-types/src/agents/__tests__/slack-managed-app-settings.dto.test.ts new file mode 100644 index 00000000000..0baca83b297 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/slack-managed-app-settings.dto.test.ts @@ -0,0 +1,28 @@ +import { UpdateSlackManagedAppSettingsDto } from '../slack'; + +describe('UpdateSlackManagedAppSettingsDto', () => { + const validSettings = { + credentialId: 'bot-credential', + name: 'Support Bot', + description: 'Handles support requests', + alwaysOnline: true, + }; + + it('accepts valid managed Slack app settings', () => { + expect(UpdateSlackManagedAppSettingsDto.safeParse(validSettings).success).toBe(true); + }); + + it.each([ + [{ ...validSettings, name: '' }, 'name'], + [{ ...validSettings, name: 'a'.repeat(81) }, 'name'], + [{ ...validSettings, description: '' }, 'description'], + [{ ...validSettings, description: 'a'.repeat(141) }, 'description'], + ])('rejects invalid settings %#', (settings, path) => { + const result = UpdateSlackManagedAppSettingsDto.safeParse(settings); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toContain(path); + } + }); +}); diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index 51baa1cb5a6..fbb8a39a819 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -235,6 +235,7 @@ export class AgentDisconnectIntegrationDto extends Z.class({ type: z.string().min(1), // Empty string targets a draft integration entry (`credentialId: ''`). credentialId: z.string(), + deleteExternalResource: z.boolean().optional(), }) {} export class PublishAgentDto extends Z.class({ diff --git a/packages/@n8n/api-types/src/agents/slack/dto.ts b/packages/@n8n/api-types/src/agents/slack/dto.ts index 36ba9cb7a6d..c0aad1b7dee 100644 --- a/packages/@n8n/api-types/src/agents/slack/dto.ts +++ b/packages/@n8n/api-types/src/agents/slack/dto.ts @@ -5,3 +5,15 @@ import { Z } from '../../zod-class'; export class CreateSlackAgentAppDto extends Z.class({ appConfigurationToken: z.string().min(1), }) {} + +export class InstallSlackManagedAppDto extends Z.class({ + managerCredentialId: z.string().min(1), + workspaceId: z.string().min(1), +}) {} + +export class UpdateSlackManagedAppSettingsDto extends Z.class({ + credentialId: z.string().min(1), + name: z.string().trim().min(1).max(80), + description: z.string().trim().min(1).max(140), + alwaysOnline: z.boolean(), +}) {} diff --git a/packages/@n8n/api-types/src/agents/slack/types.ts b/packages/@n8n/api-types/src/agents/slack/types.ts index a7b8a3ef246..6135dacebca 100644 --- a/packages/@n8n/api-types/src/agents/slack/types.ts +++ b/packages/@n8n/api-types/src/agents/slack/types.ts @@ -3,9 +3,67 @@ export interface CreateSlackAgentAppResponse { installUrl: string; } +export interface SlackManagerCredentialSummary { + id: string; + name: string; + connected: boolean; + reconnectRequired: boolean; + workspaces: SlackManagedWorkspaceSummary[]; +} + +export interface SlackManagedWorkspaceSummary { + id: string; + name: string; + enterpriseId?: string; + managedAppId?: string; + botCredentialId?: string; + connected: boolean; +} + +export interface SlackManagedSetupState { + managedSetupAvailable: boolean; + managerCredentials: SlackManagerCredentialSummary[]; +} + +export interface SlackManagedAppSettings { + credentialId: string; + appId: string; + name: string; + description: string; + alwaysOnline: boolean; + appHomeUrl: string; +} + +export type SlackManagedAppSettingsErrorCode = 'service_limits_exceeded'; + +export type SlackApiErrorMeta = { + integrationType: 'slack'; + code: string; +}; + +export interface CreateSlackManagerCredentialResponse { + id: string; + name: string; + type: 'slackManagerOAuth2Api'; + isResolvable: false; +} + +export type InstallSlackManagedAppResponse = + | { + status: 'connected'; + appId: string; + credentialId: string; + } + | { + status: 'manual_install_required'; + appId: string; + installUrl: string; + }; + export interface SlackAgentAppManifest { display_information: { name: string; + description?: string; }; features: { app_home: { @@ -36,6 +94,10 @@ export interface SlackAgentAppManifest { org_deploy_enabled: boolean; socket_mode_enabled: boolean; token_rotation_enabled: boolean; + managed_app_settings?: { + is_install_from_slack_disabled: boolean; + external_app_management_url: string; + }; }; } diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 28a9cd60b40..457af2c66a9 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -46,6 +46,21 @@ export interface AgentIntegrationStatusResponse { integrations: AgentIntegrationStatusEntry[]; } +export interface AgentDisconnectIntegrationResponse { + status: 'disconnected'; + warning?: AgentIntegrationDisconnectWarning; +} + +export interface AgentIntegrationDisconnectWarning { + integrationType: string; + code: string; + action?: { + type: 'open_url'; + url: string; + }; + details?: Record; +} + export interface AgentSkillReference { path: string; content: string; diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index c4288acfbcf..acc076491b1 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -20,6 +20,7 @@ const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true'; generateUserManagementEmailTemplates(); generateTimezoneData(); copyInstanceAiExamplesData(); +copyAgentIntegrationAssets(); if (publicApiEnabled) { createPublicApiDirectory(); @@ -161,6 +162,39 @@ function copyInstanceAiExamplesData() { } } +function copyAgentIntegrationAssets() { + const sourceDir = path.resolve( + ROOT_DIR, + 'src', + 'modules', + 'agents', + 'integrations', + 'platforms', + 'slack', + 'assets', + ); + const destinationDir = path.resolve( + ROOT_DIR, + 'dist', + 'modules', + 'agents', + 'integrations', + 'platforms', + 'slack', + 'assets', + ); + + if (!existsSync(sourceDir)) { + throw new Error(`Agent integration assets directory not found: ${sourceDir}`); + } + shell.rm('-rf', destinationDir); + shell.mkdir('-p', path.dirname(destinationDir)); + shell.cp('-R', sourceDir, destinationDir); + if (!existsSync(destinationDir)) { + throw new Error(`Failed to copy agent integration assets to: ${destinationDir}`); + } +} + function generateTimezoneData() { const timezones = ['Etc/UTC', 'Etc/GMT', ...rawTimeZones.map((tz) => tz.name)]; const data = timezones.sort().reduce((acc, name) => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts index e7b34c7982b..ac020e69899 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-integration-management.service.test.ts @@ -48,6 +48,7 @@ describe('AgentIntegrationManagementService', () => { credentialTypes: ['slackApi'], }); registry.require.mockReturnValue(implementation); + registry.get.mockReturnValue(implementation); credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ { id: integration.credentialId, type: 'slackApi' }, ] as never); @@ -55,7 +56,6 @@ describe('AgentIntegrationManagementService', () => { agent, changed: true, })); - return { service: new AgentIntegrationManagementService( persistenceService, @@ -383,6 +383,67 @@ describe('AgentIntegrationManagementService', () => { }); describe('removing a channel', () => { + it('runs platform cleanup after durable removal and returns its warning', async () => { + const { service, persistenceService, chatService, implementation, agentRepository } = + makeService(); + const agent = makeAgent({ integrations: [integration] }); + const warning = { + integrationType: 'slack', + code: 'app_not_deleted', + action: { type: 'open_url', url: 'https://example.test/settings' }, + } as const; + const removal = mock>>(); + stubRow(agentRepository, [integration]); + removal.onRemove.mockResolvedValue(warning); + implementation.onRemove = removal.onRemove; + persistenceService.applyIntegrationDelta.mockResolvedValue({ + agent, + changed: true, + removed: integration, + }); + + await expect( + service.disconnect({ + agent, + user: user as never, + type: integration.type, + credentialId: integration.credentialId, + deleteExternalResource: true, + }), + ).resolves.toMatchObject({ warning }); + + expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan(order(removal.onRemove)); + expect(order(removal.onRemove)).toBeLessThan(order(chatService.disconnectChannel)); + }); + + it('tears down the runtime when platform cleanup fails after durable removal', async () => { + const { service, persistenceService, chatService, implementation } = makeService(); + const agent = makeAgent({ integrations: [integration] }); + const cleanupError = new Error('Slack cleanup failed'); + const removal = mock>>(); + removal.onRemove.mockRejectedValue(cleanupError); + implementation.onRemove = removal.onRemove; + persistenceService.applyIntegrationDelta.mockResolvedValue({ + agent, + changed: true, + removed: integration, + }); + + await expect( + service.disconnect({ + agent, + user: user as never, + type: integration.type, + credentialId: integration.credentialId, + deleteExternalResource: true, + }), + ).rejects.toBe(cleanupError); + + expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, integration); + expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan(order(removal.onRemove)); + expect(order(removal.onRemove)).toBeLessThan(order(chatService.disconnectChannel)); + }); + it('removes persistence before tearing down the runtime channel', async () => { const { service, persistenceService, chatService } = makeService(); const agent = makeAgent({ integrations: [integration] }); @@ -411,10 +472,12 @@ describe('AgentIntegrationManagementService', () => { ); }); - it('leaves the channel live when the durable removal fails', async () => { - const { service, persistenceService, chatService } = makeService(); + it('leaves the channel and its managed resources intact when durable removal fails', async () => { + const { service, persistenceService, chatService, implementation, agentRepository } = + makeService(); const removalError = new Error('write failed'); persistenceService.applyIntegrationDelta.mockRejectedValue(removalError); + stubRow(agentRepository, [integration]); await expect( service.disconnect({ @@ -422,9 +485,11 @@ describe('AgentIntegrationManagementService', () => { user: user as never, type: integration.type, credentialId: integration.credentialId, + deleteExternalResource: true, }), ).rejects.toBe(removalError); + expect(implementation.onRemove).not.toHaveBeenCalled(); expect(chatService.disconnectChannel).not.toHaveBeenCalled(); expect(chatService.disconnect).not.toHaveBeenCalled(); }); @@ -515,7 +580,7 @@ describe('AgentIntegrationManagementService', () => { describe('replacing a channel', () => { it('starts the new channel, swaps in one write, then releases the old one', async () => { - const { service, persistenceService, chatService } = makeService(); + const { service, persistenceService, chatService, implementation } = makeService(); const agent = makeAgent({ integrations: [replaced] }); persistenceService.applyIntegrationDelta.mockResolvedValue({ agent, @@ -538,6 +603,7 @@ describe('AgentIntegrationManagementService', () => { expect(order(chatService.connect)).toBeLessThan( order(persistenceService.applyIntegrationDelta), ); + expect(implementation.onRemove).not.toHaveBeenCalled(); expect(chatService.disconnectChannel).toHaveBeenCalledWith(agent.id, replaced); expect(order(persistenceService.applyIntegrationDelta)).toBeLessThan( order(chatService.disconnectChannel), @@ -587,7 +653,7 @@ describe('AgentIntegrationManagementService', () => { }); it('keeps the old channel live when the swap fails to persist', async () => { - const { service, persistenceService, chatService } = makeService(); + const { service, persistenceService, chatService, implementation } = makeService(); persistenceService.applyIntegrationDelta.mockRejectedValue(new Error('write failed')); const agent = makeAgent({ integrations: [replaced] }); @@ -603,8 +669,44 @@ describe('AgentIntegrationManagementService', () => { // Only the connection we just brought up is released, locally, and the old // one stays — on this main and on every peer. expect(chatService.disconnect).toHaveBeenCalledWith(agent.id, integration); + expect(implementation.onRemove).not.toHaveBeenCalled(); expect(chatService.disconnectChannel).not.toHaveBeenCalled(); expect(chatService.broadcastIntegrationChange).not.toHaveBeenCalled(); }); }); + + it.each([ + ['unpublished', null, undefined, true], + ['published', 'version-1', undefined, false], + ['unpublished with an explicit opt-out', null, false, false], + ['published with an explicit opt-in', 'version-1', true, true], + ])( + 'uses the expected external deletion policy for %s agents', + async (_scenario, activeVersionId, deleteExternalResource, expected) => { + const { service, persistenceService, implementation, agentRepository } = makeService(); + const connectedAgent = makeAgent({ activeVersionId, integrations: [integration] }); + stubRow(agentRepository, [integration], activeVersionId); + persistenceService.applyIntegrationDelta.mockResolvedValue({ + agent: connectedAgent, + changed: true, + removed: integration, + }); + + await service.disconnect({ + agent: connectedAgent, + user: user as never, + type: integration.type, + credentialId: integration.credentialId, + deleteExternalResource, + }); + + expect(implementation.onRemove).toHaveBeenCalledWith({ + agentId: connectedAgent.id, + projectId: connectedAgent.projectId, + credentialId: integration.credentialId, + user, + deleteExternalResource: expected, + }); + }, + ); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts index c5114dedf96..19e49af2712 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-slack-integrations.controller.test.ts @@ -6,6 +6,7 @@ import { expectProjectScopedAgentRoutes, getRoutesByHandlerName, } from './test-utils/controller-route-metadata'; +import type { SlackManagedSetupService } from '../integrations/platforms/slack/slack-managed-setup.service'; import type { SlackManualSetupService } from '../integrations/platforms/slack/slack-manual-setup.service'; const UNAUTHENTICATED_HANDLERS = new Set(['handleSlackAppOAuthCallback']); @@ -18,13 +19,25 @@ describe('AgentSlackIntegrationsController', () => { it.each([ ['createSlackApp', 'agent:update'], ['getSlackAppManifest', 'agent:read'], + ['getManagedSlackSetup', 'agent:read'], + ['createManagedSlackCredential', 'agent:update'], + ['finalizeManagedSlackCredential', 'agent:update'], + ['installManagedSlackApp', 'agent:update'], + ['getManagedSlackAppSettings', 'agent:read'], + ['updateManagedSlackAppSettings', 'agent:update'], ])('%s uses %s', (handlerName, scope) => { expect(routes.get(handlerName)?.accessScope?.scope).toBe(scope); }); - it('keeps the manual Slack route contracts', () => { + it('keeps the Slack route contracts', () => { expect([...routes.values()].map((route) => route.path).sort()).toEqual([ '/:agentId/integrations/slack/app', + '/:agentId/integrations/slack/managed/credentials', + '/:agentId/integrations/slack/managed/credentials/:credentialId/finalize', + '/:agentId/integrations/slack/managed/install', + '/:agentId/integrations/slack/managed/settings', + '/:agentId/integrations/slack/managed/settings/:credentialId', + '/:agentId/integrations/slack/managed/setup', '/:agentId/integrations/slack/manifest', '/:agentId/integrations/slack/oauth/callback', ]); @@ -32,7 +45,10 @@ describe('AgentSlackIntegrationsController', () => { it('binds the callback state to the route project and agent', async () => { const manualSetup = mock(); - const controller = new AgentSlackIntegrationsController(manualSetup); + const controller = new AgentSlackIntegrationsController( + manualSetup, + mock(), + ); const response = mock<{ render: (template: string, data?: unknown) => void }>(); await controller.handleSlackAppOAuthCallback( diff --git a/packages/cli/src/modules/agents/agent-integration-management.service.ts b/packages/cli/src/modules/agents/agent-integration-management.service.ts index 29bcc41e738..70e065b9688 100644 --- a/packages/cli/src/modules/agents/agent-integration-management.service.ts +++ b/packages/cli/src/modules/agents/agent-integration-management.service.ts @@ -1,4 +1,8 @@ -import { AgentIntegrationSchema, type AgentIntegrationConfig } from '@n8n/api-types'; +import { + AgentIntegrationSchema, + type AgentIntegrationConfig, + type AgentIntegrationDisconnectWarning, +} from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -33,13 +37,12 @@ export class AgentIntegrationManagementService { private readonly agentRepository: AgentRepository, ) {} - async validateConfig(input: unknown): Promise { - const parsed = await AgentIntegrationSchema.safeParseAsync(input); + async validateConfig(integration: unknown): Promise { + const parsed = await AgentIntegrationSchema.safeParseAsync(integration); if (!parsed.success) throw new BadRequestError(parsed.error.message); - - const integration = parsed.data; - this.registry.require(integration.type).validateConfig?.(integration); - return integration; + const result = parsed.data; + this.registry.require(result.type).validateConfig?.(result); + return result; } /** @@ -75,16 +78,22 @@ export class AgentIntegrationManagementService { user: User; type: string; credentialId: string; + deleteExternalResource?: boolean; modifiedBy?: AgentActor; - }): Promise<{ savedAgent: Agent }> { + }): Promise<{ savedAgent: Agent; warning?: AgentIntegrationDisconnectWarning }> { const result = await this.applyChange({ agent: options.agent, user: options.user, remove: { type: options.type, credentialId: options.credentialId }, + cleanupRemovedIntegration: true, + deleteExternalResource: options.deleteExternalResource, modifiedBy: options.modifiedBy ?? 'user', }); - return { savedAgent: result.agent }; + return { + savedAgent: result.agent, + ...(result.warning ? { warning: result.warning } : {}), + }; } /** @@ -102,8 +111,10 @@ export class AgentIntegrationManagementService { user: User; add?: AgentIntegrationConfig; remove?: IntegrationRef; + cleanupRemovedIntegration?: boolean; + deleteExternalResource?: boolean; modifiedBy: AgentActor; - }): Promise { + }): Promise { return await this.serializePerAgent( options.agent.id, async () => await this.runChange(options), @@ -136,8 +147,10 @@ export class AgentIntegrationManagementService { user: User; add?: AgentIntegrationConfig; remove?: IntegrationRef; + cleanupRemovedIntegration?: boolean; + deleteExternalResource?: boolean; modifiedBy: AgentActor; - }): Promise { + }): Promise { const { agent, add } = options; // "Replace this channel with itself" is just a connect. Left as a removal, // step 3 would release the connection step 1 just brought up, because both @@ -151,7 +164,10 @@ export class AgentIntegrationManagementService { // for both decisions that depend on it: whether to connect at all, and what // a rollback would restore to. The write does its own read and reconciles // anything that lands after this one. - const state = add ? await this.agentRepository.findIntegrationState(agent.id) : null; + const state = + add || options.cleanupRemovedIntegration + ? await this.agentRepository.findIntegrationState(agent.id) + : null; const publishedBefore = state ? state.activeVersionId !== null : agent.activeVersionId !== null; // `connect` restarts a connection that is already live — a settings-only @@ -206,12 +222,30 @@ export class AgentIntegrationManagementService { ); } - if (remove) await this.releaseRemoved(agent, remove, result); + const isPublished = result.published ?? publishedBefore; + let warning: AgentIntegrationDisconnectWarning | undefined; + try { + warning = + result.removed && options.cleanupRemovedIntegration + ? await this.registry.get(result.removed.type)?.onRemove?.({ + agentId: agent.id, + projectId: agent.projectId, + credentialId: result.removed.credentialId, + user: options.user, + deleteExternalResource: + // if not published, by default delete the external resource + options.deleteExternalResource ?? !isPublished, + }) + : undefined; + } finally { + if (remove) await this.releaseRemoved(agent, remove, result); + } + if (connected && add) { await this.chatService.broadcastIntegrationChange(agent.id, add, 'connect'); } - return result; + return { ...result, ...(warning ? { warning } : {}) }; } /** @@ -365,6 +399,7 @@ export class AgentIntegrationManagementService { integration: AgentIntegrationConfig, ): Promise { const implementation = this.registry.require(integration.type); + const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( user, { projectId: agent.projectId }, diff --git a/packages/cli/src/modules/agents/agent-integrations.controller.ts b/packages/cli/src/modules/agents/agent-integrations.controller.ts index ec528fae44f..365be197b5a 100644 --- a/packages/cli/src/modules/agents/agent-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-integrations.controller.ts @@ -1,6 +1,7 @@ import { AgentConnectIntegrationDto, AgentDisconnectIntegrationDto, + type AgentDisconnectIntegrationResponse, isDraftIntegration, type AgentIntegrationStatusResponse, } from '@n8n/api-types'; @@ -8,14 +9,14 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators'; import type { Request, Response } from 'express'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; - import { AgentIntegrationManagementService } from './agent-integration-management.service'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; import { ChatIntegrationService } from './integrations/chat-integration.service'; import { channelIntegrationRecorder } from './integrations/recording/channel-integration-recorder'; import { AgentRepository } from './repositories/agent.repository'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + @RestController('/projects/:projectId/agents/v2') export class AgentIntegrationsController { constructor( @@ -56,18 +57,19 @@ export class AgentIntegrationsController { _res: Response, @Param('agentId') agentId: string, @Body payload: AgentDisconnectIntegrationDto, - ) { - const { type, credentialId } = payload; + ): Promise { + const { type, credentialId, deleteExternalResource } = payload; const agent = await this.agentRepository.findByIdAndProjectId(agentId, req.params.projectId); if (!agent) throw new NotFoundError(`Agent "${agentId}" not found`); - await this.integrationManagementService.disconnect({ + const { warning } = await this.integrationManagementService.disconnect({ agent, user: req.user, type, credentialId, + deleteExternalResource, }); - return { status: 'disconnected' }; + return { status: 'disconnected', ...(warning ? { warning } : {}) }; } @Get('/:agentId/integrations/status') diff --git a/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts index 7efe1b722b2..1f2b26d864c 100644 --- a/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts +++ b/packages/cli/src/modules/agents/agent-slack-integrations.controller.ts @@ -1,17 +1,27 @@ import { CreateSlackAgentAppDto, type CreateSlackAgentAppResponse, + type CreateSlackManagerCredentialResponse, + InstallSlackManagedAppDto, + type InstallSlackManagedAppResponse, type SlackAgentAppManifestResponse, + type SlackManagedAppSettings, + type SlackManagedSetupState, + UpdateSlackManagedAppSettingsDto, } from '@n8n/api-types'; import type { AuthenticatedRequest } from '@n8n/db'; import { Body, Get, Param, Post, ProjectScope, RestController } from '@n8n/decorators'; import type { Request, Response } from 'express'; +import { SlackManagedSetupService } from './integrations/platforms/slack/slack-managed-setup.service'; import { SlackManualSetupService } from './integrations/platforms/slack/slack-manual-setup.service'; @RestController('/projects/:projectId/agents/v2') export class AgentSlackIntegrationsController { - constructor(private readonly manualSetup: SlackManualSetupService) {} + constructor( + private readonly manualSetup: SlackManualSetupService, + private readonly managedSetup: SlackManagedSetupService, + ) {} @Post('/:agentId/integrations/slack/app') @ProjectScope('agent:update') @@ -36,9 +46,102 @@ export class AgentSlackIntegrationsController { _res: Response, @Param('agentId') agentId: string, ): Promise { - return await this.manualSetup.getManifest({ + return await this.manualSetup.getManifest({ projectId: req.params.projectId, agentId }); + } + + @Get('/:agentId/integrations/slack/managed/setup') + @ProjectScope('agent:read') + async getManagedSlackSetup( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + return await this.managedSetup.getSetupState({ projectId: req.params.projectId, agentId, + user: req.user, + }); + } + + @Post('/:agentId/integrations/slack/managed/credentials') + @ProjectScope('agent:update') + async createManagedSlackCredential( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + ): Promise { + return await this.managedSetup.createManagerCredential({ + projectId: req.params.projectId, + agentId, + user: req.user, + }); + } + + @Post('/:agentId/integrations/slack/managed/credentials/:credentialId/finalize') + @ProjectScope('agent:update') + async finalizeManagedSlackCredential( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('credentialId') credentialId: string, + ): Promise { + await this.managedSetup.finalizeManagerCredential({ + projectId: req.params.projectId, + agentId, + credentialId, + user: req.user, + }); + } + + @Post('/:agentId/integrations/slack/managed/install') + @ProjectScope('agent:update') + async installManagedSlackApp( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: InstallSlackManagedAppDto, + ): Promise { + return await this.managedSetup.installApp({ + projectId: req.params.projectId, + agentId, + user: req.user, + managerCredentialId: payload.managerCredentialId, + workspaceId: payload.workspaceId, + }); + } + + @Get('/:agentId/integrations/slack/managed/settings/:credentialId') + @ProjectScope('agent:read') + async getManagedSlackAppSettings( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Param('credentialId') credentialId: string, + ): Promise { + return await this.managedSetup.getAppSettings({ + projectId: req.params.projectId, + agentId, + credentialId, + user: req.user, + }); + } + + @Post('/:agentId/integrations/slack/managed/settings') + @ProjectScope('agent:update') + async updateManagedSlackAppSettings( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('agentId') agentId: string, + @Body payload: UpdateSlackManagedAppSettingsDto, + ): Promise { + return await this.managedSetup.updateAppSettings({ + projectId: req.params.projectId, + agentId, + user: req.user, + credentialId: payload.credentialId, + name: payload.name, + description: payload.description, + alwaysOnline: payload.alwaysOnline, }); } @@ -58,10 +161,7 @@ export class AgentSlackIntegrationsController { const { code, state, error, error_description: errorDescription } = req.query; if (error) { return res.render('oauth-error-callback', { - error: { - message: error, - ...(errorDescription ? { reason: errorDescription } : {}), - }, + error: { message: error, ...(errorDescription ? { reason: errorDescription } : {}) }, }); } if (!code || !state) { @@ -79,10 +179,11 @@ export class AgentSlackIntegrationsController { }); return res.render('oauth-callback'); } catch (callbackError) { - const message = - callbackError instanceof Error ? callbackError.message : 'Slack app setup failed'; return res.render('oauth-error-callback', { - error: { message }, + error: { + message: + callbackError instanceof Error ? callbackError.message : 'Slack app setup failed', + }, }); } } diff --git a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts index 90191230ca5..d1e32c47426 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/agent-chat-bridge.test.ts @@ -6,6 +6,7 @@ import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; import { type Logger } from 'n8n-workflow'; +import type { AgentRepository } from '../../repositories/agent.repository'; import { AgentChatBridge } from '../agent-chat-bridge'; import { AgentChatIntegration, @@ -237,7 +238,7 @@ describe('AgentChatBridge — consumeStream', () => { registry.register(new BufferingTestIntegration()); registry.register(new StreamingTestIntegration()); registry.register(new FormattedBufferedTestIntegration()); - registry.register(new SlackIntegration()); + registry.register(new SlackIntegration(mock())); Container.set(ChatIntegrationRegistry, registry); }); @@ -1881,7 +1882,7 @@ describe('AgentChatBridge — Slack thread history', () => { beforeEach(() => { const registry = new ChatIntegrationRegistry(); - registry.register(new SlackIntegration()); + registry.register(new SlackIntegration(mock())); Container.set(ChatIntegrationRegistry, registry); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts index 36d40abc209..e61647ca6a7 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/component-mapper.test.ts @@ -1,4 +1,7 @@ import type { Mock } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import type { AgentRepository } from '../../repositories/agent.repository'; /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention -- mocks the Slack-style SDK (PascalCase components) and intentionally uses any-based factory wrappers */ // Define mocks inline inside the factory to avoid vi.mock hoisting issues type MockFn = Mock<(...args: any[]) => any>; @@ -465,7 +468,7 @@ describe('ComponentMapper', () => { it('should preserve radio_select components for Slack cards', async () => { const registry = new ChatIntegrationRegistry(); - registry.register(new SlackIntegration()); + registry.register(new SlackIntegration(mock())); Container.set(ChatIntegrationRegistry, registry); const payload = { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts index 0a4618e3c55..6d9e95cc13c 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/slack/replay-test-context.ts @@ -2,7 +2,9 @@ import type { StreamChunk } from '@n8n/agents'; import type { AgentIntegrationConfig } from '@n8n/api-types'; import nock from 'nock'; import type { Mock } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import type { AgentRepository } from '../../../../repositories/agent.repository'; import type { ChatInstance } from '../../../chat-integration.service'; import { ComponentMapper } from '../../../component-mapper'; import type { @@ -216,7 +218,7 @@ export async function createSlackReplayContext( const integration: AgentIntegrationConfig = { type: 'slack', credentialId: 'cred-slack' }; const setup = createReplayContextSetup({ chat: chat as never, - integrationImpl: new SlackIntegration(), + integrationImpl: new SlackIntegration(mock()), integration, componentMapper: new ComponentMapper(), stream: options.stream, diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts index ad0194304e7..06f643ec10a 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-action-executor.test.ts @@ -48,6 +48,7 @@ import type { OutboundHttp } from '@n8n/backend-network'; import { Container } from '@n8n/di'; import { mock } from 'vitest-mock-extended'; +import type { AgentRepository } from '../../repositories/agent.repository'; import { AgentChatIntegration, ChatIntegrationRegistry, @@ -106,7 +107,7 @@ class ShortCallbackTelegramIntegration extends AgentChatIntegration { function buildRegistry(): ChatIntegrationRegistry { const registry = new ChatIntegrationRegistry(); - registry.register(new SlackIntegration()); + registry.register(new SlackIntegration(mock())); registry.register(new LinearIntegration(mock(), mock())); return registry; } diff --git a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts index fb94a00ae10..dae3679e1d2 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/integration-context-query-executor.test.ts @@ -2,6 +2,7 @@ import type { Logger } from '@n8n/backend-common'; import type { OutboundHttp } from '@n8n/backend-network'; import { mock } from 'vitest-mock-extended'; +import type { AgentRepository } from '../../repositories/agent.repository'; import { ChatIntegrationRegistry } from '../agent-chat-integration'; import type { ChatIntegrationService, ChatInstance } from '../chat-integration.service'; import { ChatIntegrationContextQueryExecutor } from '../integration-context-query-executor'; @@ -22,7 +23,7 @@ const linear: AgentIntegrationConfig = { function buildRegistry(): ChatIntegrationRegistry { const registry = new ChatIntegrationRegistry(); - registry.register(new SlackIntegration()); + registry.register(new SlackIntegration(mock())); registry.register(new LinearIntegration(mock(), mock())); return registry; } diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts new file mode 100644 index 00000000000..6e2bb3d3710 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-app-setup.service.test.ts @@ -0,0 +1,1538 @@ +import type { Mock, Mocked } from 'vitest'; +import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; +import type { CredentialsEntity, User, UserRepository } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; +import type { Cipher } from 'n8n-core'; + +import type { CredentialsService } from '@/credentials/credentials.service'; +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import type { CredentialsOverwrites } from '@/credentials-overwrites'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { CacheService } from '@/services/cache/cache.service'; +import type { ProjectService } from '@/services/project.service.ee'; +import type { UrlService } from '@/services/url.service'; + +import type { AgentIntegrationManagementService } from '../../agent-integration-management.service'; +import type { AgentRepository } from '../../repositories/agent.repository'; +import { + SlackManagedSetupService, + type DeleteManagedSlackAppOptions, + type FinalizeSlackManagerCredentialOptions, + type GetManagedSetupStateOptions, + type GetManagedSlackAppSettingsOptions, + type InstallManagedSlackAppOptions, + type UpdateManagedSlackAppSettingsOptions, +} from '../platforms/slack/slack-managed-setup.service'; +import { + SlackManualSetupService, + type CompleteSlackAppInstallOptions, + type CreateSlackAppOptions, + type GetSlackAppManifestOptions, +} from '../platforms/slack/slack-manual-setup.service'; +import { SlackMethodsService } from '../platforms/slack/slack-methods.service'; + +const agent = { + id: 'agent-1', + projectId: 'project-1', + name: 'Support Agent', + activeVersionId: 'v1', + activeVersion: {}, + integrations: [], +}; + +const unpublishedAgent = { + ...agent, + activeVersionId: null, + activeVersion: null, +}; + +const user = { id: 'user-1' } as User; + +function slackResponse(body: Record) { + return { statusCode: 200, body }; +} + +function slackAppCreatedResponse() { + return slackResponse({ + ok: true, + app_id: 'A123', + credentials: { + client_id: 'C123', + client_secret: 'client-secret', + signing_secret: 'signing-secret', + }, + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=C123&scope=chat%3Awrite', + }); +} + +function slackOAuthResponse() { + return slackResponse({ + ok: true, + access_token: 'xoxb-installed-token', + token_type: 'bot', + app_id: 'A123', + }); +} + +function fetchParams(requestMock: Mock, callIndex: number) { + const request = requestMock.mock.calls[callIndex]?.[0] as { + headers?: Record; + body: Record; + }; + // The body is passed as a plain object; OutboundHttp/axios only serializes it as + // form-urlencoded when this content-type is set, so assert the contract here. + expect(request.headers?.['content-type']).toBe('application/x-www-form-urlencoded'); + return new URLSearchParams(request.body); +} + +describe('Slack setup services', () => { + let requestMock: Mock; + let outboundHttp: Mocked; + let cacheStore: Map; + let cacheService: Mocked; + let cipher: Mocked; + let credentialsService: Mocked; + let credentialsFinderService: Mocked; + let credentialsOverwrites: Mocked; + let userRepository: Mocked; + let projectService: Mocked; + let agentRepository: Mocked; + let integrationManagementService: Mocked; + let service: { + createApp: SlackManualSetupService['createApp']; + getManualManifest: ( + options: GetSlackAppManifestOptions, + ) => ReturnType; + completeInstall: (options: CompleteSlackAppInstallOptions) => Promise; + isManagedSetupAvailable: SlackManagedSetupService['isSetupAvailable']; + createManagerCredential: ( + options: GetManagedSetupStateOptions, + ) => ReturnType; + finalizeManagerCredential: ( + options: FinalizeSlackManagerCredentialOptions, + ) => ReturnType; + getManagedSetupState: ( + options: GetManagedSetupStateOptions, + ) => ReturnType; + installManagedApp: ( + options: InstallManagedSlackAppOptions, + ) => ReturnType; + getManagedAppSettings: ( + options: GetManagedSlackAppSettingsOptions, + ) => ReturnType; + updateManagedAppSettings: ( + options: UpdateManagedSlackAppSettingsOptions, + ) => ReturnType; + deleteManagedAppForCredential: ( + options: DeleteManagedSlackAppOptions, + ) => ReturnType; + }; + + beforeEach(() => { + const httpClient = mock(); + requestMock = httpClient.request as Mock; + outboundHttp = mock(); + outboundHttp.requests.mockReturnValue(httpClient); + + cacheStore = new Map(); + cacheService = mock(); + cacheService.set.mockImplementation(async (key: string, value: unknown) => { + cacheStore.set(key, value); + }); + cacheService.get.mockImplementation(async (key: string) => cacheStore.get(key)); + cacheService.take.mockImplementation(async (key: string) => { + const value = cacheStore.get(key); + cacheStore.delete(key); + return value; + }); + cacheService.delete.mockImplementation(async (key: string) => { + cacheStore.delete(key); + }); + + cipher = mock(); + cipher.encryptV2.mockImplementation(async (data: string | object) => { + const plaintext = typeof data === 'string' ? data : JSON.stringify(data); + return `encrypted:${Buffer.from(plaintext).toString('base64')}`; + }); + cipher.decryptV2.mockImplementation(async (data: string) => + Buffer.from(data.replace(/^encrypted:/, ''), 'base64').toString(), + ); + + credentialsService = mock(); + credentialsFinderService = mock(); + credentialsOverwrites = mock(); + userRepository = mock(); + projectService = mock(); + projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never); + agentRepository = mock(); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never); + integrationManagementService = mock(); + integrationManagementService.connect.mockImplementation(async ({ agent, integration }) => ({ + integration: integration as never, + savedAgent: agent, + })); + const urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('https://hooks.example/'); + urlService.getInstanceBaseUrl.mockReturnValue('https://hooks.example'); + + const methods = new SlackMethodsService( + credentialsService, + agentRepository, + integrationManagementService, + urlService, + outboundHttp, + cacheService, + cipher, + ); + const manualService = new SlackManualSetupService( + methods, + userRepository, + cacheService, + cipher, + projectService, + ); + const managedService = new SlackManagedSetupService( + methods, + cacheService, + cipher, + credentialsService, + credentialsFinderService, + credentialsOverwrites, + agentRepository, + ); + service = { + createApp: async (options: CreateSlackAppOptions) => await manualService.createApp(options), + getManualManifest: async (options) => await manualService.getManifest(options), + completeInstall: async (options) => await manualService.completeInstall(options), + isManagedSetupAvailable: () => managedService.isSetupAvailable(), + createManagerCredential: async (options) => + await managedService.createManagerCredential(options), + finalizeManagerCredential: async (options) => + await managedService.finalizeManagerCredential(options), + getManagedSetupState: async (options) => await managedService.getSetupState(options), + installManagedApp: async (options) => await managedService.installApp(options), + getManagedAppSettings: async (options) => await managedService.getAppSettings(options), + updateManagedAppSettings: async (options) => await managedService.updateAppSettings(options), + deleteManagedAppForCredential: async (options) => + await managedService.deleteAppForCredential(options), + }; + }); + + async function beginInstall() { + requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); + const { installUrl } = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'xoxe-config', + user, + }); + return new URL(installUrl).searchParams.get('state') ?? ''; + } + + it('creates a Slack app from an agent manifest and returns an install URL with state', async () => { + requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); + + const result = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'xoxe-config', + user, + }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://slack.com/api/apps.manifest.create', + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/x-www-form-urlencoded', + }), + }), + ); + + const createParams = fetchParams(requestMock, 0); + expect(createParams.get('token')).toBe('xoxe-config'); + const manifest = JSON.parse(createParams.get('manifest') ?? '') as { + features: { + app_home: { + home_tab_enabled: boolean; + messages_tab_enabled: boolean; + messages_tab_read_only_enabled: boolean; + }; + }; + oauth_config: { redirect_urls: string[]; scopes: { bot: string[] } }; + settings: { + event_subscriptions: { request_url: string; bot_events: string[] }; + interactivity: { is_enabled: boolean; request_url: string }; + socket_mode_enabled: boolean; + token_rotation_enabled: boolean; + }; + }; + const webhookUrl = + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack'; + const callbackUrl = + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback'; + expect(manifest.oauth_config.redirect_urls).toEqual([callbackUrl]); + expect(manifest.features.app_home).toEqual({ + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }); + expect(manifest.oauth_config.scopes.bot).toEqual( + expect.arrayContaining(['channels:history', 'groups:history', 'im:history', 'mpim:history']), + ); + expect(manifest.settings.event_subscriptions.request_url).toBe(webhookUrl); + expect(manifest.settings.event_subscriptions.bot_events).toEqual([ + 'app_mention', + 'assistant_thread_started', + 'assistant_thread_context_changed', + 'message.channels', + 'message.groups', + 'message.im', + 'message.mpim', + ]); + expect(manifest.settings.interactivity).toEqual({ + is_enabled: true, + request_url: webhookUrl, + }); + expect(manifest.settings.socket_mode_enabled).toBe(false); + expect(manifest.settings.token_rotation_enabled).toBe(false); + + expect(result.appId).toBe('A123'); + const installUrl = new URL(result.installUrl); + const state = installUrl.searchParams.get('state'); + expect(state).toBeTruthy(); + expect(installUrl.searchParams.get('redirect_uri')).toBe(callbackUrl); + expect(cacheService.set).toHaveBeenCalledWith( + `agents:slack-app-setup:${state}`, + expect.stringMatching(/^encrypted:/), + 60 * 60 * 1000, + ); + const cachedSession = cacheStore.get(`agents:slack-app-setup:${state}`); + expect(cachedSession).not.toEqual(expect.objectContaining({ clientSecret: 'client-secret' })); + expect(String(cachedSession)).not.toContain('client-secret'); + expect(String(cachedSession)).not.toContain('signing-secret'); + + const plaintextSession = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as { + agentId: string; + projectId: string; + userId: string; + appId: string; + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUrl: string; + }; + expect(plaintextSession).toEqual({ + agentId: 'agent-1', + projectId: 'project-1', + userId: 'user-1', + appId: 'A123', + clientId: 'C123', + clientSecret: 'client-secret', + signingSecret: 'signing-secret', + redirectUrl: callbackUrl, + }); + expect(credentialsService.createManagedCredential).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + }); + + it('returns the manual Slack app manifest without OAuth redirect URLs', async () => { + const result = await service.getManualManifest({ + projectId: 'project-1', + agentId: 'agent-1', + }); + + expect(result.manifest.display_information.name).toBe('Support Agent'); + expect(result.manifest.features.app_home).toEqual({ + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }); + expect(result.manifest.oauth_config).not.toHaveProperty('redirect_urls'); + expect(result.manifest.oauth_config.scopes.bot).toContain('chat:write'); + expect(result.manifest.settings.event_subscriptions.request_url).toBe( + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', + ); + expect(result.manifest.settings.interactivity).toEqual({ + is_enabled: true, + request_url: 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/webhooks/slack', + }); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('saves, connects, and broadcasts a Slack integration for a published agent', async () => { + requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce( + slackResponse({ + ok: true, + access_token: 'xoxb-installed-token', + token_type: 'bot', + app_id: 'A123', + }), + ); + userRepository.findOne.mockResolvedValue(user); + credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); + + const { installUrl } = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'xoxe-config', + user, + }); + const state = new URL(installUrl).searchParams.get('state'); + expect(state).toBeTruthy(); + const encryptedSession = cacheStore.get(`agents:slack-app-setup:${state}`); + + await service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state: state ?? '', + }); + + const tokenRequest = requestMock.mock.calls[1]?.[0] as { + url: string; + headers: Record; + }; + expect(tokenRequest.url).toBe('https://slack.com/api/oauth.v2.access'); + expect(tokenRequest.headers).toEqual( + expect.objectContaining({ + authorization: `Basic ${Buffer.from('C123:client-secret').toString('base64')}`, + 'content-type': 'application/x-www-form-urlencoded', + }), + ); + const tokenParams = fetchParams(requestMock, 1); + expect(tokenParams.get('code')).toBe('slack-code'); + expect(tokenParams.get('redirect_uri')).toBe( + 'https://hooks.example/rest/projects/project-1/agents/v2/agent-1/integrations/slack/oauth/callback', + ); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-1' }, + relations: ['role'], + }); + expect(projectService.getProjectWithScope).toHaveBeenCalledWith(user, 'project-1', [ + 'agent:update', + 'credential:create', + ]); + expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( + { + name: 'Slack - Support Agent', + type: 'slackApi', + data: { + accessToken: 'xoxb-installed-token', + signatureSecret: 'signing-secret', + }, + projectId: 'project-1', + }, + user, + ); + const integration = { type: 'slack', credentialId: 'cred-slack' }; + expect(integrationManagementService.connect).toHaveBeenCalledWith({ + agent, + user, + integration, + }); + expect(cacheService.take).toHaveBeenCalledWith(`agents:slack-app-setup:${state}`); + expect(cipher.decryptV2).toHaveBeenCalledWith(encryptedSession); + await expect( + service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state: state ?? '', + }), + ).rejects.toThrow('expired or is invalid'); + }); + + it('revalidates project permissions before completing Slack setup', async () => { + const state = await beginInstall(); + userRepository.findOne.mockResolvedValue(user); + projectService.getProjectWithScope.mockResolvedValue(null); + + await expect( + service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state, + }), + ).rejects.toThrow('You do not have permission to complete Slack app setup'); + + expect(requestMock).toHaveBeenCalledOnce(); + }); + + it('does not broadcast when connecting a published Slack install fails', async () => { + const state = await beginInstall(); + requestMock.mockResolvedValueOnce(slackOAuthResponse()); + userRepository.findOne.mockResolvedValue(user); + credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); + const connectError = new Error('Slack connect failed'); + integrationManagementService.connect.mockRejectedValue(connectError); + + await expect( + service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state, + }), + ).rejects.toBe(connectError); + + expect(integrationManagementService.connect).toHaveBeenCalledWith({ + agent, + user, + integration: { type: 'slack', credentialId: 'cred-slack' }, + }); + }); + + it('saves without connecting or broadcasting for an unpublished agent', async () => { + agentRepository.findByIdAndProjectId + .mockResolvedValueOnce(agent as never) + .mockResolvedValueOnce(unpublishedAgent as never); + requestMock.mockResolvedValueOnce(slackAppCreatedResponse()).mockResolvedValueOnce( + slackResponse({ + ok: true, + access_token: 'xoxb-installed-token', + token_type: 'bot', + app_id: 'A123', + }), + ); + userRepository.findOne.mockResolvedValue(user); + credentialsService.createUnmanagedCredential.mockResolvedValue({ id: 'cred-slack' } as never); + + const { installUrl } = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'xoxe-config', + user, + }); + const state = new URL(installUrl).searchParams.get('state') ?? ''; + + await service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state, + }); + + const integration = { type: 'slack', credentialId: 'cred-slack' }; + expect(integrationManagementService.connect).toHaveBeenCalledWith({ + agent: unpublishedAgent, + user, + integration, + }); + }); + + it('rejects a callback state that does not belong to the requested project and agent', async () => { + requestMock.mockResolvedValueOnce(slackAppCreatedResponse()); + + const { installUrl } = await service.createApp({ + projectId: 'project-1', + agentId: 'agent-1', + appConfigurationToken: 'xoxe-config', + user, + }); + const state = new URL(installUrl).searchParams.get('state') ?? ''; + + await expect( + service.completeInstall({ + projectId: 'project-2', + agentId: 'agent-1', + code: 'slack-code', + state, + }), + ).rejects.toThrow(BadRequestError); + expect(credentialsService.createManagedCredential).not.toHaveBeenCalled(); + expect(integrationManagementService.connect).not.toHaveBeenCalled(); + }); + + it.each([ + [undefined, false], + [ + { + clientId: 'client', + clientSecret: 'secret', + }, + true, + ], + ])('gates managed setup from the complete Slack OAuth overwrite', (overwrite, expected) => { + credentialsOverwrites.getOverwrites.mockReturnValue(overwrite); + + expect(service.isManagedSetupAvailable()).toBe(expected); + }); + + it('creates a project-scoped Slack manager OAuth credential', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsService.createUnmanagedCredential.mockResolvedValue({ + id: 'manager', + name: 'Workspace credentials', + } as never); + + await expect( + service.createManagerCredential({ + projectId: 'project-1', + agentId: 'agent-1', + user, + }), + ).resolves.toEqual({ + id: 'manager', + name: 'Workspace credentials', + type: 'slackManagerOAuth2Api', + isResolvable: false, + }); + expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( + { + name: 'Workspace credentials', + type: 'slackManagerOAuth2Api', + data: {}, + projectId: 'project-1', + }, + user, + ); + }); + + it.each([ + { + scenario: 'generated manager credential name', + name: 'Workspace credentials', + expectedName: 'Workspace credentials - jane @ Acme', + }, + { + scenario: 'custom manager credential name', + name: 'Custom manager name', + expectedName: undefined, + }, + ])('handles the $scenario after OAuth', async ({ name, expectedName }) => { + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'manager', + name, + type: 'slackManagerOAuth2Api', + data: 'encrypted', + } as CredentialsEntity); + const rawData = { + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }; + credentialsService.decrypt.mockResolvedValue(rawData); + credentialsService.createEncryptedData.mockResolvedValue({ + name: expectedName, + type: 'slackManagerOAuth2Api', + data: 're-encrypted', + } as never); + requestMock.mockResolvedValueOnce( + slackResponse({ + ok: true, + user: 'jane', + team: 'Acme', + }), + ); + + await service.finalizeManagerCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'manager', + user, + }); + + if (expectedName) { + expect(credentialsService.createEncryptedData).toHaveBeenCalledWith({ + id: 'manager', + name: expectedName, + type: 'slackManagerOAuth2Api', + data: rawData, + }); + expect(credentialsService.update).toHaveBeenCalledWith( + 'manager', + { + name: expectedName, + type: 'slackManagerOAuth2Api', + data: 're-encrypted', + }, + rawData, + ); + } else { + expect(credentialsService.createEncryptedData).not.toHaveBeenCalled(); + expect(credentialsService.update).not.toHaveBeenCalled(); + expect(requestMock).not.toHaveBeenCalled(); + } + }); + + it('returns managed Slack setup before the agent is persisted', async () => { + agentRepository.findByIdAndProjectId.mockResolvedValue(null); + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'managed', type: 'slackManagerOAuth2Api', name: 'Managed Slack' }, + { id: 'custom', type: 'slackManagerOAuth2Api', name: 'Custom Slack' }, + { id: 'bot', type: 'slackApi', name: 'Slack Bot' }, + ] as never); + const managedCredential = { + id: 'managed', + name: 'Managed Slack', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + const customCredential = { + id: 'custom', + name: 'Custom Slack', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce(managedCredential) + .mockResolvedValueOnce(customCredential); + const managedData = { + oauthTokenData: { + authed_user: { + id: 'U123', + access_token: 'xoxp-manager', + scope: 'app_configurations:read,app_configurations:write,managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }; + credentialsService.decrypt + .mockResolvedValueOnce(managedData) + .mockResolvedValueOnce({ clientId: 'custom-client' }); + credentialsOverwrites.usesManagedAuth.mockImplementation((_type, data) => data === managedData); + + await expect( + service.getManagedSetupState({ + projectId: 'project-1', + agentId: 'agent-1', + user, + }), + ).resolves.toEqual({ + managedSetupAvailable: true, + managerCredentials: [ + { + id: 'managed', + name: 'Managed Slack', + connected: true, + reconnectRequired: false, + workspaces: [ + { + id: 'T123', + name: 'Example workspace', + connected: false, + }, + ], + }, + ], + }); + }); + + it('refreshes the manager token during icon setup and auto-installs the bot credential', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + { id: 'bot-credential', type: 'slackApi' }, + ] as never); + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser.mockResolvedValue(managerCredential); + credentialsService.decrypt.mockResolvedValue({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + refresh_token: 'xoxe-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }); + requestMock + .mockResolvedValueOnce(slackAppCreatedResponse()) + .mockResolvedValueOnce(slackResponse({ ok: false, error: 'token_expired' })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + user_id: 'U123', + access_token: 'xoxp-refreshed', + expires_in: 43200, + refresh_token: 'xoxe-refreshed', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + token_type: 'user', + }), + ) + .mockResolvedValueOnce(slackResponse({ ok: true })) + .mockResolvedValueOnce( + slackResponse({ + ok: true, + app_id: 'A123', + api_access_tokens: { bot_access_token: 'xoxb-managed' }, + }), + ); + credentialsService.createEncryptedData.mockResolvedValue({ data: 'encrypted' } as never); + credentialsService.createManagedCredential.mockResolvedValue({ + id: 'bot-credential', + } as never); + + await expect( + service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }), + ).resolves.toEqual({ + status: 'connected', + appId: 'A123', + credentialId: 'bot-credential', + }); + + const manifestParams = fetchParams(requestMock, 0); + const manifest = JSON.parse(manifestParams.get('manifest') ?? '') as { + display_information: { description: string }; + settings: { managed_app_settings: Record }; + }; + expect(manifest.display_information.description).toContain('Support Agent'); + expect(manifest.settings.managed_app_settings).toEqual({ + is_install_from_slack_disabled: true, + external_app_management_url: 'https://hooks.example/projects/project-1/agents/agent-1', + }); + const iconRequest = requestMock.mock.calls[1]?.[0] as { + url: string; + body: FormData; + }; + expect(iconRequest.url).toBe('https://slack.com/api/apps.icon.set'); + expect(iconRequest.body.get('app_id')).toBe('A123'); + expect(iconRequest.body.get('token')).toBe('xoxp-manager'); + expect(iconRequest.body.get('file')).toBeInstanceOf(Blob); + expect(fetchParams(requestMock, 2).get('grant_type')).toBe('refresh_token'); + const retriedIconRequest = requestMock.mock.calls[3]?.[0] as { + url: string; + body: FormData; + }; + expect(retriedIconRequest.url).toBe('https://slack.com/api/apps.icon.set'); + expect(retriedIconRequest.body.get('token')).toBe('xoxp-refreshed'); + expect(fetchParams(requestMock, 4).get('bot_scopes')).toContain('chat:write'); + expect(fetchParams(requestMock, 4).get('token')).toBe('xoxp-refreshed'); + expect(credentialsService.update).toHaveBeenCalledWith( + 'manager', + { data: 'encrypted' }, + expect.objectContaining({ + oauthTokenData: expect.objectContaining({ + authed_user: expect.objectContaining({ + id: 'U123', + access_token: 'xoxp-refreshed', + expires_in: 43200, + refresh_token: 'xoxe-refreshed', + token_type: 'user', + }), + }), + }), + ); + expect(credentialsService.createManagedCredential).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Example workspace - Support Agent', + projectId: 'project-1', + type: 'slackApi', + data: expect.objectContaining({ + accessToken: 'xoxb-managed', + signatureSecret: 'signing-secret', + managedAppId: 'A123', + teamId: 'T123', + managerCredentialId: 'manager', + }), + }), + user, + ); + }); + + it('deletes a newly created managed app when icon setup fails', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }); + requestMock + .mockResolvedValueOnce(slackAppCreatedResponse()) + .mockResolvedValueOnce(slackResponse({ ok: false, error: 'slack_request_failed' })) + .mockResolvedValueOnce(slackResponse({ ok: true })); + + await expect( + service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }), + ).rejects.toThrow('Slack could not set the Slack app icon: slack_request_failed'); + + expect(requestMock.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ url: 'https://slack.com/api/apps.manifest.delete' }), + ); + expect(fetchParams(requestMock, 2).get('app_id')).toBe('A123'); + expect(fetchParams(requestMock, 2).get('token')).toBe('xoxp-manager'); + expect(cacheService.delete).toHaveBeenCalledWith( + 'agents:slack-managed-app:project-1:agent-1:manager:T123:user-1', + ); + }); + + it('uses Slack OAuth responses and falls back to the app session URL', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser.mockResolvedValue(managerCredential); + credentialsService.decrypt.mockResolvedValue({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }); + requestMock + .mockResolvedValueOnce(slackAppCreatedResponse()) + .mockResolvedValueOnce(slackResponse({ ok: true })) + .mockResolvedValueOnce( + slackResponse({ + ok: false, + error: 'admin_approval_required', + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=response-client', + team_id: 'T123', + }), + ) + .mockResolvedValueOnce(slackResponse({ ok: false, error: 'installation_denied' })); + + const result = await service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }); + + expect(result.status).toBe('manual_install_required'); + if (result.status === 'manual_install_required') { + const installUrl = new URL(result.installUrl); + expect(installUrl.searchParams.get('client_id')).toBe('response-client'); + expect(installUrl.searchParams.get('state')).toBeTruthy(); + } + expect(cacheService.set).toHaveBeenCalledWith( + 'agents:slack-managed-app:project-1:agent-1:manager:T123:user-1', + expect.stringMatching(/^encrypted:/), + 60 * 60 * 1000, + ); + const fallbackResult = await service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }); + expect(fallbackResult.status).toBe('manual_install_required'); + if (fallbackResult.status === 'manual_install_required') { + expect(new URL(fallbackResult.installUrl).searchParams.get('client_id')).toBe('C123'); + } + expect( + requestMock.mock.calls.filter( + ([request]) => request.url === 'https://slack.com/api/apps.manifest.create', + ), + ).toHaveLength(1); + expect(credentialsService.createManagedCredential).not.toHaveBeenCalled(); + }); + + it.each(['app_approval_request_pending', 'app_approval_request_denied'])( + 'returns the %s managed installation error even when Slack includes an OAuth URL', + async (errorCode) => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }); + requestMock + .mockResolvedValueOnce(slackAppCreatedResponse()) + .mockResolvedValueOnce(slackResponse({ ok: true })) + .mockResolvedValueOnce( + slackResponse({ + ok: false, + error: errorCode, + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=response-client', + team_id: 'T123', + }), + ); + + await expect( + service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }), + ).rejects.toMatchObject({ + meta: { integrationType: 'slack', code: errorCode }, + }); + expect( + requestMock.mock.calls.filter( + ([request]) => request.url === 'https://slack.com/api/apps.manifest.delete', + ), + ).toHaveLength(0); + }, + ); + + it('preserves managed app provenance after fallback OAuth installation', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + { id: 'bot-credential', type: 'slackApi' }, + ] as never); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + team: { id: 'T123', name: 'Example workspace' }, + }, + }); + requestMock + .mockResolvedValueOnce(slackAppCreatedResponse()) + .mockResolvedValueOnce(slackResponse({ ok: true })) + .mockResolvedValueOnce( + slackResponse({ + ok: false, + error: 'app_approval_request_eligible', + oauth_authorize_url: 'https://slack.com/oauth/v2/authorize?client_id=response-client', + team_id: 'T456', + }), + ) + .mockResolvedValueOnce(slackOAuthResponse()); + userRepository.findOne.mockResolvedValue(user); + credentialsService.createManagedCredential.mockResolvedValue({ + id: 'bot-credential', + } as never); + + const result = await service.installManagedApp({ + projectId: 'project-1', + agentId: 'agent-1', + managerCredentialId: 'manager', + workspaceId: 'T123', + user, + }); + expect(result.status).toBe('manual_install_required'); + if (result.status !== 'manual_install_required') return; + + const installUrl = new URL(result.installUrl); + expect(installUrl.searchParams.get('client_id')).toBe('response-client'); + const state = installUrl.searchParams.get('state') ?? ''; + await service.completeInstall({ + projectId: 'project-1', + agentId: 'agent-1', + code: 'slack-code', + state, + }); + + expect(credentialsService.createManagedCredential).toHaveBeenCalledWith( + { + name: 'Example workspace - Support Agent', + type: 'slackApi', + data: { + accessToken: 'xoxb-installed-token', + signatureSecret: 'signing-secret', + managedAppId: 'A123', + teamId: 'T456', + managerCredentialId: 'manager', + }, + projectId: 'project-1', + }, + user, + ); + expect(credentialsService.createUnmanagedCredential).not.toHaveBeenCalled(); + }); + + it('exports settings for a managed Slack bot credential', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + ...agent, + integrations: [{ type: 'slack', credentialId: 'bot-credential' }], + } as never); + const botCredential = { + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + } as CredentialsEntity; + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce(botCredential) + .mockResolvedValueOnce(managerCredential); + credentialsService.decrypt + .mockResolvedValueOnce({ + managedAppId: 'A123', + managerCredentialId: 'manager', + teamId: 'T123', + }) + .mockResolvedValueOnce({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + requestMock.mockResolvedValueOnce( + slackResponse({ + ok: true, + manifest: { + display_information: { name: 'Slack app', description: 'Handles support requests' }, + features: { + bot_user: { display_name: 'Support Bot', always_online: false }, + }, + }, + }), + ); + + await expect( + service.getManagedAppSettings({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + }), + ).resolves.toEqual({ + credentialId: 'bot-credential', + appId: 'A123', + name: 'Support Bot', + description: 'Handles support requests', + alwaysOnline: false, + appHomeUrl: 'https://api.slack.com/apps/A123/app-home', + }); + expect(fetchParams(requestMock, 0).get('app_id')).toBe('A123'); + expect(fetchParams(requestMock, 0).get('token')).toBe('xoxp-manager'); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledTimes(2); + }); + + it('updates the live manifest', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + ...agent, + integrations: [{ type: 'slack', credentialId: 'bot-credential' }], + } as never); + const botCredential = { + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + } as CredentialsEntity; + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce(botCredential) + .mockResolvedValueOnce(managerCredential); + credentialsService.decrypt + .mockResolvedValueOnce({ + signatureSecret: 'signing-secret', + managedAppId: 'A123', + managerCredentialId: 'manager', + teamId: 'T123', + }) + .mockResolvedValueOnce({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + const manifest = { + display_information: { + name: 'App name stays unchanged', + description: 'Old description', + background_color: '#000000', + }, + features: { + app_home: { home_tab_enabled: true }, + bot_user: { display_name: 'Old Bot', always_online: true }, + }, + settings: { socket_mode_enabled: false }, + }; + requestMock + .mockResolvedValueOnce(slackResponse({ ok: true, manifest })) + .mockResolvedValueOnce(slackResponse({ ok: true })); + + await expect( + service.updateManagedAppSettings({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + name: 'New Bot', + description: 'New description', + alwaysOnline: false, + user, + }), + ).resolves.toEqual( + expect.objectContaining({ + name: 'New Bot', + description: 'New description', + alwaysOnline: false, + }), + ); + + const updatedManifest = JSON.parse(fetchParams(requestMock, 1).get('manifest') ?? '') as { + display_information: Record; + features: Record>; + settings: Record; + }; + expect(updatedManifest.display_information).toEqual({ + name: 'App name stays unchanged', + description: 'New description', + background_color: '#000000', + }); + expect(updatedManifest.features.app_home).toEqual({ home_tab_enabled: false }); + expect(updatedManifest.features.bot_user).toEqual({ + display_name: 'New Bot', + always_online: false, + }); + expect(updatedManifest.settings).toEqual({ socket_mode_enabled: false }); + expect(fetchParams(requestMock, 0).get('token')).toBe('xoxp-manager'); + expect(fetchParams(requestMock, 1).get('token')).toBe('xoxp-manager'); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledTimes(2); + expect(credentialsService.createEncryptedData).not.toHaveBeenCalled(); + expect(credentialsService.update).not.toHaveBeenCalled(); + }); + + it('rejects settings access for an unmanaged Slack credential', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + ...agent, + integrations: [{ type: 'slack', credentialId: 'bot-credential' }], + } as never); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'bot-credential', + type: 'slackApi', + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ accessToken: 'xoxb-token' }); + + await expect( + service.getManagedAppSettings({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + }), + ).rejects.toThrow('The Slack connection is not managed by n8n'); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('includes Slack error metadata in managed settings errors', async () => { + credentialsOverwrites.getOverwrites.mockReturnValue({ + clientId: 'client', + clientSecret: 'secret', + userScope: 'app_configurations:read app_configurations:write managed_apps:install', + }); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + agentRepository.findByIdAndProjectId.mockResolvedValue({ + ...agent, + integrations: [{ type: 'slack', credentialId: 'bot-credential' }], + } as never); + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce({ + id: 'bot-credential', + type: 'slackApi', + } as CredentialsEntity) + .mockResolvedValueOnce({ + id: 'manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity); + credentialsService.decrypt + .mockResolvedValueOnce({ + managedAppId: 'A123', + managerCredentialId: 'manager', + teamId: 'T123', + }) + .mockResolvedValueOnce({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + requestMock.mockResolvedValueOnce( + slackResponse({ ok: false, error: 'service_limits_exceeded' }), + ); + + await expect( + service.getManagedAppSettings({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + }), + ).rejects.toMatchObject({ + message: expect.stringContaining('service_limits_exceeded'), + meta: { + integrationType: 'slack', + code: 'service_limits_exceeded', + }, + }); + }); + + it('deletes a managed Slack app associated with the bot credential', async () => { + const botCredential = { + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + } as CredentialsEntity; + const managerCredential = { + id: 'manager', + name: 'Slack manager', + type: 'slackManagerOAuth2Api', + } as CredentialsEntity; + credentialsFinderService.findCredentialForUser + .mockResolvedValueOnce(botCredential) + .mockResolvedValueOnce(managerCredential); + credentialsService.decrypt + .mockResolvedValueOnce({ + managedAppId: 'A123', + teamId: 'T123', + managerCredentialId: 'manager', + }) + .mockResolvedValueOnce({ + oauthTokenData: { + authed_user: { + access_token: 'xoxp-manager', + scope: 'app_configurations:read app_configurations:write managed_apps:install', + }, + }, + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([ + { id: 'manager', type: 'slackManagerOAuth2Api' }, + ] as never); + credentialsOverwrites.usesManagedAuth.mockReturnValue(true); + requestMock.mockResolvedValueOnce(slackResponse({ ok: true })); + + await service.deleteManagedAppForCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + deleteExternalResource: true, + }); + + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://slack.com/api/apps.manifest.delete', + }), + ); + expect(fetchParams(requestMock, 0).get('app_id')).toBe('A123'); + expect(fetchParams(requestMock, 0).get('token')).toBe('xoxp-manager'); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledTimes(2); + expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential'); + }); + + it.each([false, undefined])( + 'keeps the managed Slack app when deleteExternalResource is %s', + async (deleteExternalResource) => { + credentialsFinderService.findCredentialForUser.mockResolvedValueOnce({ + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + isManaged: true, + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ + managedAppId: 'A123', + teamId: 'T123', + managerCredentialId: 'manager', + }); + + await service.deleteManagedAppForCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + deleteExternalResource, + }); + + expect(requestMock).not.toHaveBeenCalled(); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledTimes(1); + expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential'); + expect(cacheService.delete).toHaveBeenCalledWith( + 'agents:slack-managed-app:project-1:agent-1:manager:T123:user-1', + ); + }, + ); + + it('deletes the bot credential and returns a warning when its manager credential is missing', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValueOnce({ + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + isManaged: true, + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ + managedAppId: 'A123', + teamId: 'T123', + managerCredentialId: 'missing-manager', + }); + credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); + + await expect( + service.deleteManagedAppForCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + deleteExternalResource: true, + }), + ).resolves.toEqual({ + integrationType: 'slack', + code: 'app_not_deleted', + action: { type: 'open_url', url: 'https://api.slack.com/apps/A123' }, + details: { appId: 'A123' }, + }); + expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential'); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it('does not call Slack when the bot credential is not managed', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ accessToken: 'xoxb-token' }); + + await service.deleteManagedAppForCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + }); + + expect(requestMock).not.toHaveBeenCalled(); + expect(credentialsService.delete).not.toHaveBeenCalled(); + }); + + it('deletes a managed bot credential without a managed Slack app', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'bot-credential', + name: 'Slack bot', + type: 'slackApi', + isManaged: true, + } as CredentialsEntity); + credentialsService.decrypt.mockResolvedValue({ accessToken: 'xoxb-token' }); + + await service.deleteManagedAppForCredential({ + projectId: 'project-1', + agentId: 'agent-1', + credentialId: 'bot-credential', + user, + }); + + expect(requestMock).not.toHaveBeenCalled(); + expect(credentialsService.delete).toHaveBeenCalledWith(user, 'bot-credential'); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts index 39e3760f072..5b46494f412 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-integration.test.ts @@ -11,7 +11,7 @@ describe('SlackIntegration', () => { let integration: SlackIntegration; beforeEach(() => { - integration = new SlackIntegration(); + integration = new SlackIntegration(mock()); }); it('advertises Slack messaging and reaction actions', () => { diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts index 7b3e18d470a..1cf2603cce4 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-manual-setup.service.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { CacheService } from '@/services/cache/cache.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { ProjectService } from '@/services/project.service.ee'; import { SlackManualSetupService } from '../platforms/slack/slack-manual-setup.service'; import type { SlackMethodsService } from '../platforms/slack/slack-methods.service'; @@ -15,17 +16,26 @@ describe('SlackManualSetupService', () => { const userRepository = mock(); const cacheService = mock(); const cipher = mock(); + const projectService = mock(); + projectService.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never); return { - service: new SlackManualSetupService(methods, userRepository, cacheService, cipher), + service: new SlackManualSetupService( + methods, + userRepository, + cacheService, + cipher, + projectService, + ), methods, userRepository, cacheService, cipher, + projectService, }; } it('creates a manual app and stores encrypted callback state', async () => { - const { service, methods, cacheService, cipher } = makeService(); + const { service, methods } = makeService(); const user = { id: 'user-1' }; const redirectUrl = 'https://n8n.example/callback'; methods.getAgent.mockResolvedValue({ id: 'agent-1', name: 'Support Agent' } as never); @@ -43,19 +53,12 @@ describe('SlackManualSetupService', () => { signing_secret: 'signing-1', }, }); - methods.childRecord.mockImplementation((record, key) => record[key] as never); - methods.stringProperty.mockImplementation((record, key) => { - const value = record?.[key]; - return typeof value === 'string' ? value : undefined; - }); methods.installUrl.mockImplementation((oauthUrl, state, callbackUrl) => { const installUrl = new URL(oauthUrl); installUrl.searchParams.set('state', state); installUrl.searchParams.set('redirect_uri', callbackUrl); return installUrl.toString(); }); - cipher.encryptV2.mockResolvedValue('encrypted-session-without-secrets'); - const result = await service.createApp({ projectId: 'project-1', agentId: 'agent-1', @@ -70,22 +73,7 @@ describe('SlackManualSetupService', () => { expect(methods.buildManifest).toHaveBeenCalledWith('Support Agent', 'project-1', 'agent-1', { redirectUrl, }); - expect(cacheService.set).toHaveBeenCalledWith( - `agents:slack-app-setup:${state}`, - 'encrypted-session-without-secrets', - 60 * 60 * 1000, - ); - const session = JSON.parse(cipher.encryptV2.mock.calls[0]?.[0] as string) as { - projectId: string; - agentId: string; - userId: string; - appId: string; - clientId: string; - clientSecret: string; - signingSecret: string; - redirectUrl: string; - }; - expect(session).toEqual({ + expect(methods.storeSession).toHaveBeenCalledWith(state, { projectId: 'project-1', agentId: 'agent-1', userId: 'user-1', @@ -120,8 +108,11 @@ describe('SlackManualSetupService', () => { cipher.decryptV2.mockResolvedValue(JSON.stringify(session)); userRepository.findOne.mockResolvedValue(user as never); methods.getAgent.mockResolvedValue(agent as never); - methods.callSlackApi.mockResolvedValue({ ok: true, access_token: 'xoxb-token' }); - methods.stringProperty.mockReturnValue('xoxb-token'); + methods.callSlackApi.mockResolvedValue({ + ok: true, + access_token: 'xoxb-token', + team: { name: 'Example workspace' }, + }); await service.completeInstall({ projectId: 'project-1', @@ -132,11 +123,9 @@ describe('SlackManualSetupService', () => { expect(cacheService.take).toHaveBeenCalledWith('agents:slack-app-setup:state-1'); expect(cacheService.delete).not.toHaveBeenCalled(); - expect(methods.createAndConnectBotCredential).toHaveBeenCalledWith({ - agent, - user, - accessToken: 'xoxb-token', - signingSecret: 'signing-1', + expect(methods.connectBotCredential).toHaveBeenCalledWith(agent, user, 'xoxb-token', { + ...session, + teamName: 'Example workspace', }); }); @@ -166,6 +155,6 @@ describe('SlackManualSetupService', () => { ).rejects.toThrow(BadRequestError); expect(methods.callSlackApi).not.toHaveBeenCalled(); - expect(methods.createAndConnectBotCredential).not.toHaveBeenCalled(); + expect(methods.connectBotCredential).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts index 9ad5e106fea..9475fc87281 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/slack-methods.service.test.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method -- mock-based tests intentionally reference unbound methods */ import type { OutboundHttp } from '@n8n/backend-network'; import { mock } from 'vitest-mock-extended'; +import type { Cipher } from 'n8n-core'; import type { CredentialsService } from '@/credentials/credentials.service'; +import type { CacheService } from '@/services/cache/cache.service'; import type { UrlService } from '@/services/url.service'; import type { AgentIntegrationManagementService } from '../../agent-integration-management.service'; @@ -23,13 +25,15 @@ describe('SlackMethodsService', () => { managementService, urlService, mock(), + mock(), + mock(), ), credentialsService, managementService, }; } - it('creates a bot credential and delegates published activation to integration management', async () => { + it('creates a bot credential and delegates activation to integration management', async () => { const { service, credentialsService, managementService } = makeService(); const agent = { id: 'agent-1', @@ -45,11 +49,15 @@ describe('SlackMethodsService', () => { integration: { type: 'slack', credentialId: 'credential-1' }, savedAgent: agent, }); - await service.createAndConnectBotCredential({ - agent, - user: user as never, - accessToken: 'xoxb-token', + await service.connectBotCredential(agent, user as never, 'xoxb-token', { + projectId: 'project-1', + agentId: 'agent-1', + userId: 'user-1', + appId: 'app-1', + clientId: 'client-1', + clientSecret: 'client-secret', signingSecret: 'signing-secret', + redirectUrl: 'https://n8n.example/callback', }); expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts index fc030b95cff..b441813c5e7 100644 --- a/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts +++ b/packages/cli/src/modules/agents/integrations/agent-chat-integration.ts @@ -1,4 +1,9 @@ -import { AgentIntegrationConfig, type RichCardComponentType } from '@n8n/api-types'; +import { + AgentIntegrationConfig, + type AgentIntegrationDisconnectWarning, + type RichCardComponentType, +} from '@n8n/api-types'; +import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import type { Thread, Author, Message } from 'chat'; import type { Logger } from 'n8n-workflow'; @@ -32,6 +37,14 @@ export interface AgentChatIntegrationContext { webhookUrlFor: (platform: string) => string; } +export interface AgentIntegrationRemovalContext { + agentId: string; + projectId: string; + credentialId: string; + user: User; + deleteExternalResource?: boolean; +} + /** Response shape returned by `handleUnauthenticatedWebhook`. */ export interface UnauthenticatedWebhookResponse { status: number; @@ -259,7 +272,7 @@ export abstract class AgentChatIntegration { /** Build the Chat SDK adapter for this platform. */ abstract createAdapter(ctx: AgentChatIntegrationContext): Promise; - /** Validate platform-specific configuration before credentials or persistence are touched. */ + /** Validate platform settings before credentials or persistence are touched. */ validateConfig?(integration: AgentIntegrationConfig): void; /** @@ -316,8 +329,16 @@ export abstract class AgentChatIntegration { onBeforeDisconnect?(ctx: AgentChatIntegrationContext): Promise; /** - * Prepare a thread created or selected by an outbound send. Platforms can - * use this to receive follow-up messages in that thread. + * Cleanup performed only when a user explicitly removes a persisted + * integration. This is deliberately separate from runtime disconnect hooks. + */ + onRemove?( + ctx: AgentIntegrationRemovalContext, + ): Promise; + + /** + * Prepare a thread created or selected by an outbound send. Slack uses this + * to subscribe the bot so follow-up messages reach the agent. */ prepareSentThread?(thread: Thread): Promise; diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts index 3e8473bbc43..2009cbe4424 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/slack/synthetic-integration.test.ts @@ -1,3 +1,6 @@ +import { mock } from 'vitest-mock-extended'; + +import type { AgentRepository } from '../../../../repositories/agent.repository'; import { createSlackReplayContext } from '../../../__tests__/helpers/slack/replay-test-context'; import { slackEventCallback, @@ -8,7 +11,7 @@ import { SlackIntegration } from '../../slack/slack-integration'; describe('Slack channel integration scenarios', () => { it('handles Slack URL verification without an active connection', () => { - const integration = new SlackIntegration(); + const integration = new SlackIntegration(mock()); expect( integration.handleUnauthenticatedWebhook({ diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/assets/n8n-bot-icon.png b/packages/cli/src/modules/agents/integrations/platforms/slack/assets/n8n-bot-icon.png new file mode 100644 index 00000000000..b8e0f8b5bf3 Binary files /dev/null and b/packages/cli/src/modules/agents/integrations/platforms/slack/assets/n8n-bot-icon.png differ diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts index 538253ffdff..36da52da22b 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-integration.ts @@ -1,5 +1,5 @@ -import { Service } from '@n8n/di'; -import type { RichCardComponentType } from '@n8n/api-types'; +import type { AgentIntegrationDisconnectWarning, RichCardComponentType } from '@n8n/api-types'; +import { Container, Service } from '@n8n/di'; import type { Thread } from 'chat'; import { ConflictError } from '@/errors/response-errors/conflict.error'; @@ -8,6 +8,7 @@ import { AgentRepository } from '../../../repositories/agent.repository'; import { AgentChatIntegration, type AgentChatIntegrationContext, + type AgentIntegrationRemovalContext, type BridgeExecutionContext, type BridgeMessageContextParams, type BridgeResumeExecutionContext, @@ -17,11 +18,11 @@ import { } from '../../agent-chat-integration'; import type { ChatInstance } from '../../chat-integration.service'; import { loadSlackAdapter } from '../../esm-loader'; +import { connectionUnavailable } from '../../integration-helpers'; import { resolveIntegrationActionDefinitions, resolveIntegrationContextQueryDefinitions, } from '../../integration-tool-definitions'; -import { connectionUnavailable } from '../../integration-helpers'; import type { ReplyExpectation } from '../../integration-tools'; import { createSlackBridgeExecutionContext, @@ -30,6 +31,7 @@ import { getSlackReplyExpectation, prepareSlackInboundText, } from './slack-bridge-behavior'; +import { SlackManagedSetupService } from './slack-managed-setup.service'; import { executeSlackContextQuery, subscribeSlackThread } from './slack-operations'; /** @@ -39,7 +41,7 @@ import { executeSlackContextQuery, subscribeSlackThread } from './slack-operatio */ @Service() export class SlackIntegration extends AgentChatIntegration { - constructor(private readonly agentRepository?: AgentRepository) { + constructor(private readonly agentRepository: AgentRepository) { super(); } @@ -99,7 +101,6 @@ export class SlackIntegration extends AgentChatIntegration { ]); async onBeforeConnect(ctx: AgentChatIntegrationContext): Promise { - if (!this.agentRepository) return; const others = await this.agentRepository.findByIntegrationCredential( this.type, ctx.credentialId, @@ -111,6 +112,12 @@ export class SlackIntegration extends AgentChatIntegration { } } + async onRemove( + ctx: AgentIntegrationRemovalContext, + ): Promise { + return await Container.get(SlackManagedSetupService).deleteAppForCredential(ctx); + } + async prepareSentThread(thread: Thread): Promise { await subscribeSlackThread(thread); } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts new file mode 100644 index 00000000000..678d87392b2 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-managed-setup.service.ts @@ -0,0 +1,911 @@ +import type { + AgentIntegrationConfig, + CreateSlackManagerCredentialResponse, + InstallSlackManagedAppResponse, + SlackManagedAppSettings, + SlackManagedSetupState, + SlackManagedWorkspaceSummary, + SlackManagerCredentialSummary, +} from '@n8n/api-types'; +import type { CredentialsEntity, User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { isRecord } from '@n8n/utils/is-record'; +import { Cipher } from 'n8n-core'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { jsonParse } from 'n8n-workflow'; +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { CredentialsService } from '@/credentials/credentials.service'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { CacheService } from '@/services/cache/cache.service'; + +import { SlackMethodsService } from './slack-methods.service'; +import { childRecord, SLACK_BOT_SCOPES, type SlackAppSetupSession } from './slack-setup.types'; +import type { Agent } from '../../../entities/agent.entity'; +import { AgentRepository } from '../../../repositories/agent.repository'; +import { stringProperty } from '../../integration-helpers'; + +const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:'; +const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; +const SLACK_CREDENTIAL_TYPE = 'slackApi'; +const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api'; +const DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME = 'Workspace credentials'; +const REQUIRED_MANAGER_SCOPES = [ + 'app_configurations:read', + 'app_configurations:write', + 'managed_apps:install', +] as const; +const MANAGED_INSTALL_FALLBACK_ERRORS = new Set([ + 'installation_denied', + 'app_approval_request_eligible', + 'manager_app_not_eligible', +]); +const MANAGED_INSTALL_APPROVAL_ERRORS = new Set([ + 'app_approval_request_pending', + 'app_approval_request_denied', +]); + +export interface GetManagedSetupStateOptions { + projectId: string; + agentId: string; + user: User; +} + +export interface InstallManagedSlackAppOptions extends GetManagedSetupStateOptions { + managerCredentialId: string; + workspaceId: string; +} + +export interface FinalizeSlackManagerCredentialOptions extends GetManagedSetupStateOptions { + credentialId: string; +} + +export interface GetManagedSlackAppSettingsOptions extends GetManagedSetupStateOptions { + credentialId: string; +} + +export interface UpdateManagedSlackAppSettingsOptions extends GetManagedSlackAppSettingsOptions { + name: string; + description: string; + alwaysOnline: boolean; +} + +export interface DeleteManagedSlackAppOptions { + projectId: string; + agentId: string; + credentialId: string; + user: User; + deleteExternalResource?: boolean; +} + +export interface ManagedSlackAppDeletionWarning { + integrationType: 'slack'; + code: 'app_not_deleted'; + action: { + type: 'open_url'; + url: string; + }; + details: { + appId: string; + }; +} + +interface ManagedSlackAppSession extends SlackAppSetupSession { + managerCredentialId: string; + teamId: string; + oauthAuthorizeUrl: string; +} + +interface ManagerCredentialContext { + credential: CredentialsEntity; + rawData: ICredentialDataDecryptedObject; + oauthTokenData: Record; + accessToken: string; +} + +interface ManagedBotCredentialContext { + credential: CredentialsEntity; + rawData: ICredentialDataDecryptedObject; + managedAppId: string; + managerCredentialId: string; + teamId: string; +} + +type RefreshTokenResponse = { + access_token: string; + expires_in: number; + refresh_token: string; + token_type: string; + app_id: string; + scope: string; + user_id: string; + team: { + id: string; + name: string; + }; + enterprise: Record | null; + is_enterprise_install: boolean; +}; + +type SlackApiParams = Record | FormData; +type SlackApiParamsFactory = (accessToken: string) => SlackApiParams; + +function stringsFromScope(value: unknown): Set { + if (typeof value !== 'string') return new Set(); + return new Set(value.split(/[\s,]+/).filter(Boolean)); +} + +function hasSessionShape(value: unknown): value is SlackAppSetupSession { + const keys: Array = [ + 'projectId', + 'agentId', + 'userId', + 'appId', + 'clientId', + 'clientSecret', + 'signingSecret', + 'redirectUrl', + ]; + return isRecord(value) && keys.every((key) => typeof value[key] === 'string'); +} + +function hasManagedSessionShape(value: unknown): value is ManagedSlackAppSession { + return ( + hasSessionShape(value) && + isRecord(value) && + typeof value.managerCredentialId === 'string' && + typeof value.teamId === 'string' && + typeof value.oauthAuthorizeUrl === 'string' + ); +} + +@Service() +export class SlackManagedSetupService { + constructor( + private readonly methods: SlackMethodsService, + private readonly cacheService: CacheService, + private readonly cipher: Cipher, + private readonly credentialsService: CredentialsService, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly credentialsOverwrites: CredentialsOverwrites, + private readonly agentRepository: AgentRepository, + ) {} + + isSetupAvailable(): boolean { + const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE); + if ( + typeof overwrite?.clientId !== 'string' || + overwrite.clientId.trim().length === 0 || + typeof overwrite.clientSecret !== 'string' || + overwrite.clientSecret.trim().length === 0 + ) { + return false; + } + return true; + } + + async getSetupState(options: GetManagedSetupStateOptions): Promise { + if (!this.isSetupAvailable()) { + return { managedSetupAvailable: false, managerCredentials: [] }; + } + + const agent = await this.agentRepository.findByIdAndProjectId( + options.agentId, + options.projectId, + ); + const integrations = agent?.integrations ?? []; + const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( + options.user, + { projectId: options.projectId }, + ); + const managerCredentials: SlackManagerCredentialSummary[] = []; + + for (const usableCredential of usableCredentials) { + if (usableCredential.type !== SLACK_MANAGER_CREDENTIAL_TYPE) continue; + const credential = await this.credentialsFinderService.findCredentialForUser( + usableCredential.id, + options.user, + ['credential:read'], + ); + if (!credential) continue; + + const rawData = await this.credentialsService.decrypt(credential, true); + if (!this.usesManagedSlackAuth(rawData)) continue; + const oauthTokenData = childRecord(rawData, 'oauthTokenData'); + const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined; + const accessToken = stringProperty(authedUser, 'access_token'); + const grantedScopes = stringsFromScope( + stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'), + ); + const reconnectRequired = + !!accessToken && REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope)); + const workspaces = + accessToken && oauthTokenData + ? await this.getWorkspacesFromContext( + { credential, rawData, oauthTokenData, accessToken }, + integrations, + options.user, + false, + ) + : []; + managerCredentials.push({ + id: credential.id, + name: credential.name, + connected: !!accessToken, + reconnectRequired, + workspaces, + }); + } + + return { managedSetupAvailable: true, managerCredentials }; + } + + async createManagerCredential( + options: GetManagedSetupStateOptions, + ): Promise { + this.assertSetupAvailable(); + await this.methods.getAgent(options.agentId, options.projectId); + const credential = await this.credentialsService.createUnmanagedCredential( + { + name: DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME, + type: SLACK_MANAGER_CREDENTIAL_TYPE, + data: {}, + projectId: options.projectId, + }, + options.user, + ); + return { + id: credential.id, + name: credential.name, + type: SLACK_MANAGER_CREDENTIAL_TYPE, + isResolvable: false, + }; + } + + async finalizeManagerCredential(options: FinalizeSlackManagerCredentialOptions): Promise { + await this.methods.getAgent(options.agentId, options.projectId); + const manager = await this.getManagerCredentialContext( + options.credentialId, + options.projectId, + options.user, + ); + if (manager.credential.name !== DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME) return; + + const response = await this.callManagerSlackApi(manager, 'auth.test', {}); + if (!response.ok) return; + const userName = stringProperty(response, 'user'); + if (!userName) return; + const teamName = stringProperty(response, 'team'); + const name = [ + DEFAULT_SLACK_MANAGER_CREDENTIAL_NAME, + teamName ? `${userName} @ ${teamName}` : userName, + ] + .join(' - ') + .slice(0, 128); + const encrypted = await this.credentialsService.createEncryptedData({ + id: manager.credential.id, + name, + type: manager.credential.type, + data: manager.rawData, + }); + await this.credentialsService.update(options.credentialId, encrypted, manager.rawData); + } + + async installApp( + options: InstallManagedSlackAppOptions, + ): Promise { + this.assertSetupAvailable(); + const agent = await this.methods.getAgent(options.agentId, options.projectId); + const existing = await this.findManagedBotCredential( + agent.integrations ?? [], + options.workspaceId, + options.managerCredentialId, + options.user, + ); + if (existing) { + return { status: 'connected', appId: existing.appId, credentialId: existing.credentialId }; + } + + const manager = await this.getManagerCredentialContext( + options.managerCredentialId, + options.projectId, + options.user, + ); + const workspaces = await this.getWorkspacesFromContext( + manager, + agent.integrations ?? [], + options.user, + ); + const workspace = workspaces.find(({ id }) => id === options.workspaceId); + if (!workspace) { + throw new NotFoundError('Slack workspace is not available to this credential'); + } + + const { session, created } = await this.getOrCreateManagedAppSession( + options, + agent, + manager, + workspace.name, + ); + const response = await this.callManagerSlackApi(manager, 'apps.managedInstall', { + app_id: session.appId, + team_id: options.workspaceId, + bot_scopes: SLACK_BOT_SCOPES.join(','), + }); + + if (response.ok) { + const botAccessToken = stringProperty( + childRecord(response, 'api_access_tokens'), + 'bot_access_token', + ); + if (!botAccessToken?.startsWith('xoxb-')) { + throw new BadRequestError('Slack did not return a Bot User OAuth Token'); + } + const credentialId = await this.methods.connectBotCredential( + agent, + options.user, + botAccessToken, + session, + ); + return { status: 'connected', appId: session.appId, credentialId }; + } + + const error = stringProperty(response, 'error') ?? 'unknown_error'; + if (MANAGED_INSTALL_APPROVAL_ERRORS.has(error)) { + throw this.methods.slackError('install the Slack app', response); + } + const responseOauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url'); + if (responseOauthAuthorizeUrl || MANAGED_INSTALL_FALLBACK_ERRORS.has(error)) { + const oauthAuthorizeUrl = responseOauthAuthorizeUrl ?? session.oauthAuthorizeUrl; + const teamId = stringProperty(response, 'team_id') ?? session.teamId; + const updatedSession: ManagedSlackAppSession = { ...session, oauthAuthorizeUrl, teamId }; + const state = randomBytes(32).toString('hex'); + await this.methods.storeSession(state, updatedSession); + return { + status: 'manual_install_required', + appId: session.appId, + installUrl: this.methods.installUrl(oauthAuthorizeUrl, state, session.redirectUrl), + }; + } + + if (created) { + const cleanupResponse = await this.callManagerSlackApi(manager, 'apps.manifest.delete', { + app_id: session.appId, + }); + if (cleanupResponse.ok) { + await this.cacheService.delete( + this.managedAppCacheKey({ ...options, userId: options.user.id }), + ); + } + } + throw this.methods.slackError('install the Slack app', response); + } + + async getAppSettings( + options: GetManagedSlackAppSettingsOptions, + ): Promise { + this.assertSetupAvailable(); + const bot = await this.getManagedBotCredentialContext(options); + const manager = await this.getManagerCredentialContext( + bot.managerCredentialId, + options.projectId, + options.user, + ); + const manifest = await this.exportManagedAppManifest(manager, bot.managedAppId); + return this.managedAppSettingsFromManifest(options.credentialId, bot.managedAppId, manifest); + } + + async updateAppSettings( + options: UpdateManagedSlackAppSettingsOptions, + ): Promise { + this.assertSetupAvailable(); + const bot = await this.getManagedBotCredentialContext(options); + const manager = await this.getManagerCredentialContext( + bot.managerCredentialId, + options.projectId, + options.user, + ); + const manifest = await this.exportManagedAppManifest(manager, bot.managedAppId); + const displayInformation = childRecord(manifest, 'display_information') ?? {}; + const features = childRecord(manifest, 'features') ?? {}; + const appHome = childRecord(features, 'app_home') ?? {}; + const botUser = childRecord(features, 'bot_user') ?? {}; + const updatedManifest = { + ...manifest, + display_information: { ...displayInformation, description: options.description }, + features: { + ...features, + app_home: { ...appHome, home_tab_enabled: false }, + bot_user: { + ...botUser, + display_name: options.name, + always_online: options.alwaysOnline, + }, + }, + }; + + const updateResponse = await this.callManagerSlackApi(manager, 'apps.manifest.update', { + app_id: bot.managedAppId, + manifest: JSON.stringify(updatedManifest), + }); + if (!updateResponse.ok) { + throw this.methods.slackError('update the Slack app', updateResponse); + } + + return this.managedAppSettingsFromManifest( + options.credentialId, + bot.managedAppId, + updatedManifest, + ); + } + + async deleteAppForCredential( + options: DeleteManagedSlackAppOptions, + ): Promise { + const credential = await this.credentialsFinderService.findCredentialForUser( + options.credentialId, + options.user, + ['credential:read'], + ); + if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) return; + + const data = await this.credentialsService.decrypt(credential, true); + const managedAppId = stringProperty(data, 'managedAppId'); + const managerCredentialId = stringProperty(data, 'managerCredentialId'); + const shouldDeleteExternalResource = options.deleteExternalResource === true; + if (managedAppId && !managerCredentialId && shouldDeleteExternalResource) { + throw new BadRequestError('The managed Slack app is missing its manager credential'); + } + + let warning: ManagedSlackAppDeletionWarning | undefined; + if (managedAppId && managerCredentialId && shouldDeleteExternalResource) { + try { + const manager = await this.getManagerCredentialContext( + managerCredentialId, + options.projectId, + options.user, + ); + const response = await this.callManagerSlackApi(manager, 'apps.manifest.delete', { + app_id: managedAppId, + }); + if (!response.ok && stringProperty(response, 'error') !== 'app_not_found') { + throw this.methods.slackError('delete the Slack app', response); + } + } catch (error) { + if (!(error instanceof NotFoundError)) throw error; + warning = { + integrationType: 'slack', + code: 'app_not_deleted', + action: { + type: 'open_url', + url: `https://api.slack.com/apps/${encodeURIComponent(managedAppId)}`, + }, + details: { appId: managedAppId }, + }; + } + } + + if (managerCredentialId && typeof data.teamId === 'string') { + await this.cacheService.delete( + this.managedAppCacheKey({ + projectId: options.projectId, + agentId: options.agentId, + managerCredentialId, + workspaceId: data.teamId, + userId: options.user.id, + }), + ); + } + + if (credential.isManaged || managedAppId) { + await this.credentialsService.delete(options.user, credential.id); + } + return warning; + } + + private assertSetupAvailable(): void { + if (!this.isSetupAvailable()) { + throw new NotFoundError('Managed Slack setup is not available'); + } + } + + private async getManagedBotCredentialContext( + options: GetManagedSlackAppSettingsOptions, + ): Promise { + const agent = await this.methods.getAgent(options.agentId, options.projectId); + const integration = agent.integrations?.find( + (item) => item.type === 'slack' && item.credentialId === options.credentialId, + ); + if (!integration) throw new NotFoundError('Managed Slack connection not found'); + + const credential = await this.credentialsFinderService.findCredentialForUser( + options.credentialId, + options.user, + ['credential:read'], + ); + if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) { + throw new NotFoundError(`Credential "${options.credentialId}" not found`); + } + const rawData = await this.credentialsService.decrypt(credential, true); + const managedAppId = stringProperty(rawData, 'managedAppId'); + const managerCredentialId = stringProperty(rawData, 'managerCredentialId'); + const teamId = stringProperty(rawData, 'teamId'); + if (!managedAppId || !managerCredentialId || !teamId) { + throw new BadRequestError('The Slack connection is not managed by n8n'); + } + return { credential, rawData, managedAppId, managerCredentialId, teamId }; + } + + private async exportManagedAppManifest( + manager: ManagerCredentialContext, + managedAppId: string, + ): Promise> { + const response = await this.callManagerSlackApi(manager, 'apps.manifest.export', { + app_id: managedAppId, + }); + if (!response.ok) { + throw this.methods.slackError('load the Slack app settings', response); + } + const manifest = childRecord(response, 'manifest'); + if (!manifest) throw new BadRequestError('Slack returned an incomplete app manifest'); + return manifest; + } + + private managedAppSettingsFromManifest( + credentialId: string, + appId: string, + manifest: Record, + ): SlackManagedAppSettings { + const displayInformation = childRecord(manifest, 'display_information'); + const features = childRecord(manifest, 'features'); + const botUser = features ? childRecord(features, 'bot_user') : undefined; + const name = stringProperty(botUser, 'display_name'); + const description = stringProperty(displayInformation, 'description'); + if (!name || !description || typeof botUser?.always_online !== 'boolean') { + throw new BadRequestError('Slack returned incomplete managed app settings'); + } + return { + credentialId, + appId, + name, + description, + alwaysOnline: botUser.always_online, + appHomeUrl: `https://api.slack.com/apps/${encodeURIComponent(appId)}/app-home`, + }; + } + + private async getManagerCredentialContext( + credentialId: string, + projectId: string, + user: User, + ): Promise { + const usableCredentials = await this.credentialsService.getCredentialsAUserCanUseInAWorkflow( + user, + { projectId }, + ); + if ( + !usableCredentials.some( + (credential) => + credential.id === credentialId && credential.type === SLACK_MANAGER_CREDENTIAL_TYPE, + ) + ) { + throw new NotFoundError(`Credential "${credentialId}" not found`); + } + const credential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + user, + ['credential:read'], + ); + if (!credential) throw new NotFoundError(`Credential "${credentialId}" not found`); + const rawData = await this.credentialsService.decrypt(credential, true); + if (!this.usesManagedSlackAuth(rawData)) { + throw new BadRequestError('The selected Slack credential does not use managed OAuth'); + } + const oauthTokenData = childRecord(rawData, 'oauthTokenData'); + const authedUser = oauthTokenData ? childRecord(oauthTokenData, 'authed_user') : undefined; + const accessToken = stringProperty(authedUser, 'access_token'); + if (!oauthTokenData || !accessToken) { + throw new BadRequestError('The selected Slack credential is not connected'); + } + const grantedScopes = stringsFromScope( + stringProperty(authedUser, 'scope') ?? stringProperty(oauthTokenData, 'scope'), + ); + if (REQUIRED_MANAGER_SCOPES.some((scope) => !grantedScopes.has(scope))) { + throw new BadRequestError('Reconnect the Slack credential to grant managed app access'); + } + return { credential, rawData, oauthTokenData, accessToken }; + } + + private usesManagedSlackAuth(data: ICredentialDataDecryptedObject): boolean { + const hasCustomClient = + (typeof data.clientId === 'string' && data.clientId.trim().length > 0) || + (typeof data.clientSecret === 'string' && data.clientSecret.trim().length > 0); + return ( + !hasCustomClient && + this.credentialsOverwrites.usesManagedAuth(SLACK_MANAGER_CREDENTIAL_TYPE, data) + ); + } + + private async getWorkspacesFromContext( + manager: ManagerCredentialContext, + integrations: readonly AgentIntegrationConfig[], + user: User, + allowRefresh = true, + ): Promise { + const team = childRecord(manager.oauthTokenData, 'team'); + const enterprise = childRecord(manager.oauthTokenData, 'enterprise'); + const isEnterpriseInstall = manager.oauthTokenData.is_enterprise_install === true; + const workspaceRecords: Array> = []; + + if (isEnterpriseInstall) { + let cursor = ''; + do { + const response = allowRefresh + ? await this.callManagerSlackApi(manager, 'auth.teams.list', { + limit: '100', + ...(cursor ? { cursor } : {}), + }) + : await this.methods.callSlackApi('auth.teams.list', { + token: manager.accessToken, + limit: '100', + ...(cursor ? { cursor } : {}), + }); + if (!response.ok) break; + if (Array.isArray(response.teams)) { + workspaceRecords.push(...response.teams.filter(isRecord)); + } + cursor = + stringProperty(childRecord(response, 'response_metadata'), 'next_cursor')?.trim() ?? ''; + } while (cursor); + } else if (team) { + workspaceRecords.push(team); + } + + const enterpriseId = stringProperty(enterprise, 'id'); + const result: SlackManagedWorkspaceSummary[] = []; + for (const workspace of workspaceRecords) { + const id = stringProperty(workspace, 'id'); + if (!id) continue; + const existing = await this.findManagedBotCredential( + integrations, + id, + manager.credential.id, + user, + ); + result.push({ + id, + name: stringProperty(workspace, 'name') ?? stringProperty(workspace, 'domain') ?? id, + ...(enterpriseId ? { enterpriseId } : {}), + ...(existing + ? { + managedAppId: existing.appId, + botCredentialId: existing.credentialId, + connected: true, + } + : { connected: false }), + }); + } + return result; + } + + private async findManagedBotCredential( + integrations: readonly AgentIntegrationConfig[], + workspaceId: string, + managerCredentialId: string, + user: User, + ): Promise<{ appId: string; credentialId: string } | undefined> { + for (const integration of integrations) { + if (integration.type !== 'slack' || !integration.credentialId) continue; + const credential = await this.credentialsFinderService.findCredentialForUser( + integration.credentialId, + user, + ['credential:read'], + ); + if (!credential || credential.type !== SLACK_CREDENTIAL_TYPE) continue; + const data = await this.credentialsService.decrypt(credential, true); + if ( + data.teamId === workspaceId && + data.managerCredentialId === managerCredentialId && + typeof data.managedAppId === 'string' + ) { + return { appId: data.managedAppId, credentialId: credential.id }; + } + } + return undefined; + } + + private async getOrCreateManagedAppSession( + options: InstallManagedSlackAppOptions, + agent: Agent, + manager: ManagerCredentialContext, + workspaceName: string, + ): Promise<{ session: ManagedSlackAppSession; created: boolean }> { + const key = this.managedAppCacheKey({ ...options, userId: options.user.id }); + const cached = await this.cacheService.get(key); + if (typeof cached === 'string') { + const session = await this.decryptManagedAppSession(cached); + if (session) { + return { + session: { ...session, teamName: session.teamName ?? workspaceName }, + created: false, + }; + } + // invalid session, delete it + await this.cacheService.delete(key); + } + + const redirectUrl = this.methods.callbackUrl(options.projectId, options.agentId); + const manifest = this.methods.buildManifest(agent.name, options.projectId, options.agentId, { + redirectUrl, + managed: true, + }); + const response = await this.callManagerSlackApi(manager, 'apps.manifest.create', { + manifest: JSON.stringify(manifest), + team_id: options.workspaceId, + }); + if (!response.ok) throw this.methods.slackError('create the Slack app', response); + + const credentials = childRecord(response, 'credentials'); + const appId = stringProperty(response, 'app_id'); + const clientId = stringProperty(credentials, 'client_id'); + const clientSecret = stringProperty(credentials, 'client_secret'); + const signingSecret = stringProperty(credentials, 'signing_secret'); + const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url'); + try { + if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) { + throw new BadRequestError('Slack returned an incomplete app setup response'); + } + await this.setManagedAppIcon(manager, appId); + + const session = { + projectId: options.projectId, + agentId: options.agentId, + userId: options.user.id, + appId, + clientId, + clientSecret, + signingSecret, + redirectUrl, + managerCredentialId: options.managerCredentialId, + teamId: options.workspaceId, + teamName: workspaceName, + oauthAuthorizeUrl, + } satisfies ManagedSlackAppSession; + await this.cacheService.set( + key, + await this.cipher.encryptV2(JSON.stringify(session)), + SLACK_APP_SETUP_TTL_MS, + ); + return { session, created: true }; + } catch (error) { + if (appId) { + await Promise.allSettled([ + this.callManagerSlackApi(manager, 'apps.manifest.delete', { app_id: appId }), + this.cacheService.delete(key), + ]); + } + throw error; + } + } + + private async decryptManagedAppSession( + value: string, + ): Promise { + try { + const decrypted = await this.cipher.decryptV2(value); + const session = jsonParse(decrypted, { fallbackValue: null }); + if (hasManagedSessionShape(session)) return session; + } catch { + // Ignore stale or undecryptable managed setup state. + } + return undefined; + } + + private async callManagerSlackApi( + manager: ManagerCredentialContext, + method: string, + params: Record | SlackApiParamsFactory, + ): Promise<({ ok: true } & T) | { ok: false; error: string }> { + const paramsForToken = (accessToken: string): SlackApiParams => + typeof params === 'function' ? params(accessToken) : { ...params, token: accessToken }; + let response = await this.methods.callSlackApi(method, paramsForToken(manager.accessToken)); + const error = stringProperty(response, 'error'); + if (!['invalid_auth', 'token_expired'].includes(error ?? '')) return response; + + const authedUser = childRecord(manager.oauthTokenData, 'authed_user'); + const refreshToken = stringProperty(authedUser, 'refresh_token'); + const overwrite = this.credentialsOverwrites.getOverwrites(SLACK_MANAGER_CREDENTIAL_TYPE); + if ( + !refreshToken || + typeof overwrite?.clientId !== 'string' || + typeof overwrite.clientSecret !== 'string' + ) { + return response; + } + + const refreshResponse = await this.methods.callSlackApi( + 'oauth.v2.access', + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: overwrite.clientId, + client_secret: overwrite.clientSecret, + }, + ); + const refreshedAccessToken = stringProperty(refreshResponse, 'access_token'); + if (!refreshResponse.ok || !refreshedAccessToken) { + return response; + } + + const refreshedAuthedUser: Record = { + ...authedUser, + access_token: refreshedAccessToken, + }; + const refreshedUserId = stringProperty(refreshResponse, 'user_id'); + const refreshedScope = stringProperty(refreshResponse, 'scope'); + const refreshedRefreshToken = stringProperty(refreshResponse, 'refresh_token'); + const refreshedTokenType = stringProperty(refreshResponse, 'token_type'); + + if (refreshedUserId !== undefined) refreshedAuthedUser.id = refreshedUserId; + if (refreshedScope !== undefined) refreshedAuthedUser.scope = refreshedScope; + if (refreshedRefreshToken !== undefined) { + refreshedAuthedUser.refresh_token = refreshedRefreshToken; + } + if (refreshedTokenType !== undefined) refreshedAuthedUser.token_type = refreshedTokenType; + if (typeof refreshResponse.expires_in === 'number') { + refreshedAuthedUser.expires_in = refreshResponse.expires_in; + } + const oauthTokenData = { + ...manager.oauthTokenData, + authed_user: refreshedAuthedUser, + }; + const updatedData = { ...manager.rawData, oauthTokenData }; + const encrypted = await this.credentialsService.createEncryptedData({ + id: manager.credential.id, + name: manager.credential.name, + type: manager.credential.type, + data: updatedData, + }); + await this.credentialsService.update(manager.credential.id, encrypted, updatedData); + manager.oauthTokenData = oauthTokenData; + manager.accessToken = refreshedAccessToken; + response = await this.methods.callSlackApi(method, paramsForToken(refreshedAccessToken)); + return response; + } + + private managedAppCacheKey(options: { + projectId: string; + agentId: string; + managerCredentialId: string; + workspaceId: string; + userId: string; + }): string { + return `${SLACK_MANAGED_APP_CACHE_PREFIX}${options.projectId}:${options.agentId}:${options.managerCredentialId}:${options.workspaceId}:${options.userId}`; + } + + private async setManagedAppIcon( + manager: ManagerCredentialContext, + managedAppId: string, + ): Promise { + const image = await readFile(join(__dirname, 'assets', 'n8n-bot-icon.png')); + const response = await this.callManagerSlackApi(manager, 'apps.icon.set', (accessToken) => { + const formData = new FormData(); + formData.set('token', accessToken); + formData.set('app_id', managedAppId); + formData.set( + 'file', + new Blob([new Uint8Array(image)], { type: 'image/png' }), + 'n8n-bot-icon.png', + ); + return formData; + }); + if (!response.ok) { + throw this.methods.slackError('set the Slack app icon', response); + } + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts index 524dc8ff832..67479ab44eb 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-manual-setup.service.ts @@ -1,5 +1,3 @@ -import { randomBytes } from 'node:crypto'; - import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types'; import type { User } from '@n8n/db'; import { UserRepository } from '@n8n/db'; @@ -7,46 +5,37 @@ import { Service } from '@n8n/di'; import { isRecord } from '@n8n/utils/is-record'; import { Cipher } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; +import { randomBytes } from 'node:crypto'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { CacheService } from '@/services/cache/cache.service'; +import { ProjectService } from '@/services/project.service.ee'; import { SlackMethodsService } from './slack-methods.service'; +import { childRecord, type SlackAppSetupSession, slackSetupCacheKey } from './slack-setup.types'; +import { stringProperty } from '../../integration-helpers'; -const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:'; -const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; - -interface CreateSlackAppOptions { +export interface CreateSlackAppOptions { projectId: string; agentId: string; appConfigurationToken: string; user: User; } -interface GetSlackAppManifestOptions { +export interface GetSlackAppManifestOptions { projectId: string; agentId: string; } -interface CompleteSlackAppInstallOptions { +export interface CompleteSlackAppInstallOptions { projectId: string; agentId: string; code: string; state: string; } -interface SlackAppSetupSession { - projectId: string; - agentId: string; - userId: string; - appId: string; - clientId: string; - clientSecret: string; - signingSecret: string; - redirectUrl: string; -} - function hasSessionShape(value: unknown): value is SlackAppSetupSession { const keys: Array = [ 'projectId', @@ -68,6 +57,7 @@ export class SlackManualSetupService { private readonly userRepository: UserRepository, private readonly cacheService: CacheService, private readonly cipher: Cipher, + private readonly projectService: ProjectService, ) {} async createApp(options: CreateSlackAppOptions): Promise { @@ -85,16 +75,16 @@ export class SlackManualSetupService { token: appConfigurationToken, manifest: JSON.stringify(manifest), }); - if (response.ok !== true) { + if (!response.ok) { throw this.methods.slackError('create the Slack app', response); } - const credentials = this.methods.childRecord(response, 'credentials'); - const appId = this.methods.stringProperty(response, 'app_id'); - const clientId = this.methods.stringProperty(credentials, 'client_id'); - const clientSecret = this.methods.stringProperty(credentials, 'client_secret'); - const signingSecret = this.methods.stringProperty(credentials, 'signing_secret'); - const oauthAuthorizeUrl = this.methods.stringProperty(response, 'oauth_authorize_url'); + const credentials = childRecord(response, 'credentials'); + const appId = stringProperty(response, 'app_id'); + const clientId = stringProperty(credentials, 'client_id'); + const clientSecret = stringProperty(credentials, 'client_secret'); + const signingSecret = stringProperty(credentials, 'signing_secret'); + const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url'); if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) { throw new BadRequestError('Slack returned an incomplete app setup response'); } @@ -110,11 +100,7 @@ export class SlackManualSetupService { signingSecret, redirectUrl, } satisfies SlackAppSetupSession; - await this.cacheService.set( - this.cacheKey(state), - await this.cipher.encryptV2(JSON.stringify(setupSession)), - SLACK_APP_SETUP_TTL_MS, - ); + await this.methods.storeSession(state, setupSession); return { appId, @@ -140,6 +126,13 @@ export class SlackManualSetupService { relations: ['role'], }); if (!user) throw new NotFoundError(`User "${session.userId}" not found`); + const project = await this.projectService.getProjectWithScope(user, session.projectId, [ + 'agent:update', + 'credential:create', + ]); + if (!project) { + throw new ForbiddenError('You do not have permission to complete Slack app setup'); + } const agent = await this.methods.getAgent(session.agentId, session.projectId); const tokenResponse = await this.methods.callSlackApi( @@ -154,25 +147,25 @@ export class SlackManualSetupService { )}`, }, ); - if (tokenResponse.ok !== true) { + if (!tokenResponse.ok) { throw this.methods.slackError('finish Slack app installation', tokenResponse); } - const accessToken = this.methods.stringProperty(tokenResponse, 'access_token'); + const accessToken = stringProperty(tokenResponse, 'access_token'); if (!accessToken?.startsWith('xoxb-')) { throw new BadRequestError('Slack did not return a Bot User OAuth Token'); } - await this.methods.createAndConnectBotCredential({ - agent, - user, - accessToken, - signingSecret: session.signingSecret, + const team = childRecord(tokenResponse, 'team'); + const teamName = stringProperty(team, 'name'); + await this.methods.connectBotCredential(agent, user, accessToken, { + ...session, + ...(teamName ? { teamName } : {}), }); } private async consumeSession(state: string): Promise { - const cached = await this.cacheService.take(this.cacheKey(state)); + const cached = await this.cacheService.take(slackSetupCacheKey(state)); if (typeof cached !== 'string') { throw new BadRequestError('Slack app setup state has expired or is invalid'); } @@ -187,8 +180,4 @@ export class SlackManualSetupService { throw new BadRequestError('Slack app setup state has expired or is invalid'); } - - private cacheKey(state: string): string { - return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`; - } } diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts index fb9b58eede9..150a198c202 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-methods.service.ts @@ -1,18 +1,32 @@ -import type { AgentIntegrationConfig, SlackAgentAppManifest } from '@n8n/api-types'; +import type { + AgentIntegrationConfig, + SlackAgentAppManifest, + SlackApiErrorMeta, +} from '@n8n/api-types'; import { OutboundHttp } from '@n8n/backend-network'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { isRecord } from '@n8n/utils/is-record'; +import { Cipher } from 'n8n-core'; import { CredentialsService } from '@/credentials/credentials.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { CacheService } from '@/services/cache/cache.service'; import { UrlService } from '@/services/url.service'; +import { + SLACK_BOT_SCOPES, + type SlackAppSetupSession, + slackSetupCacheKey, +} from './slack-setup.types'; import { AgentIntegrationManagementService } from '../../../agent-integration-management.service'; import type { Agent } from '../../../entities/agent.entity'; import { AgentRepository } from '../../../repositories/agent.repository'; +import { stringProperty } from '../../integration-helpers'; +const SLACK_MANAGED_APP_CACHE_PREFIX = 'agents:slack-managed-app:'; +const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; const DEFAULT_SLACK_APP_NAME = 'n8n Agent'; const SLACK_CREDENTIAL_TYPE = 'slackApi'; @@ -26,30 +40,17 @@ const REQUIRED_BOT_EVENTS = [ 'message.mpim', ] as const; -const REQUIRED_BOT_SCOPES = [ - 'app_mentions:read', - 'assistant:write', - 'channels:history', - 'channels:join', - 'channels:manage', - 'channels:read', - 'chat:write', - 'chat:write.customize', - 'files:read', - 'files:write', - 'groups:history', - 'groups:read', - 'im:history', - 'im:read', - 'im:write', - 'mpim:history', - 'mpim:read', - 'mpim:write', - 'reactions:write', - 'search:read.public', - 'users:read', - 'users:read.email', -] as const; +const isLocalhost = (hostname: string): boolean => + hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; + +class SlackApiError extends BadRequestError { + override readonly meta: SlackApiErrorMeta; + + constructor(action: string, code: string) { + super(`Slack could not ${action}: ${code}`); + this.meta = { integrationType: 'slack', code }; + } +} @Service() export class SlackMethodsService { @@ -59,35 +60,43 @@ export class SlackMethodsService { private readonly integrationManagementService: AgentIntegrationManagementService, private readonly urlService: UrlService, private readonly outboundHttp: OutboundHttp, + private readonly cacheService: CacheService, + private readonly cipher: Cipher, ) {} - async callSlackApi( + async callSlackApi( method: string, - params: Record, + params: Record | FormData, headers: Record = {}, - ): Promise> { + ): Promise<({ ok: true } & T) | { ok: false; error: string }> { try { - const response = await this.outboundHttp.requests({ ssrf: 'disabled' }).request({ - method: 'POST', - url: `https://slack.com/api/${method}`, - headers: { - ...headers, - 'content-type': 'application/x-www-form-urlencoded', - }, - body: params, - returnFullResponse: true, - ignoreHttpStatusErrors: true, - }); + const requestHeaders = { ...headers }; + if (!(params instanceof FormData)) { + requestHeaders['content-type'] = 'application/x-www-form-urlencoded'; + } + const response = await this.outboundHttp + .requests({ + ssrf: 'disabled', + }) + .request({ + method: 'POST', + url: `https://slack.com/api/${method}`, + headers: requestHeaders, + body: params, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + }); const data: unknown = response.body; - return isRecord(data) ? data : { ok: false, error: 'invalid_response' }; + return isRecord(data) + ? (data as ({ ok: true } & T) | { ok: false; error: string }) + : { ok: false, error: 'invalid_response' }; } catch { return { ok: false, error: 'slack_request_failed' }; } } slackError(action: string, response: Record): BadRequestError { - const error = this.stringProperty(response, 'error') ?? 'unknown_error'; - return new BadRequestError(`Slack could not ${action}: ${error}`); + return new SlackApiError(action, stringProperty(response, 'error') ?? 'unknown_error'); } async getAgent(agentId: string, projectId: string): Promise { @@ -100,12 +109,17 @@ export class SlackMethodsService { agentName: string, projectId: string, agentId: string, - options: { redirectUrl?: string } = {}, + options: { redirectUrl?: string; managed?: boolean } = {}, ): SlackAgentAppManifest { const slackAppName = this.sanitiseSlackAppName(agentName); const webhookUrl = this.webhookUrl(projectId, agentId); return { - display_information: { name: slackAppName }, + display_information: { + name: slackAppName, + ...(options.managed + ? { description: `Work with ${slackAppName}, your n8n AI agent, in Slack.` } + : {}), + }, features: { app_home: { home_tab_enabled: false, @@ -119,7 +133,7 @@ export class SlackMethodsService { }, oauth_config: { ...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}), - scopes: { bot: [...REQUIRED_BOT_SCOPES] }, + scopes: { bot: [...SLACK_BOT_SCOPES] }, }, settings: { event_subscriptions: { @@ -133,6 +147,14 @@ export class SlackMethodsService { org_deploy_enabled: false, socket_mode_enabled: false, token_rotation_enabled: false, + ...(options.managed + ? { + managed_app_settings: { + is_install_from_slack_disabled: true, + external_app_management_url: this.managedAppUrl(projectId, agentId), + }, + } + : {}), }, }; } @@ -152,52 +174,80 @@ export class SlackMethodsService { } } - async createAndConnectBotCredential(options: { - agent: Agent; - user: User; - accessToken: string; - signingSecret: string; - }): Promise { - const credential = await this.credentialsService.createUnmanagedCredential( - { - name: this.credentialName(options.agent.name), - type: SLACK_CREDENTIAL_TYPE, - data: { - accessToken: options.accessToken, - signatureSecret: options.signingSecret, - }, - projectId: options.agent.projectId, - }, - options.user, + async storeSession(state: string, session: SlackAppSetupSession): Promise { + await this.cacheService.set( + slackSetupCacheKey(state), + await this.cipher.encryptV2(JSON.stringify(session)), + SLACK_APP_SETUP_TTL_MS, ); + } + + async connectBotCredential( + agent: Agent, + user: User, + accessToken: string, + session: SlackAppSetupSession, + ): Promise { + const credentialData = { + name: this.credentialName(session.teamName, agent.name), + type: SLACK_CREDENTIAL_TYPE, + data: { + accessToken, + signatureSecret: session.signingSecret, + ...(session.managerCredentialId + ? { + managedAppId: session.appId, + teamId: session.teamId, + managerCredentialId: session.managerCredentialId, + } + : {}), + }, + projectId: session.projectId, + }; + const credential = session.managerCredentialId + ? await this.credentialsService.createManagedCredential(credentialData, user) + : await this.credentialsService.createUnmanagedCredential(credentialData, user); const integration = { type: 'slack', credentialId: credential.id, } satisfies AgentIntegrationConfig; await this.integrationManagementService.connect({ - agent: options.agent, - user: options.user, + agent, + user, integration, }); + await this.clearManagedAppSession(session); return credential.id; } - childRecord(record: Record, key: string): Record | undefined { - const child = record[key]; - return isRecord(child) ? child : undefined; - } - - stringProperty(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === 'string' ? value : undefined; + private async clearManagedAppSession(session: SlackAppSetupSession): Promise { + if (session.managerCredentialId && session.teamId) { + await this.cacheService.delete( + `${SLACK_MANAGED_APP_CACHE_PREFIX}${session.projectId}:${session.agentId}:${session.managerCredentialId}:${session.teamId}:${session.userId}`, + ); + } } private webhookUrl(projectId: string, agentId: string): string { return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`; } - private credentialName(agentName: string): string { - return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128); + private managedAppUrl(projectId: string, agentId: string): string { + const url = new URL( + `/projects/${projectId}/agents/${agentId}`, + `${this.urlService.getInstanceBaseUrl()}/`, + ); + if (process.env.NODE_ENV === 'development' && isLocalhost(url.hostname)) { + url.protocol = 'https:'; + } + return url.toString(); + } + + private credentialName(workspaceName: string | undefined, agentName: string): string { + return [workspaceName ?? 'Slack', agentName || DEFAULT_SLACK_APP_NAME] + .filter(Boolean) + .join(' - ') + .slice(0, 128); } private sanitiseSlackAppName(raw: string): string { diff --git a/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts new file mode 100644 index 00000000000..9da7b45eb58 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/slack/slack-setup.types.ts @@ -0,0 +1,51 @@ +import { isRecord } from '@n8n/utils/is-record'; + +const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:'; + +export const SLACK_BOT_SCOPES = [ + 'app_mentions:read', + 'assistant:write', + 'channels:history', + 'channels:join', + 'channels:read', + 'chat:write', + 'files:read', + 'files:write', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'mpim:write', + 'reactions:write', + 'users:read', + 'users:read.email', +] as const; + +export interface SlackAppSetupSession { + projectId: string; + agentId: string; + userId: string; + appId: string; + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUrl: string; + managerCredentialId?: string; + teamId?: string; + teamName?: string; +} + +export function slackSetupCacheKey(state: string): string { + return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`; +} + +export function childRecord( + record: Record, + key: string, +): Record | undefined { + const child = record[key]; + return isRecord(child) ? child : undefined; +} diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 848838564df..8ee52d718fb 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -1648,7 +1648,7 @@ export class McpAgentToolsService { } private async disconnectIntegration(user: User, input: UpdateIntegrationInput, agent: Agent) { - const { savedAgent: saved } = await this.integrationManagementService.disconnect({ + const { savedAgent: saved, warning } = await this.integrationManagementService.disconnect({ agent, user, type: input.type, @@ -1660,6 +1660,7 @@ export class McpAgentToolsService { agentId: input.agentId, integration: { type: input.type, credentialId: input.credentialId }, connected: false, + ...(warning ? { warning } : {}), published: saved.activeVersionId !== null, activeVersionId: saved.activeVersionId, configHash: getAgentConfigHash(this.configFromEntity(saved)), @@ -1691,7 +1692,6 @@ export class McpAgentToolsService { configHash: getAgentConfigHash(this.configFromEntity(saved)), }; if (saved.activeVersionId === null) return { ...result, connected: false }; - return { ...result, connected: true, diff --git a/packages/cli/src/services/cache/__tests__/cache.service.test.ts b/packages/cli/src/services/cache/__tests__/cache.service.test.ts index 00a60534a6c..f91bac793fa 100644 --- a/packages/cli/src/services/cache/__tests__/cache.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache.service.test.ts @@ -138,6 +138,15 @@ for (const backend of ['memory', 'redis'] as const) { }); }); + describe('take', () => { + test('should return a value only once', async () => { + await cacheService.set('single-use', 'value'); + + await expect(cacheService.take('single-use')).resolves.toBe('value'); + await expect(cacheService.take('single-use')).resolves.toBeUndefined(); + }); + }); + describe('get', () => { const createRefreshFn = () => vi.fn(async () => await Promise.resolve('refreshValue')); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDialog/Dialog.vue b/packages/frontend/@n8n/design-system/src/components/N8nDialog/Dialog.vue index 28d6b6fc209..5a860314ebc 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDialog/Dialog.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDialog/Dialog.vue @@ -75,6 +75,10 @@ export interface DialogProps { * Only rendered when header prop is also provided. */ description?: string; + /** + * Render above another open dialog + */ + stacked?: boolean; } export interface DialogEmits { @@ -91,6 +95,7 @@ withDefaults(defineProps(), { trapFocus: true, disableOutsidePointerEvents: true, showCloseButton: true, + stacked: false, }); const emit = defineEmits(); @@ -107,7 +112,7 @@ const handleOpenChange = (value: boolean) => { @update:open="handleOpenChange" > - + { :show-close-button="showCloseButton" :aria-label="ariaLabel" :aria-description="ariaDescription" + :stacked="stacked" @escape-key-down="emit('escapeKeyDown', $event)" @interact-outside="emit('interactOutside', $event)" @open-auto-focus="emit('openAutoFocus', $event)" diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogContent.vue b/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogContent.vue index d801073b35b..da4e9295c45 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogContent.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogContent.vue @@ -47,6 +47,10 @@ export interface DialogContentProps { * Accessible description for the dialog (used when DialogDescription is not provided) */ ariaDescription?: string; + /** + * Render above another open dialog + */ + stacked?: boolean; } export interface DialogContentEmits { @@ -106,7 +110,7 @@ function handleInteractOutside(e: Event) { :force-mount="forceMount" :trap-focus="trapFocus" :disable-outside-pointer-events="disableOutsidePointerEvents" - :class="[$style.content, sizeClass]" + :class="[$style.content, sizeClass, stacked && $style.stacked]" @escape-key-down="emit('escapeKeyDown', $event)" @interact-outside="handleInteractOutside" @open-auto-focus="emit('openAutoFocus', $event)" @@ -174,6 +178,10 @@ function handleInteractOutside(e: Event) { } } +.stacked { + z-index: 1952; // Higher than default .content z-index to ensure the dialog is always on top. +} + .content[data-state='open'] { animation: dialogFadeIn var(--animation--duration--snappy) ease-out; } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogOverlay.vue b/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogOverlay.vue index ee01c8b2f37..bd5491c2e8b 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogOverlay.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDialog/DialogOverlay.vue @@ -6,13 +6,17 @@ export interface DialogOverlayProps { * Force mount for animation control */ forceMount?: boolean; + /** + * Render above another open dialog + */ + stacked?: boolean; } defineProps(); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.test.ts new file mode 100644 index 00000000000..d0616ee79e6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.test.ts @@ -0,0 +1,58 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; + +import AgentChannelSlackRemoveConfirmation from './AgentChannelSlackRemoveConfirmation.vue'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), +})); + +function mountConfirmation() { + return mount(AgentChannelSlackRemoveConfirmation, { + props: { open: true, loading: false }, + global: { + stubs: { + Dialog: { + props: ['open', 'stacked', 'size'], + template: '
', + }, + DialogHeader: { template: '
' }, + DialogTitle: { template: '

' }, + DialogFooter: { template: '
' }, + N8nText: { template: '' }, + N8nButton: { + emits: ['click'], + template: '', + }, + N8nCheckbox: { + props: ['modelValue'], + emits: ['update:modelValue'], + template: ` +
+
+ `, + }, + }, + }, + }); +} + +describe('AgentChannelSlackRemoveConfirmation', () => { + it('controls whether the managed Slack app is deleted', async () => { + const wrapper = mountConfirmation(); + + expect(wrapper.attributes()).toHaveProperty('data-stacked'); + expect(wrapper.attributes('data-size')).toBe('medium'); + await wrapper.get('[data-testid="slack-managed-remove-confirm"]').trigger('click'); + expect(wrapper.emitted('confirm')).toEqual([[true]]); + + await wrapper.get('[data-testid="slack-managed-remove-delete-app"]').trigger('click'); + await wrapper.get('[data-testid="slack-managed-remove-confirm"]').trigger('click'); + expect(wrapper.emitted('confirm')).toEqual([[true], [false]]); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.vue new file mode 100644 index 00000000000..3a99c7813b0 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackRemoveConfirmation.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackServiceLimitError.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackServiceLimitError.vue new file mode 100644 index 00000000000..476ead56995 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackServiceLimitError.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupKindSelector.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupKindSelector.vue new file mode 100644 index 00000000000..4145712aab9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupKindSelector.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue index b6f8f01c2cc..9ad6a0fa3cf 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/AgentChannelSlackSetupView.vue @@ -1,12 +1,18 @@ + + diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/api.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.test.ts new file mode 100644 index 00000000000..f8a217e27c0 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.test.ts @@ -0,0 +1,41 @@ +import { ResponseError } from '@n8n/rest-api-client'; +import { describe, expect, it } from 'vitest'; + +import { getSlackApiErrorCode } from './api'; + +describe('getSlackApiErrorCode', () => { + it('returns the code from a Slack API response error', () => { + const error = new ResponseError('Slack request failed', { + httpStatusCode: 400, + meta: { + integrationType: 'slack', + code: 'service_limits_exceeded', + }, + }); + + expect(getSlackApiErrorCode(error)).toBe('service_limits_exceeded'); + }); + + it('rejects metadata from another integration', () => { + const error = new ResponseError('Integration request failed', { + httpStatusCode: 400, + meta: { + integrationType: 'linear', + code: 'service_limits_exceeded', + }, + }); + + expect(getSlackApiErrorCode(error)).toBeUndefined(); + }); + + it('rejects Slack-shaped metadata outside a response error', () => { + const error = { + meta: { + integrationType: 'slack', + code: 'service_limits_exceeded', + }, + }; + + expect(getSlackApiErrorCode(error)).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts index 84e90c8f7d8..5ded58e88a8 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/api.ts @@ -1,10 +1,34 @@ -import type { CreateSlackAgentAppResponse, SlackAgentAppManifestResponse } from '@n8n/api-types'; +import type { + CreateSlackAgentAppResponse, + CreateSlackManagerCredentialResponse, + InstallSlackManagedAppResponse, + SlackAgentAppManifestResponse, + SlackApiErrorMeta, + SlackManagedAppSettings, + SlackManagedSetupState, +} from '@n8n/api-types'; import type { IRestApiContext } from '@n8n/rest-api-client'; -import { makeRestApiRequest } from '@n8n/rest-api-client'; +import { makeRestApiRequest, ResponseError } from '@n8n/rest-api-client'; const integrationPath = (projectId: string, agentId: string) => `/projects/${projectId}/agents/v2/${agentId}/integrations/slack`; +function isSlackApiErrorMeta(value: unknown): value is SlackApiErrorMeta { + return ( + typeof value === 'object' && + value !== null && + 'integrationType' in value && + value.integrationType === 'slack' && + 'code' in value && + typeof value.code === 'string' + ); +} + +export function getSlackApiErrorCode(error: unknown): string | undefined { + const meta = error instanceof ResponseError ? error.meta : undefined; + return isSlackApiErrorMeta(meta) ? meta.code : undefined; +} + export const createSlackAgentApp = async ( context: IRestApiContext, projectId: string, @@ -21,3 +45,72 @@ export const getSlackAgentAppManifest = async ( agentId: string, ): Promise => await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/manifest`); + +export const getSlackManagedSetup = async ( + context: IRestApiContext, + projectId: string, + agentId: string, +): Promise => + await makeRestApiRequest(context, 'GET', `${integrationPath(projectId, agentId)}/managed/setup`); + +export const createSlackManagerCredential = async ( + context: IRestApiContext, + projectId: string, + agentId: string, +): Promise => + await makeRestApiRequest( + context, + 'POST', + `${integrationPath(projectId, agentId)}/managed/credentials`, + ); + +export const finalizeSlackManagerCredential = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + credentialId: string, +): Promise => + await makeRestApiRequest( + context, + 'POST', + `${integrationPath(projectId, agentId)}/managed/credentials/${credentialId}/finalize`, + ); + +export const installSlackManagedApp = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + managerCredentialId: string, + workspaceId: string, +): Promise => + await makeRestApiRequest( + context, + 'POST', + `${integrationPath(projectId, agentId)}/managed/install`, + { managerCredentialId, workspaceId }, + ); + +export const getSlackManagedAppSettings = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + credentialId: string, +): Promise => + await makeRestApiRequest( + context, + 'GET', + `${integrationPath(projectId, agentId)}/managed/settings/${credentialId}`, + ); + +export const updateSlackManagedAppSettings = async ( + context: IRestApiContext, + projectId: string, + agentId: string, + settings: Pick, +): Promise => + await makeRestApiRequest( + context, + 'POST', + `${integrationPath(projectId, agentId)}/managed/settings`, + settings, + ); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/constants.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/constants.ts new file mode 100644 index 00000000000..fdda891d239 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/constants.ts @@ -0,0 +1 @@ +export const SLACK_APP_DASHBOARD_URL = 'https://api.slack.com/apps'; diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts index a73970dc91f..fa2db2eeca2 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts @@ -1,71 +1,249 @@ +import { flushPromises } from '@vue/test-utils'; +import { ResponseError } from '@n8n/rest-api-client'; import { ref } from 'vue'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useSlackChannelRuntime } from './useSlackChannelRuntime'; -const { createSlackAgentApp } = vi.hoisted(() => ({ - createSlackAgentApp: vi.fn().mockResolvedValue({ installUrl: 'https://slack.test/install' }), +const mocks = vi.hoisted(() => ({ + getSetup: vi.fn(), + getSettings: vi.fn(), + updateSettings: vi.fn(), + install: vi.fn(), + fetchCredentials: vi.fn(), + createManager: vi.fn(), + finalizeManager: vi.fn(), + authorize: vi.fn(), + authorizeNewCredential: vi.fn(), })); vi.mock('@n8n/stores/useRootStore', () => ({ - useRootStore: () => ({ restApiContext: {} }), + useRootStore: () => ({ restApiContext: {}, urlBaseEditor: 'https://n8n.test' }), })); -vi.mock('./api', () => ({ - createSlackAgentApp, +vi.mock('@/app/stores/ui.store', () => ({ + useUIStore: () => ({ openExistingCredential: vi.fn() }), })); +vi.mock('@/features/credentials/credentials.store', () => ({ + useCredentialsStore: () => ({ + setCredentials: vi.fn(), + fetchAllCredentialsForWorkflow: mocks.fetchCredentials, + deleteCredential: vi.fn(), + }), +})); + +vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({ + useCredentialOAuth: () => ({ + authorize: mocks.authorize, + authorizeNewCredential: mocks.authorizeNewCredential, + }), +})); + +vi.mock('./api', async (importOriginal) => ({ + ...(await importOriginal()), + createSlackAgentApp: vi.fn(), + createSlackManagerCredential: mocks.createManager, + finalizeSlackManagerCredential: mocks.finalizeManager, + getSlackManagedSetup: mocks.getSetup, + getSlackManagedAppSettings: mocks.getSettings, + updateSlackManagedAppSettings: mocks.updateSettings, + installSlackManagedApp: mocks.install, +})); + +function createRuntime( + selectedCredentialId = '', + credentialModalOpen = ref(false), + ensureAgentPersisted = vi.fn().mockResolvedValue(undefined), +) { + return useSlackChannelRuntime({ + projectId: ref('project-1'), + agentId: ref('agent-1'), + selectedCredentialId: ref(selectedCredentialId), + credentialModalOpen, + fetchStatus: vi.fn().mockResolvedValue(undefined), + isConnected: () => false, + isConfigured: () => false, + ensureAgentPersisted, + }); +} + describe('useSlackChannelRuntime', () => { - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSetup.mockResolvedValue({ + managedSetupAvailable: true, + managerCredentials: [ + { + id: 'manager', + name: 'Manager', + connected: true, + reconnectRequired: false, + workspaces: [{ id: 'T1', name: 'Workspace', botCredentialId: 'bot' }], + }, + ], + }); + mocks.getSettings.mockResolvedValue({ + credentialId: 'bot', + appId: 'A1', + name: 'Bot', + description: 'Description', + alwaysOnline: true, + appHomeUrl: 'https://api.slack.com/apps/A1/app-home', + }); + mocks.updateSettings.mockResolvedValue({ + credentialId: 'bot', + appId: 'A1', + name: 'Updated', + description: 'Updated description', + alwaysOnline: false, + appHomeUrl: 'https://api.slack.com/apps/A1/app-home', + }); + mocks.install.mockResolvedValue({ + status: 'connected', + appId: 'A1', + credentialId: 'bot', + }); + mocks.createManager.mockResolvedValue({ + id: 'manager', + name: 'Slack workspace manager', + type: 'slackManagerOAuth2Api', + isResolvable: false, + }); + mocks.fetchCredentials.mockResolvedValue([ + { + id: 'manager', + name: 'Slack workspace manager', + type: 'slackManagerOAuth2Api', + }, + ]); + mocks.authorize.mockResolvedValue(true); + mocks.authorizeNewCredential.mockResolvedValue(true); + mocks.finalizeManager.mockResolvedValue(undefined); }); - it('completes manual setup when a final poll confirms the connection', async () => { - vi.useFakeTimers(); + it('loads managed setup and settings for the selected managed credential', async () => { + const runtime = createRuntime('bot'); - class FakeBroadcastChannel { - addEventListener() {} - close() {} - } - vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + await runtime.load(); - const popup = { closed: false, close: vi.fn() }; - vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window); + expect(runtime.setup.value.managedSetupAvailable).toBe(true); + expect(runtime.setupKind.value).toBe('managed'); + expect(runtime.settings.value?.credentialId).toBe('bot'); + expect(runtime.isManagedCredential('bot')).toBe(true); + expect(mocks.getSettings).toHaveBeenCalledOnce(); + }); - let resolveFirstPoll!: () => void; - const firstPoll = new Promise((resolve) => { - resolveFirstPoll = resolve; - }); - let isConfigured = false; - const fetchStatus = vi.fn().mockImplementation(async () => { - if (fetchStatus.mock.calls.length === 1) await firstPoll; - }); - const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined); + it.each([ + ['new', undefined, mocks.authorizeNewCredential], + ['existing', 'manager', mocks.authorize], + ])( + 'finalizes a %s manager credential after OAuth', + async (_scenario, credentialId, authorize) => { + const runtime = createRuntime(); + + await expect(runtime.connectManagerCredential(credentialId)).resolves.toBe(true); + + const expectedCredential = expect.objectContaining({ id: 'manager' }); + if (credentialId) { + expect(authorize).toHaveBeenCalledWith(expectedCredential, undefined, { + abortOnPopupClose: true, + }); + } else { + expect(authorize).toHaveBeenCalledWith(expectedCredential, { + abortOnPopupClose: true, + }); + } + expect(mocks.finalizeManager).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'manager'); + }, + ); + + it('saves managed app settings locally through the Slack API', async () => { + const runtime = createRuntime('bot'); + const settings = { + credentialId: 'bot', + name: 'Updated', + description: 'Updated description', + alwaysOnline: false, + }; + + await runtime.saveSettings(settings); + + expect(mocks.updateSettings).toHaveBeenCalledWith({}, 'project-1', 'agent-1', settings); + expect(runtime.settings.value?.name).toBe('Updated'); + }); + + it('keeps settings valid so a failed save can be retried', async () => { + const runtime = createRuntime('bot'); + const settings = { + credentialId: 'bot', + name: 'Updated', + description: 'Updated description', + alwaysOnline: false, + }; + mocks.updateSettings.mockRejectedValueOnce(new Error('Slack unavailable')); + + await expect(runtime.saveSettings(settings)).rejects.toThrow('Slack unavailable'); + + expect(runtime.settingsLoading.value).toBe(false); + expect(runtime.settingsError.value).toBe(false); + expect(runtime.settingsSaveError.value).toBeNull(); + }); + + it('exposes Slack service limit errors returned when saving', async () => { + const runtime = createRuntime('bot'); + mocks.updateSettings.mockRejectedValueOnce( + new ResponseError('Slack could not update the Slack app: service_limits_exceeded', { + httpStatusCode: 400, + meta: { + integrationType: 'slack', + code: 'service_limits_exceeded', + }, + }), + ); + + await expect( + runtime.saveSettings({ + credentialId: 'bot', + name: 'Updated', + description: 'Updated description', + alwaysOnline: false, + }), + ).rejects.toThrow('service_limits_exceeded'); + + expect(runtime.settingsSaveError.value).toBe('service_limits_exceeded'); + }); + + it('completes managed installation and refreshes Slack state', async () => { const onConnected = vi.fn(); - const runtime = useSlackChannelRuntime({ - projectId: ref('project-1'), - agentId: ref('agent-1'), - selectedCredentialId: ref(''), - credentialModalOpen: ref(false), - fetchStatus, - isConnected: () => false, - isConfigured: () => isConfigured, - ensureAgentPersisted, - }); + const ensureAgentPersisted = vi.fn().mockResolvedValue(undefined); + const runtime = createRuntime('', ref(false), ensureAgentPersisted); - const setupPromise = runtime.setupApp('token', onConnected); - await vi.advanceTimersByTimeAsync(0); + await runtime.installManagedApp('manager', 'T1', onConnected); - popup.closed = true; - await vi.advanceTimersByTimeAsync(2000); - isConfigured = true; - resolveFirstPoll(); - - await expect(setupPromise).resolves.toBe(true); expect(ensureAgentPersisted).toHaveBeenCalledOnce(); - expect(createSlackAgentApp).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'token'); + expect(mocks.install).toHaveBeenCalledWith({}, 'project-1', 'agent-1', 'manager', 'T1'); + expect(ensureAgentPersisted.mock.invocationCallOrder[0]).toBeLessThan( + mocks.install.mock.invocationCallOrder[0], + ); + expect(mocks.getSetup).toHaveBeenCalled(); expect(onConnected).toHaveBeenCalledOnce(); }); + + it('refreshes managed setup after the credential modal closes', async () => { + const credentialModalOpen = ref(false); + const runtime = createRuntime('', credentialModalOpen); + await runtime.load(); + mocks.getSetup.mockResolvedValue({ + managedSetupAvailable: true, + managerCredentials: [], + }); + + credentialModalOpen.value = true; + await flushPromises(); + credentialModalOpen.value = false; + await flushPromises(); + + expect(runtime.setup.value.managerCredentials).toEqual([]); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts index c66e67b51b1..b4796731515 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts @@ -1,35 +1,141 @@ +import type { + SlackManagedAppSettings, + SlackManagedAppSettingsErrorCode, + SlackManagedSetupState, +} from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; -import { readonly, ref } from 'vue'; +import { computed, ref, watch, type Ref } from 'vue'; + +import { useUIStore } from '@/app/stores/ui.store'; +import { + getTrustedOAuthOrigins, + waitForOAuthCallback, +} from '@/features/credentials/composables/oauthCallback'; +import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; import type { AgentChannelRuntime, AgentChannelRuntimeContext } from '../types'; -import { createSlackAgentApp } from './api'; +import { + createSlackAgentApp, + createSlackManagerCredential, + finalizeSlackManagerCredential, + getSlackApiErrorCode, + getSlackManagedAppSettings, + getSlackManagedSetup, + installSlackManagedApp, + updateSlackManagedAppSettings, +} from './api'; -const SLACK_APP_SETUP_POLL_INTERVAL_MS = 2000; const SLACK_APP_SETUP_TIMEOUT_MS = 2 * 60 * 1000; +const SLACK_MANAGER_CREDENTIAL_TYPE = 'slackManagerOAuth2Api'; +export type SlackSetupKind = 'managed' | 'manual'; export interface SlackChannelRuntime extends AgentChannelRuntime { + setup: Ref; + setupKind: Ref; + settings: Ref; + settingsLoading: Ref; + settingsError: Ref; + settingsSaveError: Ref; + isManagedCredential: (credentialId: string) => boolean; setupApp: ( appConfigurationToken: string, onConnected: () => void | Promise, ) => Promise; + connectManagerCredential: (credentialId?: string) => Promise; + editManagerCredential: (credentialId: string) => void; + installManagedApp: ( + managerCredentialId: string, + workspaceId: string, + onConnected: () => void | Promise, + ) => Promise; + saveSettings: ( + settings: Pick< + SlackManagedAppSettings, + 'credentialId' | 'name' | 'description' | 'alwaysOnline' + >, + ) => Promise; } export function isSlackChannelRuntime( runtime: AgentChannelRuntime, ): runtime is SlackChannelRuntime { - return 'setupApp' in runtime && typeof runtime.setupApp === 'function'; + return 'setup' in runtime; } export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): SlackChannelRuntime { const rootStore = useRootStore(); - const loading = ref(false); + const uiStore = useUIStore(); + const credentialsStore = useCredentialsStore(); + const credentialOAuth = useCredentialOAuth(); + + const setup = ref({ + managedSetupAvailable: false, + managerCredentials: [], + }); + const setupKind = ref('managed'); + const loading = ref(true); + const settings = ref(null); + const settingsLoading = ref(false); + const settingsError = ref(false); + const settingsSaveError = ref(null); + let settingsRequestId = 0; + + function isManagedCredential(credentialId: string): boolean { + return setup.value.managerCredentials.some((manager) => + manager.workspaces.some((workspace) => workspace.botCredentialId === credentialId), + ); + } + + async function loadSettings(credentialId: string) { + const requestId = ++settingsRequestId; + if (!credentialId || !isManagedCredential(credentialId)) { + settings.value = null; + settingsError.value = false; + return; + } + + settingsLoading.value = true; + settingsError.value = false; + try { + const result = await getSlackManagedAppSettings( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + credentialId, + ); + if (requestId === settingsRequestId) settings.value = result; + } catch { + if (requestId === settingsRequestId) { + settings.value = null; + settingsError.value = true; + } + } finally { + if (requestId === settingsRequestId) settingsLoading.value = false; + } + } + + async function load() { + loading.value = true; + try { + setup.value = await getSlackManagedSetup( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + ); + } catch { + setup.value = { managedSetupAvailable: false, managerCredentials: [] }; + } finally { + loading.value = false; + } + await loadSettings(context.selectedCredentialId.value); + } function openAuthorizationPopup(installUrl: string): Window { const parsedUrl = new URL(installUrl); if (!['http:', 'https:'].includes(parsedUrl.protocol)) { throw new Error('Invalid Slack installation URL'); } - const popup = window.open( parsedUrl.toString(), 'Slack App Authorization', @@ -40,87 +146,168 @@ export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): Sla } async function waitForSetupCompletion(popup: Window): Promise { - return await new Promise((resolve) => { - const oauthChannel = new BroadcastChannel('oauth-callback'); - let activePoll: Promise | null = null; - let settled = false; - - const closePopup = () => { - try { - popup.close(); - } catch {} - }; - - const settle = (success: boolean) => { - if (settled) return; - settled = true; - window.clearInterval(pollInterval); - window.clearTimeout(timeout); - oauthChannel.close(); - if (success) closePopup(); - resolve(success); - }; - - const pollStatus = async () => { - if (activePoll || settled) return; - activePoll = (async () => { - try { - await context.fetchStatus(['slack']); - if (context.isConfigured('slack')) settle(true); - } finally { - activePoll = null; - } - })(); - await activePoll; - }; - - const pollInterval = window.setInterval(() => { - if (popup.closed) { - void (activePoll ?? Promise.resolve()) - .catch(() => {}) - .then(pollStatus) - .finally(() => settle(false)); - return; - } - void pollStatus(); - }, SLACK_APP_SETUP_POLL_INTERVAL_MS); - const timeout = window.setTimeout(() => settle(false), SLACK_APP_SETUP_TIMEOUT_MS); - - oauthChannel.addEventListener('message', (event: MessageEvent) => { - settle(event.data === 'success'); - }); - - void pollStatus(); + const outcome = await waitForOAuthCallback({ + popup, + trustedOrigins: getTrustedOAuthOrigins(rootStore.urlBaseEditor), + verifyConnected: async () => { + await context.fetchStatus(['slack']); + return context.isConfigured('slack'); + }, + timeoutMs: SLACK_APP_SETUP_TIMEOUT_MS, + abortOnPopupClose: true, }); + popup.close(); + return outcome === 'success'; } async function setupApp( appConfigurationToken: string, onConnected: () => void | Promise, ): Promise { - loading.value = true; - try { - await context.ensureAgentPersisted?.(); - const { installUrl } = await createSlackAgentApp( + await context.ensureAgentPersisted?.(); + const { installUrl } = await createSlackAgentApp( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + appConfigurationToken, + ); + const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl)); + if (!connected) throw new Error('Slack app installation was not completed'); + await context.fetchStatus(['slack']); + await onConnected(); + return true; + } + + async function finalizeConnectedManagerCredential( + credentialId: string, + connected: boolean, + ): Promise { + if (!connected) return false; + await finalizeSlackManagerCredential( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + credentialId, + ).catch(() => {}); + await load(); + return true; + } + + async function connectManagerCredential(credentialId?: string): Promise { + await context.ensureAgentPersisted?.(); + let id = credentialId; + let createdCredentialId: string | undefined; + let authorizationStarted = false; + if (!id) { + const created = await createSlackManagerCredential( rootStore.restApiContext, context.projectId.value, context.agentId.value, - appConfigurationToken, ); - const connected = await waitForSetupCompletion(openAuthorizationPopup(installUrl)); - if (!connected) throw new Error('Slack app installation was not completed'); + id = created.id; + createdCredentialId = created.id; + } - await context.fetchStatus(['slack']); - await onConnected(); - return true; + try { + credentialsStore.setCredentials([]); + const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({ + projectId: context.projectId.value, + }); + const credential = credentials.find( + (item) => item.id === id && item.type === SLACK_MANAGER_CREDENTIAL_TYPE, + ); + if (!credential) throw new Error('Slack manager credential could not be loaded'); + if (createdCredentialId) { + authorizationStarted = true; + const connected = await credentialOAuth.authorizeNewCredential(credential, { + abortOnPopupClose: true, + }); + return await finalizeConnectedManagerCredential(id, connected); + } + const connected = await credentialOAuth.authorize(credential, undefined, { + abortOnPopupClose: true, + }); + return await finalizeConnectedManagerCredential(id, connected); } finally { - loading.value = false; + if (createdCredentialId && !authorizationStarted) { + await credentialsStore.deleteCredential({ id: createdCredentialId }); + } } } + function editManagerCredential(credentialId: string) { + uiStore.openExistingCredential(credentialId, { + hideAskAssistant: true, + appendToBody: true, + }); + } + + async function installManagedApp( + managerCredentialId: string, + workspaceId: string, + onConnected: () => void | Promise, + ): Promise { + await context.ensureAgentPersisted?.(); + const result = await installSlackManagedApp( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + managerCredentialId, + workspaceId, + ); + if (result.status === 'manual_install_required') { + const connected = await waitForSetupCompletion(openAuthorizationPopup(result.installUrl)); + if (!connected) throw new Error('Slack app installation was not completed'); + } + await context.fetchStatus(['slack']); + await load(); + await onConnected(); + return true; + } + + async function saveSettings( + update: Pick, + ) { + settingsLoading.value = true; + settingsError.value = false; + settingsSaveError.value = null; + try { + settings.value = await updateSlackManagedAppSettings( + rootStore.restApiContext, + context.projectId.value, + context.agentId.value, + update, + ); + } catch (error) { + const code = getSlackApiErrorCode(error); + if (code === 'service_limits_exceeded') { + settingsSaveError.value = code; + } + throw error; + } finally { + settingsLoading.value = false; + } + } + + watch(context.selectedCredentialId, (credentialId) => void loadSettings(credentialId)); + watch(context.credentialModalOpen, (isOpen, wasOpen) => { + if (wasOpen && !isOpen) void load(); + }); + return { - load: async () => {}, - loading: readonly(loading), + setup, + setupKind, + settings, + loading: computed(() => loading.value), + settingsLoading, + settingsError, + settingsSaveError, + load, + isManagedCredential, setupApp, + connectManagerCredential, + editManagerCredential, + installManagedApp, + saveSettings, }; } diff --git a/packages/frontend/editor-ui/src/features/agents/channels/types.ts b/packages/frontend/editor-ui/src/features/agents/channels/types.ts index 58aad60b23c..544b8b4f89c 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/types.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/types.ts @@ -1,8 +1,12 @@ -import type { AgentIntegrationSettings, ChatIntegrationDescriptor } from '@n8n/api-types'; -import type { IconName } from '@n8n/design-system'; +import type { + AgentIntegrationDisconnectWarning, + AgentIntegrationSettings, + ChatIntegrationDescriptor, +} from '@n8n/api-types'; +import type { IconName } from '@n8n/design-system/components/N8nIcon/icons'; import type { BaseTextKey } from '@n8n/i18n'; import type { PermissionsRecord } from '@n8n/permissions'; -import type { Component, Ref } from 'vue'; +import type { Component, Ref, VNode } from 'vue'; import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue'; @@ -64,7 +68,21 @@ export interface AgentChannelPresentationContext { text: (key: BaseTextKey) => string; } -export interface ChannelPlatformDefinition { +export interface AgentChannelWarningPresentation { + title: string; + message: string | VNode; +} + +export interface AgentChannelDisconnectContext { + isPublished: boolean; +} + +export interface AgentChannelHeaderContentProps { + runtime: AgentChannelRuntime; + disabled?: boolean; +} + +export interface AgentChannelPlatform { type: string; setupComponent: Component; editComponent: Component; @@ -74,4 +92,18 @@ export interface ChannelPlatformDefinition { runtime: AgentChannelRuntime, ) => AgentChannelConnectAction; getConnectedDescription?: (context: AgentChannelPresentationContext) => string; + headerContent?: { + setupModal?: Component; + editModal?: Component; + }; + disconnectConfirmationComponent?: Component; + shouldConfirmDisconnect?: ( + runtime: AgentChannelRuntime, + credentialId: string, + context: AgentChannelDisconnectContext, + ) => boolean; + presentDisconnectWarning?: ( + warning: AgentIntegrationDisconnectWarning, + context: AgentChannelPresentationContext, + ) => AgentChannelWarningPresentation | null; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue index 918a748f806..0a3f64680c2 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelModal.vue @@ -1,4 +1,5 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackManagedSetup.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackManagedSetup.vue new file mode 100644 index 00000000000..d16b17669f6 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackManagedSetup.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackSetup.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackSetup.vue index 600b73b88de..dc30b6cc781 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelSlackSetup.vue @@ -175,7 +175,7 @@ watch( { immediate: true }, ); -defineExpose({ credentialId, validationError: null }); +defineExpose({ credentialId, validationError: null, loading: setupLoading });