From 32fb2405e588115fee9decfd602dd12378518d92 Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk Date: Tue, 4 Aug 2026 20:07:55 +0200 Subject: [PATCH] feat(core): Show a person their own catalog runs Adds GET /catalog/runs, selected by the marker the run service writes rather than by workflow, so someone sharing a workflow with colleagues sees only what they started themselves. Each entry is projected down from the execution summary. The summary also carries lastNodeExecuted, per-node statuses and error details, which would hand node names to someone who was given execute but not read. Moves the route prefix to /catalog so runs are not addressed as a sub-resource of workflows. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/catalog.service.test.ts | 73 ++++++++++++++++++- .../cli/src/workflows/catalog.controller.ts | 16 +++- packages/cli/src/workflows/catalog.service.ts | 56 +++++++++++++- 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/workflows/__tests__/catalog.service.test.ts b/packages/cli/src/workflows/__tests__/catalog.service.test.ts index eb97ecc82dc..29affddc4dc 100644 --- a/packages/cli/src/workflows/__tests__/catalog.service.test.ts +++ b/packages/cli/src/workflows/__tests__/catalog.service.test.ts @@ -2,7 +2,13 @@ import type { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; -import { CATALOG_LIST_LIMIT, CatalogService } from '@/workflows/catalog.service'; +import type { ExecutionService } from '@/executions/execution.service'; +import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service'; +import { + CATALOG_LIST_LIMIT, + CATALOG_RUNS_LIMIT, + CatalogService, +} from '@/workflows/catalog.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service'; @@ -19,15 +25,18 @@ describe('CatalogService', () => { let logger: ReturnType>; let finder: ReturnType>; let schemas: ReturnType>; + let executions: ReturnType>; beforeEach(() => { logger = mock(); finder = mock(); schemas = mock(); + executions = mock(); schemas.describe.mockResolvedValue({ eligible: true, fields: [] }); + executions.findRangeWithCount.mockResolvedValue({ results: [], count: 0, estimated: false }); - service = new CatalogService(logger, finder, schemas); + service = new CatalogService(logger, finder, schemas, executions); }); it('should list only workflows the user may execute', async () => { @@ -95,4 +104,64 @@ describe('CatalogService', () => { expect(result.workflows).toHaveLength(CATALOG_LIST_LIMIT); expect(logger.warn).toHaveBeenCalled(); }); + + describe('listRuns', () => { + it('should select only runs the person started themselves', async () => { + await service.listRuns(user); + + expect(executions.findRangeWithCount).toHaveBeenCalledWith( + expect.objectContaining({ + user, + metadata: [{ key: CATALOG_RUN_USER_KEY, value: 'user-1', exactMatch: true }], + range: { limit: CATALOG_RUNS_LIMIT }, + order: { startedAt: 'DESC' }, + }), + ); + }); + + it('should scope visibility to workflows the person may execute', async () => { + executions.buildSharingOptions.mockResolvedValue({ scopes: ['workflow:execute'] }); + + await service.listRuns(user); + + expect(executions.buildSharingOptions).toHaveBeenCalledWith('workflow:execute'); + expect(executions.findRangeWithCount).toHaveBeenCalledWith( + expect.objectContaining({ sharingOptions: { scopes: ['workflow:execute'] } }), + ); + }); + + it('should not expose node names through the run summary', async () => { + executions.findRangeWithCount.mockResolvedValue({ + results: [ + { + id: 'exec-1', + workflowId: 'wf-1', + workflowName: 'Weekly report', + status: 'success', + startedAt: new Date('2026-01-01'), + stoppedAt: new Date('2026-01-02'), + mode: 'trigger', + createdAt: new Date('2026-01-01'), + lastNodeExecuted: 'Send Email', + }, + ], + count: 1, + estimated: false, + }); + + const result = await service.listRuns(user); + + expect(result.runs).toEqual([ + { + id: 'exec-1', + workflowId: 'wf-1', + workflowName: 'Weekly report', + status: 'success', + startedAt: new Date('2026-01-01'), + stoppedAt: new Date('2026-01-02'), + }, + ]); + expect(result).toEqual(expect.objectContaining({ count: 1, estimated: false })); + }); + }); }); diff --git a/packages/cli/src/workflows/catalog.controller.ts b/packages/cli/src/workflows/catalog.controller.ts index 91b61195af4..ea45a0073c5 100644 --- a/packages/cli/src/workflows/catalog.controller.ts +++ b/packages/cli/src/workflows/catalog.controller.ts @@ -18,7 +18,7 @@ type WorkflowParam = { workflowId: string }; * 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') +@RestController('/catalog') export class CatalogController { constructor( private readonly catalogService: CatalogService, @@ -30,13 +30,23 @@ export class CatalogController { * Scoped globally rather than per project: the listing spans every project * the person belongs to, and the per-workflow filtering happens in the query. */ - @Get('/') + @Get('/workflows') @GlobalScope('workflow:list') async list(req: AuthenticatedRequest) { return await this.catalogService.list(req.user); } - @Post('/:workflowId/run') + /** + * Scoped to executions the person started themselves, by the marker the run + * service writes — not to the workflows they can reach. + */ + @Get('/runs') + @GlobalScope('workflow:list') + async listRuns(req: AuthenticatedRequest) { + return await this.catalogService.listRuns(req.user); + } + + @Post('/workflows/:workflowId/run') @ProjectScope('workflow:execute') async run(req: AuthenticatedRequest, _res: unknown, @Body payload: CatalogRunDto) { const workflow = await this.workflowFinderService.findWorkflowForUser( diff --git a/packages/cli/src/workflows/catalog.service.ts b/packages/cli/src/workflows/catalog.service.ts index 438edb75ac7..45ae5f12f9f 100644 --- a/packages/cli/src/workflows/catalog.service.ts +++ b/packages/cli/src/workflows/catalog.service.ts @@ -1,8 +1,10 @@ import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { FieldValueOption } from 'n8n-workflow'; +import type { ExecutionStatus, FieldValueOption } from 'n8n-workflow'; +import { ExecutionService } from '@/executions/execution.service'; +import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service'; @@ -26,12 +28,31 @@ export type CatalogListing = { truncated: boolean; }; +/** How many past runs one page of history returns. */ +export const CATALOG_RUNS_LIMIT = 50; + +export type CatalogRun = { + id: string; + workflowId: string; + workflowName?: string; + status: ExecutionStatus; + startedAt: Date | null; + stoppedAt?: Date; +}; + +export type CatalogRunListing = { + runs: CatalogRun[]; + count: number; + estimated: boolean; +}; + @Service() export class CatalogService { constructor( private readonly logger: Logger, private readonly workflowFinderService: WorkflowFinderService, private readonly workflowInputSchemaService: WorkflowInputSchemaService, + private readonly executionService: ExecutionService, ) {} /** @@ -82,4 +103,37 @@ export class CatalogService { truncated, }; } + + /** + * A person's own catalog runs, most recent first. + * + * Scoped by the run marker rather than by workflow, so someone sharing a + * workflow with colleagues sees only what they themselves started. + */ + async listRuns(user: User): Promise { + const sharingOptions = await this.executionService.buildSharingOptions('workflow:execute'); + + const { results, count, estimated } = await this.executionService.findRangeWithCount({ + kind: 'range', + user, + sharingOptions, + metadata: [{ key: CATALOG_RUN_USER_KEY, value: user.id, exactMatch: true }], + range: { limit: CATALOG_RUNS_LIMIT }, + order: { startedAt: 'DESC' }, + }); + + // Projected down from the execution summary on purpose: it also carries + // `lastNodeExecuted`, per-node statuses and error details, which would + // hand node names to someone who cannot read the workflow. + const runs = results.map(({ id, workflowId, workflowName, status, startedAt, stoppedAt }) => ({ + id, + workflowId, + workflowName, + status, + startedAt, + stoppedAt, + })); + + return { runs, count, estimated }; + } }