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, + ); + } +}