From ca56b6b90ae1db4d30850dfd33003c54fb1057d1 Mon Sep 17 00:00:00 2001 From: James Gee <1285296+geemanjs@users.noreply.github.com> Date: Fri, 22 May 2026 22:53:58 +0200 Subject: [PATCH] feat(core): Package workflow export (#30641) --- packages/@n8n/api-types/src/dto/index.ts | 2 + .../export-workflows-request.dto.test.ts | 27 ++ .../packages/export-workflows-request.dto.ts | 7 + .../modules/__tests__/module-registry.test.ts | 2 + .../src/modules/module-registry.ts | 1 + .../src/modules/modules.config.ts | 1 + .../config/src/configs/public-api.config.ts | 7 + packages/@n8n/config/test/config.test.ts | 1 + packages/@n8n/constants/src/index.ts | 1 + .../scope-information.test.ts.snap | 1 + packages/@n8n/permissions/src/constants.ee.ts | 3 +- .../src/public-api-permissions.ee.ts | 3 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../src/roles/scopes/project-scopes.ee.ts | 4 + .../scopes/workflow-sharing-scopes.ee.ts | 2 + .../@n8n/permissions/src/scope-information.ts | 4 + packages/cli/package.json | 1 + .../cli/src/controllers/e2e.controller.ts | 1 + .../export-workflow.controller.test.ts | 60 ++++ .../export-workflow.integration.test.ts | 290 ++++++++++++++++++ .../__tests__/utils/tar-support.ts | 45 +++ .../__tests__/workflow.exporter.test.ts | 119 +++++++ .../entities/workflow/workflow.exporter.ts | 93 ++++++ .../entities/workflow/workflow.serializer.ts | 24 ++ .../io/__tests__/slug.utils.test.ts | 23 ++ .../io/__tests__/tar-package-writer.test.ts | 96 ++++++ .../modules/n8n-packages/io/package-writer.ts | 7 + .../src/modules/n8n-packages/io/slug.utils.ts | 22 ++ .../n8n-packages/io/tar/tar-package-writer.ts | 85 +++++ .../n8n-packages/n8n-packages.controller.ts | 28 ++ .../n8n-packages/n8n-packages.module.ts | 13 + .../n8n-packages/n8n-packages.service.ts | 40 +++ .../n8n-packages/n8n-packages.types.ts | 6 + .../modules/n8n-packages/spec/constants.ts | 1 + .../n8n-packages/spec/manifest.schema.ts | 18 ++ .../spec/serialized/workflow.schema.ts | 53 ++++ .../n8n-packages/n8n-packages.handler.ts | 56 ++++ .../spec/paths/n8n-packages.export.yml | 36 +++ .../spec/schemas/exportWorkflowsRequest.yml | 15 + packages/cli/src/public-api/v1/openapi.yml | 4 + .../src/workflows/workflow-finder.service.ts | 28 +- .../workflows/workflows.controller.test.ts | 13 + .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../components/RoleHoverPopover.test.ts | 2 +- .../project-roles/projectRoleScopes.ts | 1 + .../nodes/N8n/n8n-api-coverage.json | 3 + packages/workflow/src/interfaces.ts | 1 - pnpm-lock.yaml | 3 + 48 files changed, 1252 insertions(+), 4 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/packages/__tests__/export-workflows-request.dto.test.ts create mode 100644 packages/@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/utils/tar-support.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-writer.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/package-writer.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/slug.utils.ts create mode 100644 packages/cli/src/modules/n8n-packages/io/tar/tar-package-writer.ts create mode 100644 packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts create mode 100644 packages/cli/src/modules/n8n-packages/n8n-packages.module.ts create mode 100644 packages/cli/src/modules/n8n-packages/n8n-packages.service.ts create mode 100644 packages/cli/src/modules/n8n-packages/n8n-packages.types.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/constants.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/serialized/workflow.schema.ts create mode 100644 packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts create mode 100644 packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml create mode 100644 packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportWorkflowsRequest.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index e2414e03c3b..4b74cb7e16a 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -201,6 +201,8 @@ export { export { DownloadDataTableCsvQueryDto } from './data-table/download-data-table-csv-query.dto'; export { ImportCsvToDataTableDto } from './data-table/import-csv-to-data-table.dto'; +export { ExportWorkflowsRequestDto } from './packages/export-workflows-request.dto'; + export * from './evaluations'; export { diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/export-workflows-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/export-workflows-request.dto.test.ts new file mode 100644 index 00000000000..dc59100dcf0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/export-workflows-request.dto.test.ts @@ -0,0 +1,27 @@ +import { ExportWorkflowsRequestDto } from '../export-workflows-request.dto'; + +describe('ExportWorkflowsRequestDto', () => { + it('accepts a non-empty array of workflow ids', () => { + const result = ExportWorkflowsRequestDto.safeParse({ workflowIds: ['wf-1', 'wf-2'] }); + expect(result.success).toBe(true); + }); + + it('accepts up to 300 workflow ids', () => { + const workflowIds = Array.from({ length: 300 }, (_, i) => `wf-${i}`); + expect(ExportWorkflowsRequestDto.safeParse({ workflowIds }).success).toBe(true); + }); + + it.each([ + { name: 'missing workflowIds', request: {} }, + { name: 'empty workflowIds array', request: { workflowIds: [] } }, + { name: 'empty-string id', request: { workflowIds: [''] } }, + { name: 'whitespace-only id', request: { workflowIds: [' '] } }, + { name: 'non-string id', request: { workflowIds: [123] } }, + { + name: 'more than 300 workflow ids', + request: { workflowIds: Array.from({ length: 301 }, (_, i) => `wf-${i}`) }, + }, + ])('rejects $name', ({ request }) => { + expect(ExportWorkflowsRequestDto.safeParse(request).success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts new file mode 100644 index 00000000000..8ff260fff6e --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class ExportWorkflowsRequestDto extends Z.class({ + workflowIds: z.array(z.string().trim().min(1)).min(1).max(300), +}) {} diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index d21be95facf..82ac42cf602 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -44,6 +44,7 @@ describe('eligibleModules', () => { 'instance-version-history', 'encryption-key-manager', 'oauth-jwe', + 'n8n-packages', 'runtime-credentials', 'mcp-registry', ]); @@ -76,6 +77,7 @@ describe('eligibleModules', () => { 'instance-version-history', 'encryption-key-manager', 'oauth-jwe', + 'n8n-packages', 'runtime-credentials', 'mcp-registry', 'instance-ai', diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index c08cc56056b..ef256619ca4 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -55,6 +55,7 @@ export class ModuleRegistry { 'instance-version-history', 'encryption-key-manager', 'oauth-jwe', + 'n8n-packages', 'runtime-credentials', 'mcp-registry', ]; diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index ec54fa5624e..a8130f61ac4 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -31,6 +31,7 @@ export const MODULE_NAMES = [ 'encryption-key-manager', 'oauth-jwe', 'runtime-credentials', + 'n8n-packages', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/@n8n/config/src/configs/public-api.config.ts b/packages/@n8n/config/src/configs/public-api.config.ts index e944db7dac3..06f0f08d685 100644 --- a/packages/@n8n/config/src/configs/public-api.config.ts +++ b/packages/@n8n/config/src/configs/public-api.config.ts @@ -13,4 +13,11 @@ export class PublicApiConfig { /** When true, the Swagger UI for the Public API is not served. */ @Env('N8N_PUBLIC_API_SWAGGERUI_DISABLED') swaggerUiDisabled: boolean = false; + + /** + * When true, the n8n-packages public API endpoints are enabled. This feature is + * currently in beta and disabled by default. + */ + @Env('N8N_PUBLIC_API_PACKAGES_ENABLED') + packagesEnabled: boolean = false; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 48ad82c79b7..7b59abf6686 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -183,6 +183,7 @@ describe('GlobalConfig', () => { disabled: false, path: 'api', swaggerUiDisabled: false, + packagesEnabled: false, }, templates: { enabled: true, diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 2639aac9f95..2af7b197f4a 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -44,6 +44,7 @@ export const LICENSE_FEATURES = { PERSONAL_SPACE_POLICY: 'feat:personalSpacePolicy', TOKEN_EXCHANGE: 'feat:tokenExchange', DATA_REDACTION: 'feat:dataRedaction', + N8N_PACKAGES: 'feat:n8nPackages', } as const; export const LICENSE_QUOTAS = { 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 cc19af3b9f7..b1cc941053b 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 @@ -118,6 +118,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "workflow:unshare", "workflow:execute", "workflow:execute-chat", + "workflow:export", "workflow:move", "workflow:activate", "workflow:deactivate", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 2017dbac74d..d3f7015cdd7 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -37,6 +37,7 @@ export const RESOURCES = { 'unshare', 'execute', 'execute-chat', + 'export', 'move', 'activate', 'deactivate', @@ -74,7 +75,7 @@ export const RESOURCES = { export const API_KEY_RESOURCES = { tag: [...DEFAULT_OPERATIONS] as const, - workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate'] as const, + workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate', 'export'] as const, variable: ['create', 'update', 'delete', 'list'] as const, securityAudit: ['generate'] as const, project: ['create', 'update', 'delete', 'list'] as const, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 51e144081a0..5b5a95c8a13 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -36,6 +36,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'workflow:read', 'workflow:update', 'workflow:delete', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:activate', @@ -87,6 +88,7 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'workflow:read', 'workflow:update', 'workflow:delete', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:activate', @@ -138,6 +140,7 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [ 'workflow:read', 'workflow:update', 'workflow:delete', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:activate', 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 56ec7e2450f..10b2c0e0e07 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -82,6 +82,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'projectVariable:list', 'workflow:create', 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:publish', 'workflow:unpublish', diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index f3f49310c5f..3aea801d9f8 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -17,6 +17,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'agent:unpublish', 'workflow:create', 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:publish', 'workflow:unpublish', @@ -72,6 +73,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'agent:unpublish', 'workflow:create', 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:delete', 'workflow:list', @@ -119,6 +121,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'agent:unpublish', 'workflow:create', 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:publish', 'workflow:unpublish', @@ -164,6 +167,7 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'project:read', 'workflow:list', 'workflow:read', + 'workflow:export', 'workflow:execute-chat', 'folder:read', 'folder:list', diff --git a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts index bbd2b65a10d..a19c2456be4 100644 --- a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts @@ -2,6 +2,7 @@ import type { Scope } from '../../types.ee'; export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:publish', 'workflow:unpublish', @@ -15,6 +16,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ 'workflow:read', + 'workflow:export', 'workflow:update', 'workflow:publish', 'workflow:unpublish', diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index a9d0999a396..f3ca91f5f72 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -80,6 +80,10 @@ export const scopeInformation: Partial> = { displayName: 'Unshare Workflow', description: 'Allows removing workflow shares.', }, + 'workflow:export': { + displayName: 'Export Workflow', + description: 'Allows including workflows in a portable package export.', + }, 'credential:unshare': { displayName: 'Unshare Credential', description: 'Allows removing credential shares.', diff --git a/packages/cli/package.json b/packages/cli/package.json index b8b41aa7eb2..1f5903b3319 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -214,6 +214,7 @@ "sshpk": "1.18.0", "sucrase": "3.35.0", "swagger-ui-express": "5.0.1", + "tar": "^7.5.11", "undici": "^7.16.0", "uuid": "catalog:", "validator": "13.15.22", diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index c41f47919ea..fea08e27d80 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -129,6 +129,7 @@ export class E2EController { [LICENSE_FEATURES.PERSONAL_SPACE_POLICY]: false, [LICENSE_FEATURES.TOKEN_EXCHANGE]: false, [LICENSE_FEATURES.DATA_REDACTION]: false, + [LICENSE_FEATURES.N8N_PACKAGES]: false, }; private static readonly numericFeaturesDefaults: Record = { diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts new file mode 100644 index 00000000000..06703786588 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts @@ -0,0 +1,60 @@ +import type { AuthenticatedRequest } from '@n8n/db'; +import { ControllerRegistryMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import { PassThrough } from 'node:stream'; + +import { N8nPackagesController } from '../n8n-packages.controller'; +import type { N8nPackagesService } from '../n8n-packages.service'; + +describe('n8n-packages export', () => { + describe('exportWorkflows', () => { + it('sets gzip Content-Type and .n8np attachment Content-Disposition on the response', async () => { + const service = mock(); + service.exportWorkflows.mockResolvedValue(new PassThrough()); + + const controller = new N8nPackagesController(service); + const req = { user: { id: 'user-1' } } as unknown as AuthenticatedRequest; + const res = mock(); + + await controller.exportWorkflows(req, res, { workflowIds: ['wf-1'] }); + + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip'); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="export.n8np"', + ); + }); + + it('forwards the authenticated user and workflow ids to the service', async () => { + const service = mock(); + service.exportWorkflows.mockResolvedValue(new PassThrough()); + + const controller = new N8nPackagesController(service); + const user = { id: 'user-1' }; + const req = { user } as unknown as AuthenticatedRequest; + + await controller.exportWorkflows(req, mock(), { workflowIds: ['wf-a', 'wf-b'] }); + + expect(service.exportWorkflows).toHaveBeenCalledWith({ + user, + workflowIds: ['wf-a', 'wf-b'], + }); + }); + }); + + describe('route decorators', () => { + const route = Container.get(ControllerRegistryMetadata) + .getControllerMetadata(N8nPackagesController as never) + .routes.get('exportWorkflows'); + + it('is gated by the feat:n8nPackages license', () => { + expect(route?.licenseFeature).toBe('feat:n8nPackages'); + }); + + it('has no @ProjectScope or @GlobalScope decorator', () => { + expect(route?.accessScope).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts new file mode 100644 index 00000000000..d437e457d32 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts @@ -0,0 +1,290 @@ +import { + createTeamProject, + createWorkflow, + linkUserToProject, + shareWorkflowWithUsers, + testDb, + testModules, +} from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { ProjectRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createMember, createOwner } from '@test-integration/db/users'; + +import { N8nPackagesService } from '../n8n-packages.service'; +import { FORMAT_VERSION } from '../spec/constants'; +import { readExport } from './utils/tar-support'; + +beforeAll(async () => { + await testModules.loadModules(['n8n-packages']); + await testDb.init(); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +beforeEach(async () => { + await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']); +}); + +describe('workflow package export', () => { + let service: N8nPackagesService; + + beforeAll(() => { + service = Container.get(N8nPackagesService); + }); + + async function exportSingleWorkflow(user: User, workflowId: string) { + const stream = await service.exportWorkflows({ user, workflowIds: [workflowId] }); + return await readExport(stream); + } + + describe('package contents', () => { + it('emits a tar with manifest.json first and a workflow.json per requested workflow', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const workflow = await createWorkflow( + { + name: 'My Workflow', + nodes: [ + { + id: 'n1', + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + }, + project, + ); + + const { manifest, entries } = await exportSingleWorkflow(owner, workflow.id); + + expect(entries[0].name).toBe('manifest.json'); + expect(manifest).toMatchObject({ + packageFormatVersion: FORMAT_VERSION, + exportedAt: expect.any(String), + sourceN8nVersion: expect.any(String), + sourceId: expect.any(String), + }); + expect(manifest.workflows).toEqual([ + { id: workflow.id, name: 'My Workflow', target: expect.any(String) }, + ]); + + const workflowFile = entries.find( + (e) => e.name === `${manifest.workflows![0].target}/workflow.json`, + ); + expect(workflowFile).toBeDefined(); + const serialized = JSON.parse(workflowFile!.content.toString()); + expect(serialized.id).toBe(workflow.id); + expect(serialized.nodes).toHaveLength(1); + }); + + it('writes each workflow under a distinct slugged target', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const wfA = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project); + const wfB = await createWorkflow({ name: 'Beta', nodes: [], connections: {} }, project); + + const stream = await service.exportWorkflows({ + user: owner, + workflowIds: [wfA.id, wfB.id], + }); + const { manifest, entries } = await readExport(stream); + + expect(manifest.workflows).toHaveLength(2); + expect(manifest.workflows!.map((w) => w.id).sort()).toEqual([wfA.id, wfB.id].sort()); + expect(new Set(manifest.workflows!.map((w) => w.target)).size).toBe(2); + + for (const entry of manifest.workflows!) { + expect(entries.find((e) => e.name === `${entry.target}/workflow.json`)).toBeDefined(); + } + }); + + it('disambiguates targets when two workflows share a name', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const wfA = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); + const wfB = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); + + const stream = await service.exportWorkflows({ + user: owner, + workflowIds: [wfA.id, wfB.id], + }); + const { manifest, entries } = await readExport(stream); + + const targetsById = new Map(manifest.workflows!.map((w) => [w.id, w.target])); + expect(targetsById.get(wfA.id)).toBeDefined(); + expect(targetsById.get(wfB.id)).toBeDefined(); + expect(targetsById.get(wfA.id)).not.toBe(targetsById.get(wfB.id)); + + // Cross-paste guard: each target's workflow.json must serialize the + // matching workflow id, proving slug collision didn't swap contents. + for (const [id, target] of targetsById) { + const file = entries.find((e) => e.name === `${target}/workflow.json`); + expect(file).toBeDefined(); + expect(JSON.parse(file!.content.toString()).id).toBe(id); + } + }); + }); + + describe('authorization', () => { + it('Lists count of workflows inaccessible', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Project A', owner); + const wf = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project); + + await expect( + service.exportWorkflows({ + user: owner, + workflowIds: [wf.id, 'missing-1', 'missing-2'], + }), + ).rejects.toThrow('2 workflow(s) not found or not accessible. Export aborted.'); + }); + + it('denies a caller with no access', async () => { + // Unauthorized and truly-missing ids surface with the same message so a caller + // can't probe whether a workflow exists outside their permission scope. + const owner = await createOwner(); + const ownerProject = await createTeamProject('Owner Project', owner); + const ownerWorkflow = await createWorkflow( + { name: 'Owners Workflow', nodes: [], connections: {} }, + ownerProject, + ); + const outsider = await createMember(); + + await expect( + service.exportWorkflows({ user: outsider, workflowIds: [ownerWorkflow.id] }), + ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); + }); + + it('fails the whole batch and only names the inaccessible ids when mixed with accessible ones', async () => { + const owner = await createOwner(); + const ownerProject = await createTeamProject('Owner Project', owner); + const ownerWorkflow = await createWorkflow( + { name: 'Owner Only', nodes: [], connections: {} }, + ownerProject, + ); + const member = await createMember(); + const memberPersonal = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); + const memberWorkflow = await createWorkflow( + { name: 'Member Workflow', nodes: [], connections: {} }, + memberPersonal, + ); + + const error = (await service + .exportWorkflows({ + user: member, + workflowIds: [memberWorkflow.id, ownerWorkflow.id], + }) + .catch((e: Error) => e)) as Error; + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('1 workflow(s) not found or not accessible. Export aborted.'); + }); + + it("denies one member access to another member's personal workflow", async () => { + const memberA = await createMember(); + const memberB = await createMember(); + const memberAPersonal = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(memberA.id); + const wf = await createWorkflow( + { name: 'Member A Workflow', nodes: [], connections: {} }, + memberAPersonal, + ); + + await expect( + service.exportWorkflows({ user: memberB, workflowIds: [wf.id] }), + ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); + }); + + it('denies a member access to a workflow shared only with someone else', async () => { + const owner = await createOwner(); + const ownerProject = await createTeamProject('Source Project', owner); + const wf = await createWorkflow( + { name: 'Shared Workflow', nodes: [], connections: {} }, + ownerProject, + ); + const sharee = await createMember(); + const bystander = await createMember(); + await shareWorkflowWithUsers(wf, [sharee]); + + await expect( + service.exportWorkflows({ user: bystander, workflowIds: [wf.id] }), + ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); + }); + + it('allows a personal-project owner to export their own workflows', async () => { + const member = await createMember(); + const personal = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( + member.id, + ); + const wf = await createWorkflow( + { name: 'Member Workflow', nodes: [], connections: {} }, + personal, + ); + + const { manifest } = await exportSingleWorkflow(member, wf.id); + expect(manifest.workflows![0].id).toBe(wf.id); + }); + + it('allows a project editor to export team project workflows', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Editor Project', owner); + const editor = await createMember(); + await linkUserToProject(editor, project, 'project:editor'); + const wf = await createWorkflow({ name: 'Editable', nodes: [], connections: {} }, project); + + const { manifest } = await exportSingleWorkflow(editor, wf.id); + expect(manifest.workflows![0].id).toBe(wf.id); + }); + + it('allows a project viewer to export team project workflows', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Viewer Project', owner); + const viewer = await createMember(); + await linkUserToProject(viewer, project, 'project:viewer'); + const wf = await createWorkflow({ name: 'Viewable', nodes: [], connections: {} }, project); + + const { manifest } = await exportSingleWorkflow(viewer, wf.id); + expect(manifest.workflows![0].id).toBe(wf.id); + }); + + it("allows a global owner to export a team project's workflows without being a member", async () => { + // Global owners bypass the per-project scope check, so the typical operator + // path — exporting a workflow from a project they have no relation to — must work. + const projectAdmin = await createMember(); + const project = await createTeamProject('Foreign Project', projectAdmin); + const wf = await createWorkflow( + { name: 'Foreign Workflow', nodes: [], connections: {} }, + project, + ); + const globalOwner = await createOwner(); + + const { manifest } = await exportSingleWorkflow(globalOwner, wf.id); + expect(manifest.workflows![0].id).toBe(wf.id); + }); + + it('allows a direct-share recipient to export the shared workflow', async () => { + const owner = await createOwner(); + const ownerProject = await createTeamProject('Source Project', owner); + const wf = await createWorkflow( + { name: 'Shared Workflow', nodes: [], connections: {} }, + ownerProject, + ); + const sharee = await createMember(); + await shareWorkflowWithUsers(wf, [sharee]); + + const { manifest } = await exportSingleWorkflow(sharee, wf.id); + expect(manifest.workflows![0].id).toBe(wf.id); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/utils/tar-support.ts b/packages/cli/src/modules/n8n-packages/__tests__/utils/tar-support.ts new file mode 100644 index 00000000000..330cbfabdd6 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/utils/tar-support.ts @@ -0,0 +1,45 @@ +import { jsonParse } from 'n8n-workflow'; +import type { Readable } from 'node:stream'; +import { Parser, type ReadEntry } from 'tar'; + +import type { PackageManifest } from '../../spec/manifest.schema'; + +export interface UnpackedEntry { + name: string; + type: string; + content: Buffer; +} + +export async function streamToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer)); + } + return Buffer.concat(chunks); +} + +export async function unpackTar(buffer: Buffer): Promise { + const entries: UnpackedEntry[] = []; + return await new Promise((resolve, reject) => { + const parser = new Parser(); + parser.on('entry', (entry: ReadEntry) => { + const chunks: Buffer[] = []; + entry.on('data', (c: Buffer) => chunks.push(c)); + entry.on('end', () => { + entries.push({ name: entry.path, type: entry.type, content: Buffer.concat(chunks) }); + }); + entry.resume(); + }); + parser.on('error', reject); + parser.on('end', () => resolve(entries)); + parser.end(buffer); + }); +} + +export async function readExport( + stream: Readable, +): Promise<{ manifest: PackageManifest; entries: UnpackedEntry[] }> { + const entries = await unpackTar(await streamToBuffer(stream)); + const manifest = jsonParse(entries[0].content.toString()); + return { manifest, entries }; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts new file mode 100644 index 00000000000..4070e3c6aac --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow.exporter.test.ts @@ -0,0 +1,119 @@ +import type { User, WorkflowEntity } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; +import type { Readable } from 'node:stream'; + +import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import type { PackageWriter } from '../../../io/package-writer'; +import { WorkflowExporter } from '../workflow.exporter'; +import { WorkflowSerializer } from '../workflow.serializer'; + +const user = mock({ id: 'user-1' }); + +function makeWorkflow(overrides: Partial = {}): WorkflowEntity { + return { + id: 'wf-abc1234567', + name: 'My Workflow', + nodes: [], + connections: {}, + versionId: 'v1', + active: false, + isArchived: false, + settings: undefined, + parentFolder: null, + ...overrides, + } as unknown as WorkflowEntity; +} + +class CapturingWriter implements PackageWriter { + readonly files: Array<{ path: string; content: string }> = []; + + readonly directories: string[] = []; + + writeFile(path: string, content: string | Buffer): void { + this.files.push({ path, content: content.toString() }); + } + + writeDirectory(path: string): void { + this.directories.push(path); + } + + finalize(): Readable { + throw new Error('not used in this test'); + } +} + +function makeExporter(returned: WorkflowEntity[]) { + const finder = mock(); + finder.findWorkflowsByIdsForUser.mockResolvedValue(returned); + const exporter = new WorkflowExporter(finder, new WorkflowSerializer()); + return { exporter, finder }; +} + +describe('WorkflowExporter', () => { + it('asks the finder for the workflows using the workflow:export scope', async () => { + const workflow = makeWorkflow(); + const { exporter, finder } = makeExporter([workflow]); + const writer = new CapturingWriter(); + + await exporter.export({ user, workflowIds: [workflow.id], writer }); + + expect(finder.findWorkflowsByIdsForUser).toHaveBeenCalledWith( + [workflow.id], + user, + ['workflow:export'], + { includeParentFolder: true }, + ); + }); + + it('throws when the finder omits a requested id (unauthorized or missing)', async () => { + const present = makeWorkflow({ id: 'present-1' }); + const { exporter } = makeExporter([present]); + const writer = new CapturingWriter(); + + await expect( + exporter.export({ + user, + workflowIds: ['present-1', 'missing-or-denied'], + writer, + }), + ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); + }); + + it('writes one entry per finder-returned workflow, even if the request repeats an id', async () => { + // The finder is responsible for deduping; the exporter must iterate the + // finder's output (not the input ids) so a repeated id can't double-write. + const workflow = makeWorkflow({ id: 'wf-repeated', name: 'Repeated' }); + const { exporter } = makeExporter([workflow]); + const writer = new CapturingWriter(); + + const entries = await exporter.export({ + user, + workflowIds: [workflow.id, workflow.id], + writer, + }); + + expect(entries).toEqual([ + { id: workflow.id, name: workflow.name, target: 'workflows/repeated' }, + ]); + expect(writer.files.filter((f) => f.path === 'workflows/repeated/workflow.json')).toHaveLength( + 1, + ); + }); + + it('disambiguates targets when two workflows share a name', async () => { + const a = makeWorkflow({ id: 'wf-aaaaa', name: 'Same Name' }); + const b = makeWorkflow({ id: 'wf-bbbbb', name: 'Same Name' }); + const { exporter } = makeExporter([a, b]); + const writer = new CapturingWriter(); + + const entries = await exporter.export({ user, workflowIds: [a.id, b.id], writer }); + + const targets = entries.map((e) => e.target); + expect(targets).toEqual(['workflows/same-name', 'workflows/same-name-2']); + + const writtenPaths = writer.files.map((f) => f.path); + expect(writtenPaths).toContain('workflows/same-name/workflow.json'); + expect(writtenPaths).toContain('workflows/same-name-2/workflow.json'); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts new file mode 100644 index 00000000000..bedd48d41ec --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts @@ -0,0 +1,93 @@ +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +import { WorkflowSerializer } from './workflow.serializer'; +import type { PackageWriter } from '../../io/package-writer'; +import { generateSlug } from '../../io/slug.utils'; +import type { ManifestEntry } from '../../spec/manifest.schema'; + +export interface WorkflowExportRequest { + user: User; + workflowIds: string[]; + writer: PackageWriter; +} + +@Service() +export class WorkflowExporter { + constructor( + private readonly workflowFinder: WorkflowFinderService, + private readonly workflowSerializer: WorkflowSerializer, + ) {} + + async export(request: WorkflowExportRequest): Promise { + const workflows = await this.workflowFinder.findWorkflowsByIdsForUser( + request.workflowIds, + request.user, + ['workflow:export'], + { includeParentFolder: true }, + ); + + this.assertAllRequestedWorkflowsFound(request.workflowIds, workflows); + + const entries: ManifestEntry[] = []; + const usedTargets = new Set(); + + for (const workflow of workflows) { + const target = this.allocateUniqueFileName(workflow.name, usedTargets); + const serialized = this.workflowSerializer.serialize(workflow); + + request.writer.writeDirectory(target); + request.writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t')); + + entries.push({ + id: workflow.id, + name: workflow.name, + target, + }); + } + + return entries; + } + + private allocateUniqueFileName(name: string, used: Set): string { + const base = `workflows/${generateSlug(name)}`; + + if (!used.has(base)) { + used.add(base); + return base; + } + + for (let suffix = 2; ; suffix++) { + const candidate = `${base}-${suffix}`; + if (!used.has(candidate)) { + used.add(candidate); + return candidate; + } + } + } + + private assertAllRequestedWorkflowsFound( + requestedWorkflowIds: string[], + foundWorkflows: Array<{ id: string }>, + ) { + const foundWorkflowIds = new Set(foundWorkflows.map(({ id }) => id)); + const missingWorkflowIds = requestedWorkflowIds.filter((id) => !foundWorkflowIds.has(id)); + + if (missingWorkflowIds.length > 0) { + const displayedWorkflowIds = missingWorkflowIds.slice(0, 20); + const omittedCount = missingWorkflowIds.length - displayedWorkflowIds.length; + + throw new UserError( + `${missingWorkflowIds.length} workflow(s) not found or not accessible. Export aborted.`, + { + description: `Missing workflow IDs: ${displayedWorkflowIds.join(', ')}${ + omittedCount > 0 ? `, and ${omittedCount} more` : '' + }`, + }, + ); + } + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts new file mode 100644 index 00000000000..0b4928a55d2 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts @@ -0,0 +1,24 @@ +import type { WorkflowEntity } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { + serializedWorkflowSchema, + type SerializedWorkflow, +} from '../../spec/serialized/workflow.schema'; + +@Service() +export class WorkflowSerializer { + serialize(workflow: WorkflowEntity): SerializedWorkflow { + return serializedWorkflowSchema.parse({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + settings: workflow.settings, + versionId: workflow.versionId, + parentFolderId: workflow.parentFolder?.id ?? null, + active: workflow.activeVersionId === workflow.versionId, + isArchived: workflow.isArchived, + }); + } +} diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts new file mode 100644 index 00000000000..4c04ea82382 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/slug.utils.test.ts @@ -0,0 +1,23 @@ +import { generateSlug } from '../slug.utils'; + +describe('generateSlug', () => { + it('lower-cases and hyphenates the name', () => { + expect(generateSlug('My Workflow')).toBe('my-workflow'); + }); + + it('collapses whitespace and strips disallowed characters', () => { + expect(generateSlug(' Hello, World! ')).toBe('hello-world'); + }); + + it('removes emojis', () => { + expect(generateSlug('💳 Payments')).toBe('payments'); + }); + + it('falls back to "workflow" when the name yields an empty slug', () => { + expect(generateSlug('--')).toBe('workflow'); + }); + + it('strips leading and trailing hyphens', () => { + expect(generateSlug('---foo---')).toBe('foo'); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-writer.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-writer.test.ts new file mode 100644 index 00000000000..025eb85c797 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/tar-package-writer.test.ts @@ -0,0 +1,96 @@ +import type { Readable } from 'node:stream'; +import { Parser, type ReadEntry } from 'tar'; + +import { TarPackageWriter } from '../tar/tar-package-writer'; + +async function streamToBuffer(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer)); + } + return Buffer.concat(chunks); +} + +interface UnpackedEntry { + name: string; + type: string; + mtime: Date | undefined; + content: Buffer; +} + +async function unpackTar(buffer: Buffer): Promise { + const entries: UnpackedEntry[] = []; + return await new Promise((resolve, reject) => { + const parser = new Parser(); + parser.on('entry', (entry: ReadEntry) => { + const chunks: Buffer[] = []; + entry.on('data', (c: Buffer) => chunks.push(c)); + entry.on('end', () => { + entries.push({ + name: entry.path, + type: entry.type, + mtime: entry.mtime, + content: Buffer.concat(chunks), + }); + }); + entry.resume(); + }); + parser.on('error', reject); + parser.on('end', () => resolve(entries)); + parser.end(buffer); + }); +} + +describe('TarPackageWriter', () => { + it('emits manifest.json as the first entry regardless of write order', async () => { + const writer = new TarPackageWriter(); + + writer.writeDirectory('workflows/wf-abc'); + writer.writeFile('workflows/wf-abc/workflow.json', '{"id":"abc"}'); + writer.writeFile('manifest.json', '{"packageFormatVersion":"1"}'); + + const buffer = await streamToBuffer(writer.finalize()); + const entries = await unpackTar(buffer); + + expect(entries[0].name).toBe('manifest.json'); + expect(JSON.parse(entries[0].content.toString())).toEqual({ packageFormatVersion: '1' }); + }); + + it('uses a fixed epoch mtime so packages are byte-identical for the same input', async () => { + const build = async () => { + const writer = new TarPackageWriter(); + writer.writeFile('manifest.json', '{}'); + writer.writeDirectory('workflows/x'); + writer.writeFile('workflows/x/workflow.json', '{"id":"x"}'); + return await streamToBuffer(writer.finalize()); + }; + + const first = await build(); + // Wait a tick so any wall-clock-derived mtime would differ between runs. + await new Promise((r) => setTimeout(r, 5)); + const second = await build(); + + expect(first.equals(second)).toBe(true); + + const entries = await unpackTar(first); + for (const entry of entries) { + expect(entry.mtime?.getTime()).toBe(0); + } + }); + + it('normalises leading "./" entry paths', async () => { + const writer = new TarPackageWriter(); + writer.writeFile('manifest.json', '{}'); + writer.writeFile('./workflows/wf/workflow.json', '{}'); + writer.writeDirectory('./workflows/wf'); + + const buffer = await streamToBuffer(writer.finalize()); + const entries = await unpackTar(buffer); + const names = entries.map((e) => e.name); + + expect(names).toEqual( + expect.arrayContaining(['manifest.json', 'workflows/wf/workflow.json', 'workflows/wf/']), + ); + expect(names.every((n) => !n.startsWith('./'))).toBe(true); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/io/package-writer.ts b/packages/cli/src/modules/n8n-packages/io/package-writer.ts new file mode 100644 index 00000000000..4e73e1d3436 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/package-writer.ts @@ -0,0 +1,7 @@ +import type { Readable } from 'node:stream'; + +export interface PackageWriter { + writeFile(path: string, content: string | Buffer): void; + writeDirectory(path: string): void; + finalize(): Readable; +} diff --git a/packages/cli/src/modules/n8n-packages/io/slug.utils.ts b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts new file mode 100644 index 00000000000..76694daf2d3 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts @@ -0,0 +1,22 @@ +const EMPTY_SLUG_FALLBACK = 'workflow'; + +/** + * Generates a filesystem-safe slug from an entity name + */ +export function generateSlug(name: string): string { + let slug = name; + slug = slug + .toLowerCase() + // Remove characters except lowercase letters, digits, whitespace, and hyphens + .replace(/[^a-z0-9\s-]/g, '') + // Remove whitespace at the start/end + .trim() + // Replace remaining whitespace runs with a - + .replace(/\s+/g, '-') + // Collapse consecutive hyphens into a single hyphen + .replace(/-+/g, '-') + // Remove any - at the start or end of the slug + .replace(/^-|-$/g, ''); + + return slug || EMPTY_SLUG_FALLBACK; +} diff --git a/packages/cli/src/modules/n8n-packages/io/tar/tar-package-writer.ts b/packages/cli/src/modules/n8n-packages/io/tar/tar-package-writer.ts new file mode 100644 index 00000000000..2c8528be845 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/tar/tar-package-writer.ts @@ -0,0 +1,85 @@ +import { Readable } from 'node:stream'; +import { Header, Pack, ReadEntry } from 'tar'; + +import type { PackageWriter } from '../package-writer'; + +const FIXED_MTIME = new Date(0); +const FILE_MODE = 0o644; +const DIRECTORY_MODE = 0o755; +const MANIFEST_PATH = 'manifest.json'; + +type Entry = { kind: 'file'; path: string; content: Buffer } | { kind: 'directory'; path: string }; + +function normaliseEntryPath(path: string): string { + return path.startsWith('./') ? path.slice(2) : path; +} + +function fileEntry(path: string, content: Buffer): ReadEntry { + const entry = new ReadEntry( + new Header({ + path, + size: content.length, + mode: FILE_MODE, + mtime: FIXED_MTIME, + type: 'File', + }), + ); + entry.end(content); + return entry; +} + +function directoryEntry(path: string): ReadEntry { + const normalised = path.endsWith('/') ? path : `${path}/`; + const entry = new ReadEntry( + new Header({ + path: normalised, + size: 0, + mode: DIRECTORY_MODE, + mtime: FIXED_MTIME, + type: 'Directory', + }), + ); + entry.end(); + return entry; +} + +export class TarPackageWriter implements PackageWriter { + private readonly entries: Entry[] = []; + + private manifest: Buffer | null = null; + + writeFile(path: string, content: string | Buffer): void { + const buffer = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content; + const normalised = normaliseEntryPath(path); + + if (normalised === MANIFEST_PATH) { + this.manifest = buffer; + return; + } + + this.entries.push({ kind: 'file', path: normalised, content: buffer }); + } + + writeDirectory(path: string): void { + this.entries.push({ kind: 'directory', path: normaliseEntryPath(path) }); + } + + finalize(): Readable { + const pack = new Pack({ gzip: { portable: true }, mtime: FIXED_MTIME }); + + if (this.manifest) { + pack.write(fileEntry(MANIFEST_PATH, this.manifest)); + } + + for (const entry of this.entries) { + if (entry.kind === 'file') { + pack.write(fileEntry(entry.path, entry.content)); + } else { + pack.write(directoryEntry(entry.path)); + } + } + + pack.end(); + return Readable.from(pack); + } +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts new file mode 100644 index 00000000000..131f850ee23 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts @@ -0,0 +1,28 @@ +import { ExportWorkflowsRequestDto } from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Body, Licensed, Post, RestController } from '@n8n/decorators'; +import type { Response } from 'express'; +import type { Readable } from 'node:stream'; + +import { N8nPackagesService } from './n8n-packages.service'; + +@RestController('/n8n-packages') +export class N8nPackagesController { + constructor(private readonly packagesService: N8nPackagesService) {} + + @Post('/export') + @Licensed('feat:n8nPackages') + async exportWorkflows( + req: AuthenticatedRequest, + res: Response, + @Body body: ExportWorkflowsRequestDto, + ): Promise { + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"'); + + return await this.packagesService.exportWorkflows({ + user: req.user, + workflowIds: body.workflowIds, + }); + } +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.module.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.module.ts new file mode 100644 index 00000000000..b366b19be05 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.module.ts @@ -0,0 +1,13 @@ +import { LICENSE_FEATURES } from '@n8n/constants'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; + +@BackendModule({ + name: 'n8n-packages', + licenseFlag: LICENSE_FEATURES.N8N_PACKAGES, +}) +export class N8nPackagesModule implements ModuleInterface { + async init() { + await import('./n8n-packages.controller'); + } +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts new file mode 100644 index 00000000000..6658455f221 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -0,0 +1,40 @@ +import { Service } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; +import type { Readable } from 'node:stream'; + +import { N8N_VERSION } from '@/constants'; + +import { WorkflowExporter } from './entities/workflow/workflow.exporter'; +import { TarPackageWriter } from './io/tar/tar-package-writer'; +import type { ExportWorkflowsRequest } from './n8n-packages.types'; +import { FORMAT_VERSION } from './spec/constants'; +import { packageManifestSchema } from './spec/manifest.schema'; + +@Service() +export class N8nPackagesService { + constructor( + private readonly workflowExporter: WorkflowExporter, + private readonly instanceSettings: InstanceSettings, + ) {} + + async exportWorkflows(request: ExportWorkflowsRequest): Promise { + const writer = new TarPackageWriter(); + + const workflowEntries = await this.workflowExporter.export({ + user: request.user, + workflowIds: request.workflowIds, + writer, + }); + + const manifest = packageManifestSchema.parse({ + packageFormatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + sourceN8nVersion: N8N_VERSION, + sourceId: this.instanceSettings.instanceId, + workflows: workflowEntries, + }); + + writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t')); + return writer.finalize(); + } +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts new file mode 100644 index 00000000000..3d899084732 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -0,0 +1,6 @@ +import type { User } from '@n8n/db'; + +export interface ExportWorkflowsRequest { + user: User; + workflowIds: string[]; +} diff --git a/packages/cli/src/modules/n8n-packages/spec/constants.ts b/packages/cli/src/modules/n8n-packages/spec/constants.ts new file mode 100644 index 00000000000..a4001d9685c --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/constants.ts @@ -0,0 +1 @@ +export const FORMAT_VERSION = '1'; diff --git a/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts new file mode 100644 index 00000000000..045033d401f --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const manifestEntrySchema = z.object({ + id: z.string().min(1), + name: z.string(), + target: z.string().min(1), +}); + +export const packageManifestSchema = z.object({ + packageFormatVersion: z.string().min(1), + exportedAt: z.string().min(1), + sourceN8nVersion: z.string().min(1), + sourceId: z.string().min(1), + workflows: z.array(manifestEntrySchema).optional(), +}); + +export type ManifestEntry = z.infer; +export type PackageManifest = z.infer; diff --git a/packages/cli/src/modules/n8n-packages/spec/serialized/workflow.schema.ts b/packages/cli/src/modules/n8n-packages/spec/serialized/workflow.schema.ts new file mode 100644 index 00000000000..62a9a438ebc --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/serialized/workflow.schema.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +// Outer node shape is stable; per-node-type parameter shapes are not. We +// validate the envelope and treat `parameters` as opaque — schema-validating +// parameters per node type would be impossibly maintenance-heavy. +const credentialReferenceSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const nodeSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.string().min(1), + typeVersion: z.number(), + position: z.tuple([z.number(), z.number()]), + parameters: z.record(z.unknown()), + credentials: z.record(credentialReferenceSchema).optional(), + disabled: z.boolean().optional(), + notes: z.string().optional(), + notesInFlow: z.boolean().optional(), + continueOnFail: z.boolean().optional(), + retryOnFail: z.boolean().optional(), + maxTries: z.number().optional(), + waitBetweenTries: z.number().optional(), + alwaysOutputData: z.boolean().optional(), + executeOnce: z.boolean().optional(), + onError: z.string().optional(), + webhookId: z.string().optional(), +}); + +// IConnections shape: { [sourceNodeName]: { [outputType]: Array> } } +const connectionLeafSchema = z.object({ + node: z.string(), + type: z.string(), + index: z.number(), +}); + +const connectionsSchema = z.record(z.record(z.array(z.array(connectionLeafSchema).nullable()))); + +export const serializedWorkflowSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + nodes: z.array(nodeSchema), + connections: connectionsSchema, + settings: z.record(z.unknown()).optional(), + versionId: z.string(), + parentFolderId: z.string().nullable(), + active: z.boolean(), + isArchived: z.boolean(), +}); + +export type SerializedWorkflow = z.infer; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts new file mode 100644 index 00000000000..7681b98938c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -0,0 +1,56 @@ +import { ExportWorkflowsRequestDto } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; +import { LICENSE_FEATURES } from '@n8n/constants'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; + +import type { PublicAPIEndpoint } from '../../shared/handler.types'; +import { isLicensed, publicApiScope } from '../../shared/middlewares/global.middleware'; + +type ExportWorkflowsRequest = AuthenticatedRequest<{}, {}, { workflowIds: string[] }>; + +type N8nPackagesHandlers = { + exportWorkflows: PublicAPIEndpoint; +}; + +const n8nPackagesHandlers: N8nPackagesHandlers = { + exportWorkflows: [ + isLicensed(LICENSE_FEATURES.N8N_PACKAGES), + publicApiScope('workflow:export'), + async (req, res) => { + if (!Container.get(GlobalConfig).publicApi.packagesEnabled) { + throw new NotFoundError('Not Found'); + } + + const payload = ExportWorkflowsRequestDto.safeParse(req.body); + if (!payload.success) { + throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; ')); + } + + const stream = await Container.get(N8nPackagesService).exportWorkflows({ + user: req.user, + workflowIds: payload.data.workflowIds, + }); + + res.setHeader('Content-Type', 'application/gzip'); + res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"'); + + return await new Promise((resolve, reject) => { + stream.on('error', reject); + res.on('finish', () => resolve(res)); + res.on('close', () => { + if (!res.writableFinished) stream.destroy(); + resolve(res); + }); + stream.pipe(res); + }); + }, + ], +}; + +export = n8nPackagesHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml new file mode 100644 index 00000000000..12b5adb56bc --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml @@ -0,0 +1,36 @@ +post: + x-eov-operation-id: exportWorkflows + x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler + tags: + - N8nPackage + summary: 'Beta: Export workflows as an n8n package' + description: | + **Beta — disabled by default.** Set `N8N_PUBLIC_API_PACKAGES_ENABLED=true` on the + instance to enable this endpoint. While disabled, requests return `404`. + + Export one or more workflows as a gzipped tar archive (.n8np). The response is + streamed as `application/gzip` with a `Content-Disposition` attachment header. + Requires the n8n Packages feature to be licensed. + requestBody: + description: Workflows to include in the exported package. + required: true + content: + application/json: + schema: + $ref: '../schemas/exportWorkflowsRequest.yml' + responses: + '200': + description: A gzipped tar archive containing the exported workflows. + content: + application/gzip: + schema: + type: string + format: binary + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportWorkflowsRequest.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportWorkflowsRequest.yml new file mode 100644 index 00000000000..d38f57d36c8 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportWorkflowsRequest.yml @@ -0,0 +1,15 @@ +type: object +additionalProperties: false +required: + - workflowIds +properties: + workflowIds: + type: array + minItems: 1 + maxItems: 300 + description: IDs of the workflows to include in the exported package. + items: + type: string + minLength: 1 + example: + - 2tUt1wbLX592XDdX diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 4426cd8d534..d71bce2ead2 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -44,6 +44,8 @@ tags: description: Operations about insights - name: Folders description: Operations about folders + - name: N8nPackage + description: Beta — operations about n8n packages. Disabled by default; enable with `N8N_PUBLIC_API_PACKAGES_ENABLED=true`. paths: /audit: @@ -140,6 +142,8 @@ paths: $ref: './handlers/folders/spec/paths/folders.yml' /projects/{projectId}/folders/{folderId}: $ref: './handlers/folders/spec/paths/folders.folderId.yml' + /n8n-packages/export: + $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 5c231ec9ae6..20459737f67 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -1,4 +1,4 @@ -import type { SharedWorkflow, User } from '@n8n/db'; +import type { SharedWorkflow, User, WorkflowEntity } from '@n8n/db'; import { SharedWorkflowRepository, FolderRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope, type Scope } from '@n8n/permissions'; @@ -123,6 +123,32 @@ export class WorkflowFinderService { return new Set(sharedWorkflows.map((sw) => sw.workflowId)); } + async findWorkflowsByIdsForUser( + workflowIds: string[], + user: User, + scopes: Scope[], + options: { includeParentFolder?: boolean } = {}, + ): Promise { + if (workflowIds.length === 0) return []; + + const where = await this.findAllWhere(user, scopes); + const sharedWorkflows = await this.sharedWorkflowRepository.find({ + where: { ...where, workflowId: In(workflowIds) }, + relations: { workflow: { parentFolder: options.includeParentFolder } }, + }); + + // A workflow may appear via several share paths (project membership + + // direct share); dedupe so callers see one entity per id. + const seen = new Set(); + const workflows: WorkflowEntity[] = []; + for (const { workflow } of sharedWorkflows) { + if (seen.has(workflow.id)) continue; + seen.add(workflow.id); + workflows.push(workflow); + } + return workflows; + } + async hasProjectScopeForUser(user: User, scopes: Scope[], projectId: string) { return await userHasScopes(user, scopes, false, { projectId }); } diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 226fafb044a..d54e5ad8179 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -227,6 +227,7 @@ describe('POST /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:move', 'workflow:publish', 'workflow:read', @@ -1133,6 +1134,7 @@ describe('GET /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:move', 'workflow:publish', 'workflow:read', @@ -1149,6 +1151,7 @@ describe('GET /workflows', () => { 'workflow:update', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:publish', 'workflow:unpublish', ].sort(), @@ -1171,6 +1174,7 @@ describe('GET /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:publish', 'workflow:read', 'workflow:unpublish', @@ -1184,6 +1188,7 @@ describe('GET /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:move', 'workflow:publish', 'workflow:read', @@ -1213,6 +1218,7 @@ describe('GET /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:publish', @@ -1233,6 +1239,7 @@ describe('GET /workflows', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:publish', @@ -2303,6 +2310,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:move', 'workflow:publish', 'workflow:read', @@ -2319,6 +2327,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:update', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:publish', 'workflow:unpublish', ].sort(), @@ -2346,6 +2355,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:publish', 'workflow:read', 'workflow:unpublish', @@ -2359,6 +2369,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:move', 'workflow:publish', 'workflow:read', @@ -2393,6 +2404,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:publish', @@ -2413,6 +2425,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:delete', 'workflow:execute', 'workflow:execute-chat', + 'workflow:export', 'workflow:list', 'workflow:move', 'workflow:publish', diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c4f8272bbfa..9946336ebfb 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2580,6 +2580,8 @@ "projectRoles.folder:delete.tooltip": "Delete an empty folder. If not empty, ask further action", "projectRoles.workflow:read": "View", "projectRoles.workflow:read.tooltip": "View workflows within the project", + "projectRoles.workflow:export": "Export", + "projectRoles.workflow:export.tooltip": "Include workflows in a package export", "projectRoles.workflow:execute": "Execute", "projectRoles.workflow:execute.tooltip": "Execute workflows within the project", "projectRoles.workflow:update": "Edit", diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/RoleHoverPopover.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/RoleHoverPopover.test.ts index 1407fb23f40..f3c7f762fd8 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/RoleHoverPopover.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/RoleHoverPopover.test.ts @@ -92,7 +92,7 @@ describe('RoleHoverPopover', () => { it('should display permission count', () => { const { getByText } = renderComponent(); - expect(getByText('3/50 permissions')).toBeInTheDocument(); + expect(getByText('3/51 permissions')).toBeInTheDocument(); }); it('should display role description when available', () => { diff --git a/packages/frontend/editor-ui/src/features/project-roles/projectRoleScopes.ts b/packages/frontend/editor-ui/src/features/project-roles/projectRoleScopes.ts index 52ee6264a03..b4f5f15e8ea 100644 --- a/packages/frontend/editor-ui/src/features/project-roles/projectRoleScopes.ts +++ b/packages/frontend/editor-ui/src/features/project-roles/projectRoleScopes.ts @@ -20,6 +20,7 @@ const UI_OPERATIONS = { workflow: [ 'read', 'execute', + 'export', 'update', 'create', 'publish', diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 3819dd9b0bc..f200aadbcb5 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -261,6 +261,9 @@ }, "PATCH /projects/{projectId}/folders/{folderId}": { "status": "gap" + }, + "POST /n8n-packages/export": { + "status": "gap" } } } diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 493557f383f..ef6c72b869a 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1390,7 +1390,6 @@ export interface INode { webhookId?: string; extendsCredential?: string; rewireOutputLogTo?: NodeConnectionType; - // forces the node to execute a particular custom operation // based on resource and operation // instead of calling default execute function diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e2d3d116a..5041abcecbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3016,6 +3016,9 @@ importers: swagger-ui-express: specifier: 5.0.1 version: 5.0.1(express@5.1.0) + tar: + specifier: ^7.5.11 + version: 7.5.11 undici: specifier: ^7.24.0 version: 7.24.6