diff --git a/packages/@n8n/api-types/src/push/webhook.ts b/packages/@n8n/api-types/src/push/webhook.ts index bd796cc22d4..59fa0b2d317 100644 --- a/packages/@n8n/api-types/src/push/webhook.ts +++ b/packages/@n8n/api-types/src/push/webhook.ts @@ -14,4 +14,27 @@ export type TestWebhookReceived = { }; }; -export type WebhookPushMessage = TestWebhookDeleted | TestWebhookReceived; +export type WebhookReceived = { + type: 'webhookReceived'; + data: { + workflowId: string; + node: string; + method: string; + path: string; + }; +}; + +export type TriggerFired = { + type: 'triggerFired'; + data: { + workflowId: string; + /** `trigger` for schedules/pollers, `integrated` for sub-workflow calls */ + mode: string; + }; +}; + +export type WebhookPushMessage = + | TestWebhookDeleted + | TestWebhookReceived + | WebhookReceived + | TriggerFired; diff --git a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts index 336504705d2..aaac2ac69d4 100644 --- a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts @@ -1,6 +1,6 @@ import { DatabaseConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { DataSource, EntityManager, IsNull, LessThan, Repository, Not } from '@n8n/typeorm'; +import { DataSource, EntityManager, In, IsNull, LessThan, Repository, Not } from '@n8n/typeorm'; import { WorkflowDependency } from '../entities'; @@ -159,4 +159,17 @@ export class WorkflowDependencyRepository extends Repository const { tablePrefix } = this.databaseConfig; return this.manager.connection.driver.escape(`${tablePrefix}${name}`); } + + /** + * Retrieve the caller → callee edges of workflows that reference any of the + * given workflows via an Execute Workflow node. `workflowId` is the caller, + * `dependencyKey` the called workflow's id. + */ + async findCallersOfWorkflows(workflowIds: string[]) { + if (workflowIds.length === 0) return []; + return await this.find({ + select: ['workflowId', 'dependencyKey'], + where: { dependencyType: 'workflowCall', dependencyKey: In(workflowIds) }, + }); + } } diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 06ea4913e76..9ed6b45f94d 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -282,6 +282,29 @@ export class WorkflowRepository extends BaseRepository { }); } + /** + * Retrieve id, name and the published version (with its nodes) of every + * unarchived workflow that has an active version. + */ + async findPublishedWithActiveVersionNodes() { + return await this.find({ + select: ['id', 'name'], + where: { activeVersionId: Not(IsNull()), isArchived: false }, + relations: { activeVersion: true }, + }); + } + + /** + * Retrieve id, name, nodes and connections of every unarchived workflow + * without a published (active) version. + */ + async findUnpublishedWithNodes() { + return await this.find({ + select: ['id', 'name', 'nodes', 'connections'], + where: { activeVersionId: IsNull(), isArchived: false }, + }); + } + async findPreExistingWorkflows(workflowIds: string[]): Promise { if (workflowIds.length === 0) { return []; diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 5c04e4638a3..e972dfbd0be 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -92,6 +92,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "projectVariable:update", "projectVariable:delete", "projectVariable:list", + "webhook:list", "workersView:manage", "workflow:share", "workflow:unshare", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 79cc138e1e3..3e6fdbff5af 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -40,6 +40,7 @@ export const RESOURCES = { ] as const, variable: [...DEFAULT_OPERATIONS] as const, projectVariable: [...DEFAULT_OPERATIONS] as const, + webhook: ['list'] as const, workersView: ['manage'] as const, workflow: [ 'share', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 7bbea9df2ca..9d61d4b1be7 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -99,6 +99,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'workflow:move', 'workflow:enableRedaction', 'workflow:disableRedaction', + 'webhook:list', 'workersView:manage', 'project:list', 'project:create', diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 54be7a0d8d1..9748d9784bc 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -34,6 +34,7 @@ describe('permissions', () => { user: {}, variable: {}, projectVariable: {}, + webhook: {}, workersView: {}, workflow: {}, workflowTags: {}, @@ -146,6 +147,7 @@ describe('permissions', () => { list: true, read: true, }, + webhook: {}, workersView: {}, workflow: { create: true, diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 45a89664d82..06a72209f7a 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -34,6 +34,7 @@ import { getDataDeduplicationService } from '@/deduplication'; import { TestRunCleanupService } from '@/evaluation.ee/test-runner/test-run-cleanup.service.ee'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; +import { TriggerActivityEventRelay } from '@/events/relays/trigger-activity.event-relay'; import { WorkflowFailureNotificationEventRelay } from '@/events/relays/workflow-failure-notification.event-relay'; import { ExecutionDataJsonStore } from '@/executions/execution-data/execution-data-json-store'; import { ExpressionObservabilityProvider } from '@/expression-observability/expression-observability.provider'; @@ -225,6 +226,7 @@ export abstract class BaseCommand { await Container.get(PostHogClient).init(); await Container.get(TelemetryEventRelay).init(); Container.get(WorkflowFailureNotificationEventRelay).init(); + Container.get(TriggerActivityEventRelay).init(); const { engine, poolSize, maxCodeCacheSize, bridgeTimeout, bridgeMemoryLimit, idleTimeout } = this.globalConfig.expressionEngine; diff --git a/packages/cli/src/events/relays/__tests__/trigger-activity.event-relay.test.ts b/packages/cli/src/events/relays/__tests__/trigger-activity.event-relay.test.ts new file mode 100644 index 00000000000..a1bfb9f9908 --- /dev/null +++ b/packages/cli/src/events/relays/__tests__/trigger-activity.event-relay.test.ts @@ -0,0 +1,52 @@ +import type { IWorkflowBase } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import { EventService } from '@/events/event.service'; +import { TriggerActivityEventRelay } from '@/events/relays/trigger-activity.event-relay'; +import type { Push } from '@/push'; + +describe('TriggerActivityEventRelay', () => { + const eventService = new EventService(); + const push = mock(); + new TriggerActivityEventRelay(eventService, push).init(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should broadcast triggerFired for trigger-mode executions', () => { + eventService.emit('workflow-pre-execute', { + executionId: 'e1', + data: { id: 'wf1' } as IWorkflowBase, + mode: 'trigger', + }); + + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'triggerFired', + data: { workflowId: 'wf1', mode: 'trigger' }, + }); + }); + + it('should broadcast triggerFired for sub-workflow executions', () => { + eventService.emit('workflow-pre-execute', { + executionId: 'e3', + data: { id: 'wf2' } as IWorkflowBase, + mode: 'integrated', + }); + + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'triggerFired', + data: { workflowId: 'wf2', mode: 'integrated' }, + }); + }); + + it('should ignore executions of other modes', () => { + eventService.emit('workflow-pre-execute', { + executionId: 'e2', + data: { id: 'wf1' } as IWorkflowBase, + mode: 'webhook', + }); + + expect(push.broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/events/relays/trigger-activity.event-relay.ts b/packages/cli/src/events/relays/trigger-activity.event-relay.ts new file mode 100644 index 00000000000..5db3838ca53 --- /dev/null +++ b/packages/cli/src/events/relays/trigger-activity.event-relay.ts @@ -0,0 +1,37 @@ +import { Service } from '@n8n/di'; + +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; +import { EventRelay } from '@/events/relays/event-relay'; +import { Push } from '@/push'; + +/** + * Broadcasts in-memory trigger activity (schedules, pollers) so the triggers + * settings view can highlight a workflow's triggers when they fire. Webhook + * activity is broadcast with node-level detail from `LiveWebhooks` instead. + */ +@Service() +export class TriggerActivityEventRelay extends EventRelay { + constructor( + eventService: EventService, + private readonly push: Push, + ) { + super(eventService); + } + + init() { + this.setupListeners({ + 'workflow-pre-execute': (event) => this.onPreExecute(event), + }); + } + + private onPreExecute({ data, mode }: RelayEventMap['workflow-pre-execute']) { + // `trigger` = schedules/pollers, `integrated` = sub-workflow calls + if (mode !== 'trigger' && mode !== 'integrated') return; + + const workflowId = 'workflowData' in data ? data.workflowData.id : data.id; + if (!workflowId) return; + + this.push.broadcast({ type: 'triggerFired', data: { workflowId, mode } }); + } +} diff --git a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts index 6e009bc73fb..2b001ea9e84 100644 --- a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts @@ -27,6 +27,7 @@ import { mock } from 'vitest-mock-extended'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import type { NodeTypes } from '@/node-types'; +import type { Push } from '@/push'; import { LiveWebhooks } from '@/webhooks/live-webhooks'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import type { WebhookService } from '@/webhooks/webhook.service'; @@ -52,6 +53,7 @@ describe('LiveWebhooks', () => { const expressionEngineConfig = mock({ allowWebhookIsolateSkip: true, }); + const push = mock(); let liveWebhooks: LiveWebhooks; @@ -68,6 +70,7 @@ describe('LiveWebhooks', () => { workflowsConfig, workflowPublishedDataService, expressionEngineConfig, + push, ); // Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues @@ -199,6 +202,16 @@ describe('LiveWebhooks', () => { expect(capturedNodes[0].id).toBe('webhook-node-active'); // The allowed-methods lookup is reserved for 404 responses expect(webhookService.getWebhookMethods).not.toHaveBeenCalled(); + + expect(push.broadcast).toHaveBeenCalledWith({ + type: 'webhookReceived', + data: { + workflowId: WORKFLOW_ID, + node: NODE_NAME, + method: httpMethod, + path: WEBHOOK_PATH, + }, + }); }); it('should look up allowed methods and include them in the 404 when no webhook is registered', async () => { diff --git a/packages/cli/src/webhooks/__tests__/webhooks.controller.test.ts b/packages/cli/src/webhooks/__tests__/webhooks.controller.test.ts new file mode 100644 index 00000000000..e755c8cd6b7 --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/webhooks.controller.test.ts @@ -0,0 +1,261 @@ +import { WebhookEntity } from '@n8n/db'; +import type { + Project, + SharedWorkflowRepository, + WorkflowDependencyRepository, + WorkflowEntity, + WorkflowRepository, +} from '@n8n/db'; +import type { IWebhookData } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import type { NodeTypes } from '@/node-types'; +import { getWorkflowWebhooks } from '@/webhooks/webhook-helpers'; +import type { WebhookService } from '@/webhooks/webhook.service'; +import { WebhooksController } from '@/webhooks/webhooks.controller'; +import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds'; + +vi.mock('@/workflow-execute-additional-data', () => ({ + getBase: vi.fn().mockResolvedValue({}), +})); +vi.mock('@/webhooks/webhook-helpers', () => ({ + getWorkflowWebhooks: vi.fn().mockReturnValue([]), +})); +vi.mock('@/workflows/triggers/trigger-kinds', () => ({ + getTriggerKinds: vi.fn().mockReturnValue(new Map()), +})); + +describe('WebhooksController', () => { + const webhookService = mock(); + const workflowRepository = mock(); + const sharedWorkflowRepository = mock(); + const workflowDependencyRepository = mock(); + const nodeTypes = mock(); + const controller = new WebhooksController( + webhookService, + workflowRepository, + sharedWorkflowRepository, + workflowDependencyRepository, + nodeTypes, + ); + + beforeEach(() => { + vi.clearAllMocks(); + webhookService.findAll.mockResolvedValue([]); + workflowRepository.findByIds.mockResolvedValue([]); + workflowRepository.findPublishedWithActiveVersionNodes.mockResolvedValue([]); + workflowRepository.findUnpublishedWithNodes.mockResolvedValue([]); + sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(new Map()); + workflowDependencyRepository.findCallersOfWorkflows.mockResolvedValue([]); + vi.mocked(getTriggerKinds).mockReturnValue(new Map()); + }); + + describe('getAll', () => { + it('should list registered webhooks with workflow names and full dynamic paths', async () => { + const staticWebhook = Object.assign(new WebhookEntity(), { + workflowId: 'wf1', + webhookPath: 'my-hook', + method: 'POST', + node: 'Webhook', + }); + const dynamicWebhook = Object.assign(new WebhookEntity(), { + workflowId: 'wf2', + webhookPath: 'user/:id', + webhookId: 'abc123', + method: 'GET', + node: 'Webhook 2', + }); + webhookService.findAll.mockResolvedValue([staticWebhook, dynamicWebhook]); + workflowRepository.findByIds.mockResolvedValue([ + { + id: 'wf1', + name: 'My Workflow', + nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }], + } as WorkflowEntity, + ]); + sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue( + new Map([ + [ + 'wf1', + { + name: 'My Project', + type: 'team', + icon: { type: 'icon', value: 'folder' }, + } as Project, + ], + ]), + ); + + const result = await controller.getAll(); + + expect(workflowRepository.findByIds).toHaveBeenCalledWith(['wf1', 'wf2'], { + fields: ['id', 'name', 'nodes'], + }); + expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).toHaveBeenCalledWith([ + 'wf1', + 'wf2', + ]); + expect(result).toEqual([ + { + kind: 'webhook', + workflowId: 'wf1', + workflowName: 'My Workflow', + method: 'POST', + path: 'my-hook', + node: 'Webhook', + nodeType: 'n8n-nodes-base.webhook', + isActive: true, + project: { name: 'My Project', type: 'team', icon: { type: 'icon', value: 'folder' } }, + calledBy: [], + }, + { + kind: 'webhook', + workflowId: 'wf2', + workflowName: undefined, + method: 'GET', + path: 'abc123/user/:id', + node: 'Webhook 2', + nodeType: undefined, + isActive: true, + project: undefined, + calledBy: [], + }, + ]); + }); + + it('should list in-memory triggers of published workflows as active', async () => { + workflowRepository.findPublishedWithActiveVersionNodes.mockResolvedValue([ + { + id: 'wf5', + name: 'Scheduled Workflow', + activeVersion: { + nodes: [ + { + id: 'n1', + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + }, + { id: 'n2', name: 'Set', type: 'n8n-nodes-base.set' }, + { + id: 'n3', + name: 'Sub-workflow Trigger', + type: 'n8n-nodes-base.executeWorkflowTrigger', + }, + ], + }, + } as unknown as WorkflowEntity, + ]); + vi.mocked(getTriggerKinds).mockReturnValue( + new Map([ + ['n1', 'in-memory'], + ['n2', 'persisted'], + ['n3', 'persisted'], + ]), + ); + // Duplicate edges: draft and published versions index separately + workflowDependencyRepository.findCallersOfWorkflows.mockResolvedValue([ + { workflowId: 'caller1', dependencyKey: 'wf5' }, + { workflowId: 'caller1', dependencyKey: 'wf5' }, + ] as never); + workflowRepository.findByIds.mockResolvedValue([ + { id: 'caller1', name: 'Caller Workflow' } as WorkflowEntity, + ]); + + const result = await controller.getAll(); + + expect(result).toEqual([ + { + kind: 'trigger', + workflowId: 'wf5', + workflowName: 'Scheduled Workflow', + node: 'Schedule Trigger', + nodeType: 'n8n-nodes-base.scheduleTrigger', + isActive: true, + project: undefined, + calledBy: [{ id: 'caller1', name: 'Caller Workflow' }], + }, + { + kind: 'trigger', + workflowId: 'wf5', + workflowName: 'Scheduled Workflow', + node: 'Sub-workflow Trigger', + nodeType: 'n8n-nodes-base.executeWorkflowTrigger', + isActive: true, + project: undefined, + calledBy: [{ id: 'caller1', name: 'Caller Workflow' }], + }, + ]); + }); + + it('should list triggers resolved from unpublished workflows as inactive', async () => { + workflowRepository.findUnpublishedWithNodes.mockResolvedValue([ + { + id: 'wf3', + name: 'Inactive Workflow', + nodes: [ + { + id: 'n1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [0, 0], + parameters: {}, + webhookId: 'hook-id', + }, + { + id: 'n2', + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + }, + ], + connections: {}, + } as unknown as WorkflowEntity, + ]); + vi.mocked(getTriggerKinds).mockReturnValue(new Map([['n2', 'in-memory']])); + vi.mocked(getWorkflowWebhooks).mockReturnValue([ + mock({ node: 'Webhook', httpMethod: 'POST', path: 'inactive-hook' }), + ]); + + const result = await controller.getAll(); + + expect(result).toEqual([ + { + kind: 'trigger', + workflowId: 'wf3', + workflowName: 'Inactive Workflow', + node: 'Schedule Trigger', + nodeType: 'n8n-nodes-base.scheduleTrigger', + isActive: false, + project: undefined, + calledBy: [], + }, + { + kind: 'webhook', + workflowId: 'wf3', + workflowName: 'Inactive Workflow', + method: 'POST', + path: 'inactive-hook', + node: 'Webhook', + nodeType: 'n8n-nodes-base.webhook', + isActive: false, + project: undefined, + calledBy: [], + }, + ]); + }); + + it('should skip unpublished workflows without trigger nodes', async () => { + workflowRepository.findUnpublishedWithNodes.mockResolvedValue([ + { + id: 'wf4', + name: 'No Triggers', + nodes: [{ id: 'n1', name: 'Set', type: 'n8n-nodes-base.set' }], + connections: {}, + } as unknown as WorkflowEntity, + ]); + + expect(await controller.getAll()).toEqual([]); + expect(getWorkflowWebhooks).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 3191b1ef1cf..dea66b371ff 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -15,6 +15,7 @@ import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8 import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { NodeTypes } from '@/node-types'; +import { Push } from '@/push'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -48,6 +49,7 @@ export class LiveWebhooks implements IWebhookManager { private readonly workflowsConfig: WorkflowsConfig, private readonly workflowPublishedDataService: WorkflowPublishedDataService, private readonly expressionEngineConfig: ExpressionEngineConfig, + private readonly push: Push, ) {} async getWebhookMethods(path: string) { @@ -167,6 +169,19 @@ export class LiveWebhooks implements IWebhookManager { throw new NotFoundError('Could not find node to process webhook.'); } + // Lets the webhooks settings view highlight a webhook when it triggers + this.push.broadcast({ + type: 'webhookReceived', + data: { + workflowId: webhook.workflowId, + node: webhook.node, + method: webhook.method, + path: webhook.isDynamic + ? `${webhook.webhookId}/${webhook.webhookPath}` + : webhook.webhookPath, + }, + }); + if (!authAllowlistedNodes.has(workflowStartNode.type)) { sanitizeWebhookRequest(request); } diff --git a/packages/cli/src/webhooks/webhooks.controller.ts b/packages/cli/src/webhooks/webhooks.controller.ts index 497aab88a01..237831ad295 100644 --- a/packages/cli/src/webhooks/webhooks.controller.ts +++ b/packages/cli/src/webhooks/webhooks.controller.ts @@ -1,13 +1,223 @@ -import { Post, RestController } from '@n8n/decorators'; +import type { WorkflowEntity } from '@n8n/db'; +import { + SharedWorkflowRepository, + WorkflowDependencyRepository, + WorkflowRepository, +} from '@n8n/db'; +import { Get, GlobalScope, Post, RestController } from '@n8n/decorators'; import { Request } from 'express'; import get from 'lodash/get'; +import type { INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, Workflow } from 'n8n-workflow'; +import { NodeTypes } from '@/node-types'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds'; + +import * as WebhookHelpers from './webhook-helpers'; import { WebhookService } from './webhook.service'; import type { Method } from './webhook.types'; +type TriggerRow = { + kind: 'webhook' | 'trigger'; + workflowId: string; + workflowName?: string; + method?: string; + path?: string; + node: string; + nodeType?: string; + isActive: boolean; +}; + @RestController('/webhooks') export class WebhooksController { - constructor(private readonly webhookService: WebhookService) {} + constructor( + private readonly webhookService: WebhookService, + private readonly workflowRepository: WorkflowRepository, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly workflowDependencyRepository: WorkflowDependencyRepository, + private readonly nodeTypes: NodeTypes, + ) {} + + @Get('/') + @GlobalScope('webhook:list') + async getAll() { + const rows = [ + ...(await this.getActiveWebhooks()), + ...(await this.getActiveInMemoryTriggers()), + ...(await this.getInactiveTriggers()), + ]; + if (rows.length === 0) return rows; + + const workflowIds = [...new Set(rows.map((row) => row.workflowId))]; + const ownerProjects = + await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(workflowIds); + const callersByCallee = await this.getCallersByCallee(workflowIds); + + return rows.map((row) => { + const project = ownerProjects.get(row.workflowId); + return { + ...row, + project: project + ? { name: project.name, type: project.type, icon: project.icon } + : undefined, + calledBy: callersByCallee.get(row.workflowId) ?? [], + }; + }); + } + + /** Workflows calling each given workflow via an Execute Workflow node. */ + private async getCallersByCallee(workflowIds: string[]) { + const edges = await this.workflowDependencyRepository.findCallersOfWorkflows(workflowIds); + const callerIds = [...new Set(edges.map((edge) => edge.workflowId))]; + const callers = await this.workflowRepository.findByIds(callerIds, { fields: ['id', 'name'] }); + const callerNames = new Map(callers.map((workflow) => [workflow.id, workflow.name])); + + // Draft and published versions index separately, so dedupe callers per callee + const callersByCallee = new Map>(); + for (const edge of edges) { + const perCallee = callersByCallee.get(edge.dependencyKey) ?? new Map(); + perCallee.set(edge.workflowId, callerNames.get(edge.workflowId) ?? edge.workflowId); + callersByCallee.set(edge.dependencyKey, perCallee); + } + + return new Map( + [...callersByCallee.entries()].map(([calleeId, perCallee]) => [ + calleeId, + [...perCallee.entries()].map(([id, name]) => ({ id, name })), + ]), + ); + } + + private async getActiveWebhooks(): Promise { + const registered = await this.webhookService.findAll(); + const workflowIds = [...new Set(registered.map((webhook) => webhook.workflowId))]; + const workflows = await this.workflowRepository.findByIds(workflowIds, { + fields: ['id', 'name', 'nodes'], + }); + const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow])); + + return registered.map((webhook) => { + const workflow = workflowsById.get(webhook.workflowId); + return { + kind: 'webhook', + workflowId: webhook.workflowId, + workflowName: workflow?.name, + method: webhook.method, + // Dynamic paths are served under the node's webhookId prefix + path: webhook.isDynamic + ? `${webhook.webhookId}/${webhook.webhookPath}` + : webhook.webhookPath, + node: webhook.node, + nodeType: workflow?.nodes.find((node) => node.name === webhook.node)?.type, + isActive: true, + }; + }); + } + + /** Schedule/polling triggers registered by published workflows. */ + private async getActiveInMemoryTriggers(): Promise { + const workflows = await this.workflowRepository.findPublishedWithActiveVersionNodes(); + return workflows.flatMap((workflow) => + this.buildInMemoryTriggerRows( + workflow.id, + workflow.name, + workflow.activeVersion?.nodes ?? [], + true, + ), + ); + } + + /** + * Triggers defined in unpublished workflows. Webhooks are resolved with the + * same helper the activation flow uses so paths match what would be + * registered; in-memory triggers (schedules, pollers) are listed directly. + */ + private async getInactiveTriggers(): Promise { + // ponytail: loads nodes of every unpublished workflow; filter in SQL if this gets slow + const workflowEntities = await this.workflowRepository.findUnpublishedWithNodes(); + + const rows: TriggerRow[] = []; + let additionalData: IWorkflowExecuteAdditionalData | undefined; + for (const entity of workflowEntities) { + rows.push(...this.buildInMemoryTriggerRows(entity.id, entity.name, entity.nodes, false)); + + if (entity.nodes.some((node) => node.webhookId && !node.disabled)) { + additionalData ??= await WorkflowExecuteAdditionalData.getBase(); + rows.push(...(await this.resolveWorkflowWebhooks(entity, additionalData))); + } + } + return rows; + } + + private buildInMemoryTriggerRows( + workflowId: string, + workflowName: string, + nodes: INode[], + isActive: boolean, + ): TriggerRow[] { + if (nodes.length === 0) return []; + + const kinds = getTriggerKinds(nodes, this.nodeTypes); + return nodes + .filter( + (node) => + !node.disabled && + // Sub-workflow triggers are pseudo-triggers ('persisted') but belong in the list + (kinds.get(node.id) === 'in-memory' || node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE), + ) + .map((node) => ({ + kind: 'trigger', + workflowId, + workflowName, + node: node.name, + nodeType: node.type, + isActive, + })); + } + + private async resolveWorkflowWebhooks( + entity: WorkflowEntity, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + try { + const workflow = new Workflow({ + id: entity.id, + name: entity.name, + nodes: entity.nodes, + connections: entity.connections, + active: false, + nodeTypes: this.nodeTypes, + }); + + // Resolving webhooks evaluates path/method expressions, which needs an isolate + const webhooks = await workflow.expression.withIsolate( + async () => + await Promise.resolve( + WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true), + ), + ); + + return webhooks.map((webhookData) => { + const node = workflow.getNode(webhookData.node); + return { + kind: 'webhook', + workflowId: entity.id, + workflowName: entity.name, + method: webhookData.httpMethod, + path: webhookData.path.includes(':') + ? `${node?.webhookId}/${webhookData.path}` + : webhookData.path, + node: webhookData.node, + nodeType: node?.type, + isActive: false, + }; + }); + } catch (error) { + // A workflow whose webhook expressions cannot resolve outside execution is skipped + return []; + } + } @Post('/find') async findWebhook(req: Request) { diff --git a/packages/cli/src/workflows/triggers/trigger-kinds.ts b/packages/cli/src/workflows/triggers/trigger-kinds.ts new file mode 100644 index 00000000000..53eea94602d --- /dev/null +++ b/packages/cli/src/workflows/triggers/trigger-kinds.ts @@ -0,0 +1,51 @@ +import type { WorkflowPublicationTriggerKind } from '@n8n/db'; +import type { INode, INodeTypes } from 'n8n-workflow'; +import { + ERROR_TRIGGER_NODE_TYPE, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + Workflow, +} from 'n8n-workflow'; + +// Their trigger() is a no-op — fired by the execution engine, never the +// registry — so reconciling them against the registry would re-enqueue forever. +const PSEUDO_TRIGGER_NODE_TYPES = new Set([ + MANUAL_TRIGGER_NODE_TYPE, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + ERROR_TRIGGER_NODE_TYPE, +]); + +/** + * Maps each node to where it lives once activated, decided by which functions + * its node type implements: nodes with a `poll` or `trigger` function register + * `in-memory`, nodes with only a `webhook` function are `persisted` rows in + * `webhook_entity`. The pseudo triggers (manual, executeWorkflow, error) are + * `persisted` despite their `trigger` function: it is a no-op fired by the + * execution engine, so the registry holds nothing worth reconciling for them. + */ +export function getTriggerKinds( + nodes: INode[], + nodeTypes: INodeTypes, +): Map { + const workflow = new Workflow({ + id: 'trigger-diff', + name: 'trigger-diff', + nodes, + connections: {}, + active: false, + nodeTypes, + }); + + const inMemoryNodeIds = new Set( + [...workflow.getPollNodes(), ...workflow.getTriggerNodes()] + .filter((node) => !PSEUDO_TRIGGER_NODE_TYPES.has(node.type)) + .map((node) => node.id), + ); + + const kinds = new Map(); + for (const node of nodes) { + kinds.set(node.id, inMemoryNodeIds.has(node.id) ? 'in-memory' : 'persisted'); + } + + return kinds; +} diff --git a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts index 7bf9357ec05..971c04b3ee3 100644 --- a/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts +++ b/packages/cli/src/workflows/triggers/workflow-trigger-activator.ts @@ -1,13 +1,11 @@ -import assert from 'node:assert/strict'; - import { Logger } from '@n8n/backend-common'; import { WorkflowsConfig } from '@n8n/config'; import type { IWorkflowDb, WorkflowEntity, WorkflowPublicationTriggerKind } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { createResultError, createResultOk, type Result } from '@n8n/utils/result'; +import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core'; import type { IConnections, INode, @@ -18,13 +16,8 @@ import type { WorkflowExecuteMode, WorkflowId, } from 'n8n-workflow'; -import { - ERROR_TRIGGER_NODE_TYPE, - EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, - MANUAL_TRIGGER_NODE_TYPE, - Workflow, - WorkflowActivationError, -} from 'n8n-workflow'; +import { Workflow, WorkflowActivationError } from 'n8n-workflow'; +import assert from 'node:assert/strict'; import { ActivationErrorsService } from '@/activation-errors.service'; import { TRIGGER_ACTIVATION_MAX_ATTEMPTS } from '@/constants'; @@ -40,19 +33,12 @@ import { NonWebhookTriggerRegistrar } from '@/workflows/triggers/non-webhook-tri import { retryTriggerActivation } from '@/workflows/triggers/trigger-activation-retry'; import { TriggerCountService } from '@/workflows/triggers/trigger-count.service'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; +import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds'; import { WebhookTriggerRegistrar } from '@/workflows/triggers/webhook-trigger-registrar'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; export type WorkflowTriggerVersion = { nodes: INode[]; connections: IConnections }; -// Their trigger() is a no-op — fired by the execution engine, never the -// registry — so reconciling them against the registry would re-enqueue forever. -const PSEUDO_TRIGGER_NODE_TYPES = new Set([ - MANUAL_TRIGGER_NODE_TYPE, - EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, - ERROR_TRIGGER_NODE_TYPE, -]); - /** A single trigger node that failed to (de)register during activation. */ export type TriggerActivationFailure = { nodeId: INode['id']; @@ -124,37 +110,11 @@ export class WorkflowTriggerActivator { } /** - * Maps each node to where it lives once activated, decided by which functions - * its node type implements: nodes with a `poll` or `trigger` function register - * `in-memory`, nodes with only a `webhook` function are `persisted` rows in - * `webhook_entity`. The pseudo triggers (manual, executeWorkflow, error) are - * `persisted` despite their `trigger` function: it is a no-op fired by the - * execution engine, so the registry holds nothing worth reconciling for them. - * Used by reconciliation to tell which triggers should be in the in-memory - * registry. + * See {@link getTriggerKinds}. Used by reconciliation to tell which triggers + * should be in the in-memory registry. */ getTriggerKinds(nodes: INode[]): Map { - const workflow = new Workflow({ - id: 'trigger-diff', - name: 'trigger-diff', - nodes, - connections: {}, - active: false, - nodeTypes: this.nodeTypes, - }); - - const inMemoryNodeIds = new Set( - [...workflow.getPollNodes(), ...workflow.getTriggerNodes()] - .filter((node) => !PSEUDO_TRIGGER_NODE_TYPES.has(node.type)) - .map((node) => node.id), - ); - - const kinds = new Map(); - for (const node of nodes) { - kinds.set(node.id, inMemoryNodeIds.has(node.id) ? 'in-memory' : 'persisted'); - } - - return kinds; + return getTriggerKinds(nodes, this.nodeTypes); } /** diff --git a/packages/frontend/@n8n/frontend-constants/src/views.ts b/packages/frontend/@n8n/frontend-constants/src/views.ts index 9e45d2cd6d5..4cd589d7737 100644 --- a/packages/frontend/@n8n/frontend-constants/src/views.ts +++ b/packages/frontend/@n8n/frontend-constants/src/views.ts @@ -59,6 +59,7 @@ export enum VIEWS { OPENTELEMETRY_SETTINGS = 'SettingsOpenTelemetryView', SSO_SETTINGS = 'SSoSettings', ENCRYPTION_KEYS_SETTINGS = 'EncryptionKeysSettings', + WEBHOOKS_SETTINGS = 'WebhooksSettings', EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings', SAML_ONBOARDING = 'SamlOnboarding', SOURCE_CONTROL = 'SourceControl', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 759282cae48..511912126a7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5572,6 +5572,25 @@ "settings.encryptionKeys.empty.title": "No encryption keys yet", "settings.encryptionKeys.empty.description": "Rotate a key to generate your first active encryption key.", "settings.encryptionKeys.loadError": "Could not load encryption keys", + "settings.webhooks": "Triggers", + "settings.webhooks.title": "Triggers", + "settings.webhooks.description": "All triggers on this instance — webhook endpoints, schedules and pollers, from both active and inactive workflows.", + "settings.webhooks.search.placeholder": "Search triggers...", + "settings.webhooks.filter.nodeType": "All node types", + "settings.webhooks.section.active": "Active", + "settings.webhooks.section.inactive": "Inactive", + "settings.webhooks.column.method": "Method", + "settings.webhooks.column.path": "Path", + "settings.webhooks.column.workflow": "Workflow", + "settings.webhooks.column.project": "Project", + "settings.webhooks.column.node": "Node", + "settings.webhooks.column.calls": "Calls", + "settings.webhooks.column.calledBy": "Called by", + "settings.webhooks.copy.tooltip": "Copy webhook URL", + "settings.webhooks.copy.success": "Webhook URL copied", + "settings.webhooks.empty.title": "No triggers", + "settings.webhooks.empty.description": "Triggers appear here once workflows contain webhook, schedule or polling trigger nodes.", + "settings.webhooks.loadError": "Could not load triggers", "settings.sso": "SSO", "settings.sso.title": "Single Sign-On", "settings.sso.subtitle": "SAML 2.0 Configuration", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts b/packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts index db224272ae2..56d726cc55f 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/webhooks.ts @@ -10,9 +10,30 @@ type WebhookData = { node: string; }; +export type InstanceWebhook = { + kind: 'webhook' | 'trigger'; + workflowId: string; + workflowName?: string; + method?: IHttpRequestMethods; + path?: string; + node: string; + nodeType?: string; + isActive: boolean; + project?: { + name: string; + type: 'personal' | 'team'; + icon: { type: 'emoji' | 'icon'; value: string } | null; + }; + calledBy: Array<{ id: string; name: string }>; +}; + export const findWebhook = async ( context: IRestApiContext, data: { path: string; method: string }, ): Promise => { return await makeRestApiRequest(context, 'POST', '/webhooks/find', data); }; + +export const getInstanceWebhooks = async (context: IRestApiContext): Promise => { + return await makeRestApiRequest(context, 'GET', '/webhooks'); +}; diff --git a/packages/frontend/@n8n/stores/src/rbac.store.ts b/packages/frontend/@n8n/stores/src/rbac.store.ts index 8f716102b8f..54a45a51939 100644 --- a/packages/frontend/@n8n/stores/src/rbac.store.ts +++ b/packages/frontend/@n8n/stores/src/rbac.store.ts @@ -27,6 +27,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { externalSecret: {}, project: {}, orchestration: {}, + webhook: {}, workersView: {}, eventBusDestination: {}, auditLogs: {}, diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index f7f9ec20d12..d3e40184292 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -147,6 +147,14 @@ export function useSettingsItems() { available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS), route: { to: { name: VIEWS.LDAP_SETTINGS } }, }, + { + id: 'settings-webhooks', + icon: 'webhook', + label: i18n.baseText('settings.webhooks'), + position: 'top', + available: canUserAccessRouteByName(VIEWS.WEBHOOKS_SETTINGS), + route: { to: { name: VIEWS.WEBHOOKS_SETTINGS } }, + }, { id: 'settings-workersview', icon: 'waypoints', diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index b60f666569e..f4d1a4de3c8 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -79,6 +79,8 @@ const SettingsUsageAndPlan = async () => const SettingsSso = async () => await import('@/features/settings/sso/views/SettingsSso.vue'); const SettingsEncryptionKeys = async () => await import('@/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue'); +const SettingsWebhooksView = async () => + await import('@/features/settings/webhooks/views/SettingsWebhooksView.vue'); const SignoutView = async () => await import('@/features/core/auth/views/SignoutView.vue'); const SamlOnboarding = async () => await import('@/features/settings/sso/views/SamlOnboarding.vue'); const SettingsSourceControl = async () => @@ -1065,6 +1067,22 @@ export const routes: RouteRecordRaw[] = [ }, }, }, + { + path: 'webhooks', + name: VIEWS.WEBHOOKS_SETTINGS, + component: SettingsWebhooksView, + meta: { + middleware: ['authenticated', 'rbac'], + middlewareOptions: { + rbac: { + scope: 'webhook:list', + }, + }, + telemetry: { + pageCategory: 'settings', + }, + }, + }, { path: 'workers', name: VIEWS.WORKER_VIEW, diff --git a/packages/frontend/editor-ui/src/features/settings/webhooks/views/SettingsWebhooksView.vue b/packages/frontend/editor-ui/src/features/settings/webhooks/views/SettingsWebhooksView.vue new file mode 100644 index 00000000000..8de15ab983f --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/webhooks/views/SettingsWebhooksView.vue @@ -0,0 +1,475 @@ + + + + +