From f78b151d7f2974f5eb4aa16ae413b17752ad8eb6 Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk Date: Tue, 4 Aug 2026 16:26:53 +0200 Subject: [PATCH] feat(core): Run a workflow from the catalog without opening the editor Adds POST /catalog/workflows/:id/run for people who were granted execute but not read: the endpoint never returns or requires the graph, and @ProjectScope rejects before the handler. The run is a production one even though a person pressed the button. A manual run would honour a workflow-level "don't save manual executions" and silently erase their history, and would feed them the builder's pinned test data instead of a real result. Values not named by the workflow's declared contract are dropped rather than passed through, and the execution is tagged with the person who ran it so "my runs" is a filter over existing execution storage rather than a new column. Start-node precedence is extracted from findWorkflowStart rather than repeated, so a catalog run enters a workflow through the same node a sub-workflow call would. Co-Authored-By: Claude Opus 5 (1M context) --- packages/@n8n/api-types/src/dto/index.ts | 1 + .../src/dto/workflows/catalog-run.dto.ts | 14 ++ packages/cli/src/server.ts | 1 + packages/cli/src/utils.ts | 23 +++- .../__tests__/catalog-run.service.test.ts | 129 ++++++++++++++++++ .../cli/src/workflows/catalog-run.service.ts | 79 +++++++++++ .../cli/src/workflows/catalog.controller.ts | 46 +++++++ 7 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts create mode 100644 packages/cli/src/workflows/__tests__/catalog-run.service.test.ts create mode 100644 packages/cli/src/workflows/catalog-run.service.ts create mode 100644 packages/cli/src/workflows/catalog.controller.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 7ed6f17bace..37c5d4f22f7 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -118,6 +118,7 @@ export { } from './workflows/base-workflow.dto'; export { CreateWorkflowDto } from './workflows/create-workflow.dto'; export { UpdateWorkflowDto } from './workflows/update-workflow.dto'; +export { CatalogRunDto } from './workflows/catalog-run.dto'; export { ManualRunDto, type ManualRunPayload, diff --git a/packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts b/packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts new file mode 100644 index 00000000000..0d3353ee40f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/catalog-run.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +/** + * Values a person supplies when running a workflow from the catalog. + * + * Left as an open record: the accepted keys are whatever the workflow declares + * on its trigger, so they cannot be known here. The run service drops anything + * the contract does not name. + */ +export class CatalogRunDto extends Z.class({ + inputs: z.record(z.string(), z.unknown()).optional(), +}) {} diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 44f89aeffb8..23dc5d22b38 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -65,6 +65,7 @@ import '@/evaluation.ee/test-runs.controller.ee'; import '@/evaluation.ee/evaluation-config.controller'; import '@/evaluation.ee/evaluation-collections.controller.ee'; import '@/evaluation.ee/insights/eval-insights.controller.ee'; +import '@/workflows/catalog.controller'; import '@/workflows/workflow-history/workflow-history.controller'; import '@/workflows/workflows.controller'; import '@/modules/workflow-index/workflow-dependency.controller'; diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index a8c50f3be15..d17dcba09aa 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -29,15 +29,24 @@ export function satisfiesToolCapability(syntheticToolName: string, nodeType: INo return isHitlToolType(syntheticToolName) || !!nodeType.description.usableAsTool; } +/** + * The node a caller starts a workflow from, or `undefined` when it declares + * none. A trigger that names its inputs wins over a bare start node, so a + * workflow offering an input contract is entered through it wherever it is run. + */ +export function resolveWorkflowStart(nodes: INode[]): INode | undefined { + const executeWorkflowTriggerNode = nodes.find( + (node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger', + ); + + if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode; + + return nodes.find((node) => STARTING_NODES.includes(node.type)); +} + function findWorkflowStart(executionMode: 'integrated' | 'cli') { return function (nodes: INode[]) { - const executeWorkflowTriggerNode = nodes.find( - (node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger', - ); - - if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode; - - const startNode = nodes.find((node) => STARTING_NODES.includes(node.type)); + const startNode = resolveWorkflowStart(nodes); if (startNode) return startNode; diff --git a/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts b/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts new file mode 100644 index 00000000000..f51b1867dd2 --- /dev/null +++ b/packages/cli/src/workflows/__tests__/catalog-run.service.test.ts @@ -0,0 +1,129 @@ +import type { User } from '@n8n/db'; +import type { INode, IWorkflowBase } from 'n8n-workflow'; +import { + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + MANUAL_TRIGGER_NODE_TYPE, + UserError, +} from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import type { ExecutionMetadataService } from '@/services/execution-metadata.service'; +import { CATALOG_RUN_USER_KEY, CatalogRunService } from '@/workflows/catalog-run.service'; +import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import type { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service'; + +vi.mock('@/workflow-execute-additional-data', () => ({ + getBase: vi.fn(async () => ({ userId: 'user-1' })), +})); + +const node = (type: string, disabled = false): INode => + ({ + id: `${type}-id`, + name: type, + type, + typeVersion: 1.2, + position: [0, 0], + parameters: {}, + disabled, + }) as INode; + +const workflow = (nodes: INode[]): IWorkflowBase => + ({ + id: 'workflow-1', + name: 'Report', + nodes, + connections: {}, + active: false, + settings: {}, + }) as IWorkflowBase; + +describe('CatalogRunService', () => { + const user = mock({ id: 'user-1' }); + + let service: CatalogRunService; + let executions: ReturnType>; + let schemas: ReturnType>; + let metadata: ReturnType>; + + beforeEach(() => { + executions = mock(); + schemas = mock(); + metadata = mock(); + + executions.runWorkflow.mockResolvedValue('execution-1'); + schemas.describe.mockResolvedValue({ + eligible: true, + fields: [{ name: 'customer', type: 'string' }], + }); + + service = new CatalogRunService(executions, schemas, metadata); + }); + + it('should start from the trigger that declares the contract', async () => { + const wf = workflow([node(MANUAL_TRIGGER_NODE_TYPE), node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]); + + await service.run(wf, user, { customer: 'Acme Corp' }); + + const [, startNode] = executions.runWorkflow.mock.calls[0]; + expect(startNode.type).toBe(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE); + }); + + it('should run in production mode, not manual', async () => { + // A manual run would honour "don't save manual executions" and apply the + // builder's pinned data, both of which break the run history people see. + await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user); + + const [, , , , mode] = executions.runWorkflow.mock.calls[0]; + expect(mode).toBe('trigger'); + }); + + it('should pass declared inputs through as the first item', async () => { + await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user, { + customer: 'Acme Corp', + }); + + const [, , data] = executions.runWorkflow.mock.calls[0]; + expect(data).toEqual([[{ json: { customer: 'Acme Corp' } }]]); + }); + + it('should drop values the contract does not declare', async () => { + await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user, { + customer: 'Acme Corp', + smuggled: 'value', + }); + + const [, , data] = executions.runWorkflow.mock.calls[0]; + expect(data).toEqual([[{ json: { customer: 'Acme Corp' } }]]); + }); + + it('should tag the execution with the person who ran it', async () => { + const result = await service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user); + + expect(result).toEqual({ executionId: 'execution-1' }); + expect(metadata.save).toHaveBeenCalledWith('execution-1', { + [CATALOG_RUN_USER_KEY]: 'user-1', + }); + }); + + it('should refuse a workflow the catalog does not offer', async () => { + schemas.describe.mockResolvedValue({ eligible: false, reason: 'own-schedule' }); + + await expect( + service.run(workflow([node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE)]), user), + ).rejects.toThrow(UserError); + + expect(executions.runWorkflow).not.toHaveBeenCalled(); + }); + + it('should ignore a disabled trigger when choosing the start node', async () => { + const wf = workflow([ + node(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, true), + node(MANUAL_TRIGGER_NODE_TYPE), + ]); + + await service.run(wf, user); + + const [, startNode] = executions.runWorkflow.mock.calls[0]; + expect(startNode.type).toBe(MANUAL_TRIGGER_NODE_TYPE); + }); +}); diff --git a/packages/cli/src/workflows/catalog-run.service.ts b/packages/cli/src/workflows/catalog-run.service.ts new file mode 100644 index 00000000000..a108463132d --- /dev/null +++ b/packages/cli/src/workflows/catalog-run.service.ts @@ -0,0 +1,79 @@ +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import type { IDataObject, IWorkflowBase } from 'n8n-workflow'; +import { UserError } from 'n8n-workflow'; + +import { ExecutionMetadataService } from '@/services/execution-metadata.service'; +import { resolveWorkflowStart } from '@/utils'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service'; + +/** Marks an execution as belonging to a person's catalog run rather than the workflow's own. */ +export const CATALOG_RUN_USER_KEY = 'catalogRunUserId'; + +@Service() +export class CatalogRunService { + constructor( + private readonly workflowExecutionService: WorkflowExecutionService, + private readonly workflowInputSchemaService: WorkflowInputSchemaService, + private readonly executionMetadataService: ExecutionMetadataService, + ) {} + + /** + * Runs a workflow for a person who is present, from the trigger it declares + * its input contract on. + * + * Deliberately a production run, not a manual one. A manual run would let a + * workflow-level "don't save manual executions" setting silently erase the + * person's history, and would feed them the builder's pinned test data + * instead of a real result. + */ + async run( + workflowData: IWorkflowBase, + user: User, + inputs: IDataObject = {}, + ): Promise<{ executionId: string }> { + const schema = await this.workflowInputSchemaService.describe(workflowData); + + if (!schema.eligible) { + throw new UserError('This workflow cannot be run directly', { + extra: { workflowId: workflowData.id, reason: schema.reason }, + }); + } + + const startNode = resolveWorkflowStart(workflowData.nodes.filter((node) => !node.disabled)); + + if (!startNode) { + // describe() already vetted this; a miss here means the two disagree. + throw new UserError('This workflow has no node to start from', { + extra: { workflowId: workflowData.id }, + }); + } + + const declared = new Set(schema.fields.map((field) => field.name)); + const json = Object.fromEntries(Object.entries(inputs).filter(([name]) => declared.has(name))); + + const additionalData = await WorkflowExecuteAdditionalData.getBase({ + userId: user.id, + workflowId: workflowData.id, + workflowSettings: workflowData.settings, + }); + + const executionId = await this.workflowExecutionService.runWorkflow( + workflowData, + startNode, + [[{ json }]], + additionalData, + 'trigger', + ); + + // Attribution rides on metadata rather than a column, so "my runs" is a + // filter over existing execution storage. + await this.executionMetadataService.save(executionId, { + [CATALOG_RUN_USER_KEY]: user.id, + }); + + return { executionId }; + } +} diff --git a/packages/cli/src/workflows/catalog.controller.ts b/packages/cli/src/workflows/catalog.controller.ts new file mode 100644 index 00000000000..1f4669b9ad1 --- /dev/null +++ b/packages/cli/src/workflows/catalog.controller.ts @@ -0,0 +1,46 @@ +import { CatalogRunDto } from '@n8n/api-types'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Body, Post, ProjectScope, RestController } from '@n8n/decorators'; +import type { IDataObject } from 'n8n-workflow'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { CatalogRunService } from '@/workflows/catalog-run.service'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +type WorkflowParam = { workflowId: string }; + +/** + * Running a workflow you were given access to, without opening the editor. + * + * `@ProjectScope` resolves workflow → project from `:workflowId` and rejects + * before the handler, so someone with execute but not read never reaches it — + * which is the point: the catalog is for people who run workflows, not build + * them, and it never hands out the graph. + */ +@RestController('/catalog/workflows') +export class CatalogController { + constructor( + private readonly catalogRunService: CatalogRunService, + private readonly workflowFinderService: WorkflowFinderService, + ) {} + + @Post('/:workflowId/run') + @ProjectScope('workflow:execute') + async run(req: AuthenticatedRequest, _res: unknown, @Body payload: CatalogRunDto) { + const workflow = await this.workflowFinderService.findWorkflowForUser( + req.params.workflowId, + req.user, + ['workflow:execute'], + ); + + if (!workflow) { + throw new NotFoundError('Could not find workflow'); + } + + return await this.catalogRunService.run( + workflow, + req.user, + (payload.inputs ?? {}) as IDataObject, + ); + } +}