From 0f46be02aaa66dbb7336fdaa53fe1e1e8467d46b Mon Sep 17 00:00:00 2001 From: Ali Elkhateeb Date: Wed, 1 Jul 2026 16:54:46 +0300 Subject: [PATCH] feat(API): Support exporting projects via the public API (#33084) Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com> --- packages/@n8n/api-types/src/dto/index.ts | 2 +- .../export-package-request.dto.test.ts | 55 +++++ .../export-workflows-request.dto.test.ts | 27 --- .../packages/export-package-request.dto.ts | 8 + .../packages/export-workflows-request.dto.ts | 7 - packages/@n8n/cli/docs/commands/package.md | 12 +- .../@n8n/cli/src/__tests__/client.test.ts | 15 +- .../cli/src/__tests__/package-export.test.ts | 54 ++++- packages/@n8n/cli/src/client.ts | 6 +- .../@n8n/cli/src/commands/package/export.ts | 38 +++- .../scope-information.test.ts.snap | 1 + packages/@n8n/permissions/src/constants.ee.ts | 4 +- .../src/public-api-permissions.ee.ts | 3 + .../src/roles/scopes/global-scopes.ee.ts | 1 + .../src/roles/scopes/project-scopes.ee.ts | 4 + .../@n8n/permissions/src/scope-information.ts | 4 + packages/@n8n/permissions/src/types.ee.ts | 2 +- packages/cli/src/active-workflow-manager.ts | 11 +- .../cli/src/modules/n8n-packages/CLAUDE.md | 12 +- .../export-project.integration.test.ts | 150 +++++++++++++ ...kflow-with-credentials.integration.test.ts | 14 +- .../export-workflow.controller.test.ts | 56 ----- .../export-workflow.integration.test.ts | 20 +- .../__tests__/project.exporter.test.ts | 161 ++++++++++++++ .../__tests__/project.serializer.test.ts | 57 +++++ .../entities/project/project.exporter.ts | 79 +++++++ .../entities/project/project.serializer.ts | 19 ++ .../entities/workflow/workflow.exporter.ts | 6 +- .../io/__tests__/slug.utils.test.ts | 16 +- .../unique-filename-allocator.test.ts | 10 +- .../src/modules/n8n-packages/io/slug.utils.ts | 4 +- .../io/unique-filename-allocator.ts | 2 +- .../n8n-packages/n8n-packages.controller.ts | 27 --- .../n8n-packages/n8n-packages.module.ts | 2 +- .../n8n-packages/n8n-packages.service.ts | 57 +++-- .../n8n-packages/n8n-packages.types.ts | 5 +- .../n8n-packages/spec/manifest.schema.ts | 35 +++- .../spec/serialized/project.schema.ts | 15 ++ .../source-control.controller.ee.ts | 10 +- .../v1/__tests__/scope-parity.test.ts | 5 +- .../__tests__/n8n-packages.handler.test.ts | 198 ++++++++++++++++++ .../n8n-packages/n8n-packages.handler.ts | 100 +++++++-- .../spec/paths/n8n-packages.export.yml | 22 +- ...wsRequest.yml => exportPackageRequest.yml} | 11 +- .../shared/middlewares/global.middleware.ts | 14 ++ .../cli/src/services/project.service.ee.ts | 27 +++ .../public-api/n8n-packages-export.test.ts | 43 ++++ .../cli/test/integration/webhooks.api.test.ts | 6 +- 48 files changed, 1174 insertions(+), 263 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts delete 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-package-request.dto.ts delete 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-project.integration.test.ts delete mode 100644 packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.exporter.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.serializer.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts delete mode 100644 packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts create mode 100644 packages/cli/src/modules/n8n-packages/spec/serialized/project.schema.ts create mode 100644 packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts rename packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/{exportWorkflowsRequest.yml => exportPackageRequest.yml} (58%) create mode 100644 packages/cli/test/integration/public-api/n8n-packages-export.test.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index cbbbd93e4b4..63243e78aa4 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -213,11 +213,11 @@ 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 { ImportPackageRequestDto, IMPORT_PACKAGE_REQUEST_FORM_FIELDS, } from './packages/import-package-request.dto'; +export { ExportPackageRequestDto } from './packages/export-package-request.dto'; export * from './evaluations'; diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts new file mode 100644 index 00000000000..716d7e26bea --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts @@ -0,0 +1,55 @@ +import { ExportPackageRequestDto } from '../export-package-request.dto'; + +describe('ExportPackageRequestDto', () => { + describe('workflowIds', () => { + it('accepts a non-empty array of workflow ids', () => { + const result = ExportPackageRequestDto.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(ExportPackageRequestDto.safeParse({ workflowIds }).success).toBe(true); + }); + + it.each([ + { name: 'empty workflowIds array', request: { workflowIds: [] } }, + { name: 'both empty arrays', request: { workflowIds: [], projectIds: [] } }, + { name: 'empty-string workflow id', request: { workflowIds: [''] } }, + { name: 'whitespace-only workflow id', request: { workflowIds: [' '] } }, + { name: 'non-string workflow id', request: { workflowIds: [123] } }, + { + name: 'more than 300 workflow ids', + request: { workflowIds: Array.from({ length: 301 }, (_, i) => `wf-${i}`) }, + }, + ])('rejects $name', ({ request }) => { + expect(ExportPackageRequestDto.safeParse(request).success).toBe(false); + }); + }); + + describe('projectIds', () => { + it('accepts a non-empty array of project ids', () => { + const result = ExportPackageRequestDto.safeParse({ projectIds: ['project-1'] }); + expect(result.success).toBe(true); + }); + + it('accepts up to 300 project ids', () => { + const projectIds = Array.from({ length: 300 }, (_, i) => `project-${i}`); + expect(ExportPackageRequestDto.safeParse({ projectIds }).success).toBe(true); + }); + + it.each([ + { name: 'empty projectIds array', request: { projectIds: [] } }, + { name: 'both empty arrays', request: { workflowIds: [], projectIds: [] } }, + { name: 'empty-string project id', request: { projectIds: [''] } }, + { name: 'whitespace-only project id', request: { projectIds: [' '] } }, + { name: 'non-string project id', request: { projectIds: [123] } }, + { + name: 'more than 300 project ids', + request: { projectIds: Array.from({ length: 301 }, (_, i) => `project-${i}`) }, + }, + ])('rejects $name', ({ request }) => { + expect(ExportPackageRequestDto.safeParse(request).success).toBe(false); + }); + }); +}); 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 deleted file mode 100644 index dc59100dcf0..00000000000 --- a/packages/@n8n/api-types/src/dto/packages/__tests__/export-workflows-request.dto.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts new file mode 100644 index 00000000000..6a9b7371345 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class ExportPackageRequestDto extends Z.class({ + workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), + projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), +}) {} 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 deleted file mode 100644 index 8ff260fff6e..00000000000 --- a/packages/@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/cli/docs/commands/package.md b/packages/@n8n/cli/docs/commands/package.md index c93acacf1bc..7ab35e41d7c 100644 --- a/packages/@n8n/cli/docs/commands/package.md +++ b/packages/@n8n/cli/docs/commands/package.md @@ -9,19 +9,25 @@ Export and import workflows as portable n8n packages (`.n8np` archives). ## `package export` -Export one or more workflows into a gzipped `.n8np` archive written to disk. +Export workflows or projects into a gzipped `.n8np` archive written to disk. +Provide workflow IDs or project IDs, but not both. ```bash n8n-cli package export --workflow-id=abc --output=export.n8np n8n-cli package export -w abc -w def -o team.n8np +n8n-cli package export --project-id=abc -o project.n8np +n8n-cli package export -p abc -p def -o projects.n8np ``` | Flag | Description | |------|-------------| -| `-w, --workflow-id` | Workflow ID to include. Repeat the flag to export several. (required) | +| `-w, --workflow-id` | Workflow ID to include. Repeat the flag to export several. | +| `-p, --project-id` | Project ID to include. Repeat the flag to export several. | | `-o, --output` | File to write the package to. Defaults to `export.n8np`. | -Requires the API key to hold the `workflow:export` scope. +Provide at least one `--workflow-id` or `--project-id`. Requires the API key to +hold `workflow:export` when exporting workflows, or `project:export` when +exporting projects. ## `package import` diff --git a/packages/@n8n/cli/src/__tests__/client.test.ts b/packages/@n8n/cli/src/__tests__/client.test.ts index 21f7eec89d1..c6e0c3fdbdd 100644 --- a/packages/@n8n/cli/src/__tests__/client.test.ts +++ b/packages/@n8n/cli/src/__tests__/client.test.ts @@ -46,7 +46,7 @@ describe('N8nClient packages', () => { it('posts the workflow IDs as JSON and returns the archive bytes', async () => { fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1, 2, 3]))); - const result = await client.exportPackage(['a', 'b']); + const result = await client.exportPackage({ workflowIds: ['a', 'b'] }); expect(Buffer.isBuffer(result)).toBe(true); expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true); @@ -55,6 +55,19 @@ describe('N8nClient packages', () => { expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export'); expect(init.body).toBe(JSON.stringify({ workflowIds: ['a', 'b'] })); }); + + it('posts the project IDs as JSON and returns the archive bytes', async () => { + fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([4, 5, 6]))); + + const result = await client.exportPackage({ projectIds: ['proj-1', 'proj-2'] }); + + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.equals(Buffer.from([4, 5, 6]))).toBe(true); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export'); + expect(init.body).toBe(JSON.stringify({ projectIds: ['proj-1', 'proj-2'] })); + }); }); describe('importPackage', () => { diff --git a/packages/@n8n/cli/src/__tests__/package-export.test.ts b/packages/@n8n/cli/src/__tests__/package-export.test.ts index 946b447ea31..597cd85764b 100644 --- a/packages/@n8n/cli/src/__tests__/package-export.test.ts +++ b/packages/@n8n/cli/src/__tests__/package-export.test.ts @@ -11,9 +11,12 @@ const mockedWriteFileSync = vi.mocked(fs.writeFileSync); /** The command methods we stub to isolate the file-writing behaviour from oclif/networking. */ interface ExportInternals { - parse: () => Promise<{ flags: { workflowId: string[]; output: string } }>; + parse: () => Promise<{ + flags: { workflowId?: string[]; projectId?: string[]; output: string }; + }>; getClient: () => N8nClient; succeed: () => void; + error: (message: string) => never; } describe('package export command', () => { @@ -21,23 +24,64 @@ describe('package export command', () => { vi.restoreAllMocks(); }); - it('writes the bytes returned by the client to the --output path', async () => { + it('writes workflow export bytes returned by the client to the --output path', async () => { const archive = Buffer.from([1, 2, 3]); const exportPackage = vi.fn().mockResolvedValue(archive); const command = new PackageExport([], {} as Config); const internals = command as unknown as ExportInternals; - // Bypass oclif arg parsing, connection setup, and the success/exit path; the - // behaviour under test is purely that the client's bytes land at flags.output. vi.spyOn(internals, 'parse').mockResolvedValue({ flags: { workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' }, }); vi.spyOn(internals, 'getClient').mockReturnValue({ exportPackage } as unknown as N8nClient); vi.spyOn(internals, 'succeed').mockImplementation(() => {}); + vi.spyOn(internals, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); await command.run(); - expect(exportPackage).toHaveBeenCalledWith(['wf-1', 'wf-2']); + expect(exportPackage).toHaveBeenCalledWith({ workflowIds: ['wf-1', 'wf-2'] }); expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', archive); }); + + it('writes project export bytes returned by the client to the --output path', async () => { + const archive = Buffer.from([4, 5, 6]); + const exportPackage = vi.fn().mockResolvedValue(archive); + + const command = new PackageExport([], {} as Config); + const internals = command as unknown as ExportInternals; + vi.spyOn(internals, 'parse').mockResolvedValue({ + flags: { projectId: ['proj-1', 'proj-2'], output: '/tmp/projects.n8np' }, + }); + vi.spyOn(internals, 'getClient').mockReturnValue({ exportPackage } as unknown as N8nClient); + vi.spyOn(internals, 'succeed').mockImplementation(() => {}); + vi.spyOn(internals, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await command.run(); + + expect(exportPackage).toHaveBeenCalledWith({ projectIds: ['proj-1', 'proj-2'] }); + expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', archive); + }); + + it('rejects providing both workflow and project IDs', async () => { + const command = new PackageExport([], {} as Config); + const internals = command as unknown as ExportInternals; + vi.spyOn(internals, 'parse').mockResolvedValue({ + flags: { + workflowId: ['wf-1'], + projectId: ['proj-1'], + output: '/tmp/export.n8np', + }, + }); + vi.spyOn(internals, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.run()).rejects.toThrow( + 'Provide either --workflow-id or --project-id, not both', + ); + }); }); diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index 2a3328f6f76..d4ced7d19e7 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -421,9 +421,11 @@ export class N8nClient { // ─── Packages (beta) ─────────────────────────────────────────── - async exportPackage(workflowIds: string[]): Promise { + async exportPackage( + fields: { workflowIds: string[] } | { projectIds: string[] }, + ): Promise { return await this.request('POST', '/n8n-packages/export', { - body: { workflowIds }, + body: fields, responseType: 'binary', }); } diff --git a/packages/@n8n/cli/src/commands/package/export.ts b/packages/@n8n/cli/src/commands/package/export.ts index 6ea633875f0..48d062e14bb 100644 --- a/packages/@n8n/cli/src/commands/package/export.ts +++ b/packages/@n8n/cli/src/commands/package/export.ts @@ -5,11 +5,13 @@ import { toPackagesError } from './shared'; import { BaseCommand } from '../../base-command'; export default class PackageExport extends BaseCommand { - static override description = 'Export workflows as an n8n package (.n8np)'; + static override description = 'Export workflows or projects as an n8n package (.n8np)'; static override examples = [ '<%= config.bin %> package export --workflow-id=abc --output=export.n8np', '<%= config.bin %> package export -w abc -w def -o team.n8np', + '<%= config.bin %> package export --project-id=abc -o project.n8np', + '<%= config.bin %> package export -p abc -p def -o projects.n8np', ]; static override flags = { @@ -18,9 +20,14 @@ export default class PackageExport extends BaseCommand { char: 'w', description: 'Workflow ID to include (repeat for multiple)', multiple: true, - required: true, aliases: ['workflow-id'], }), + projectId: Flags.string({ + char: 'p', + description: 'Project ID to include (repeat for multiple)', + multiple: true, + aliases: ['project-id'], + }), output: Flags.string({ char: 'o', description: 'File to write the package to', @@ -30,18 +37,39 @@ export default class PackageExport extends BaseCommand { async run(): Promise { const { flags } = await this.parse(PackageExport); + const workflowIds = flags.workflowId ?? []; + const projectIds = flags.projectId ?? []; + + if (workflowIds.length > 0 && projectIds.length > 0) { + this.error('Provide either --workflow-id or --project-id, not both'); + } + if (workflowIds.length === 0 && projectIds.length === 0) { + this.error('At least one --workflow-id or --project-id is required'); + } + await this.execute(async () => { const client = this.getClient(flags); let archive: Buffer; try { - archive = await client.exportPackage(flags.workflowId); + archive = await client.exportPackage( + workflowIds.length > 0 ? { workflowIds } : { projectIds }, + ); } catch (error) { throw toPackagesError(error); } fs.writeFileSync(flags.output, archive); - this.succeed(`Exported ${flags.workflowId.length} workflow(s) to ${flags.output}`, flags, { + + if (workflowIds.length > 0) { + this.succeed(`Exported ${workflowIds.length} workflow(s) to ${flags.output}`, flags, { + output: flags.output, + workflowIds, + }); + return; + } + + this.succeed(`Exported ${projectIds.length} project(s) to ${flags.output}`, flags, { output: flags.output, - workflowIds: flags.workflowId, + projectIds, }); }); } 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 9d8ca7f2f31..40b4ca9a6cc 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 @@ -74,6 +74,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "project:update", "project:delete", "project:list", + "project:export", "project:*", "saml:manage", "saml:*", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 1753f4af70e..146173e601b 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -23,7 +23,7 @@ export const RESOURCES = { license: ['manage'] as const, logStreaming: ['manage'] as const, orchestration: ['read', 'list'] as const, - project: [...DEFAULT_OPERATIONS] as const, + project: [...DEFAULT_OPERATIONS, 'export'] as const, saml: ['manage'] as const, securityAudit: ['generate'] as const, securitySettings: ['manage'] as const, @@ -88,7 +88,7 @@ export const API_KEY_RESOURCES = { workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate', 'export', 'import'] as const, variable: ['create', 'update', 'delete', 'list'] as const, securityAudit: ['generate'] as const, - project: ['create', 'update', 'delete', 'list'] as const, + project: ['create', 'update', 'delete', 'list', 'export'] as const, user: ['read', 'list', 'create', 'changeRole', 'delete'] as const, execution: ['delete', 'read', 'retry', 'list', 'stop'] as const, credential: ['create', 'read', 'update', 'move', '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 d3780c562f6..c20f6182f3c 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -18,6 +18,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'project:update', 'project:delete', 'project:list', + 'project:export', 'variable:create', 'variable:delete', 'variable:list', @@ -94,6 +95,7 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'workflow:move', 'workflow:activate', 'workflow:deactivate', + 'project:export', 'execution:delete', 'execution:read', 'execution:retry', @@ -147,6 +149,7 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [ 'workflow:move', 'workflow:activate', 'workflow:deactivate', + 'project:export', 'execution:delete', 'execution:read', 'execution:retry', 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 106aa13b5dd..dd034a7eb31 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -103,6 +103,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'project:read', 'project:update', 'project:delete', + 'project:export', 'insights:list', 'insights:read', 'folder:move', 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 c917290bb32..c0acc95ebf1 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -43,6 +43,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'project:read', 'project:update', 'project:delete', + 'project:export', 'folder:create', 'folder:read', 'folder:update', @@ -99,6 +100,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'execution:reveal', 'project:list', 'project:read', + 'project:export', 'folder:create', 'folder:read', 'folder:update', @@ -144,6 +146,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'credential:connect', 'project:list', 'project:read', + 'project:export', 'folder:create', 'folder:read', 'folder:update', @@ -173,6 +176,7 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'credential:read', 'project:list', 'project:read', + 'project:export', 'workflow:list', 'workflow:read', 'workflow:export', diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index 1538f3be96e..23ea26d62be 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -88,6 +88,10 @@ export const scopeInformation: Partial> = { displayName: 'Import Workflow', description: 'Allows importing workflows from a portable package into the project.', }, + 'project:export': { + displayName: 'Export Project', + description: 'Allows including projects in a portable package export.', + }, 'credential:unshare': { displayName: 'Unshare Credential', description: 'Allows removing credential shares.', diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index 009e1a2c771..4213fca5982 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -1,6 +1,7 @@ import type { z } from 'zod'; import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee'; +import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee'; import type { assignableGlobalRoleSchema, credentialSharingRoleSchema, @@ -13,7 +14,6 @@ import type { secretsProviderConnectionSharingRoleSchema, assignableProjectRoleSchema, } from './schemas.ee'; -import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee'; import { ALL_API_KEY_SCOPES } from './scope-information'; export type ScopeInformation = { diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 4a93468e469..367e298e550 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -825,7 +825,12 @@ export class ActiveWorkflowManager { await this.add(workflowId, activationMode, workflowData, { shouldPublish: false }); } catch (error) { this.errorReporter.error(error); - let lastTimeout = this.queuedActivations[workflowId].lastTimeout; + const queuedActivation = this.queuedActivations[workflowId]; + if (!queuedActivation) { + return; + } + + let lastTimeout = queuedActivation.lastTimeout; if (!(error instanceof IsolateError) && lastTimeout < WORKFLOW_REACTIVATE_MAX_TIMEOUT) { lastTimeout = Math.min(lastTimeout * 2, WORKFLOW_REACTIVATE_MAX_TIMEOUT); } @@ -841,8 +846,8 @@ export class ActiveWorkflowManager { }, ); - this.queuedActivations[workflowId].lastTimeout = lastTimeout; - this.queuedActivations[workflowId].timeout = setTimeout(retryFunction, lastTimeout); + queuedActivation.lastTimeout = lastTimeout; + queuedActivation.timeout = setTimeout(retryFunction, lastTimeout); return; } this.logger.info(`Activation of workflow "${workflowName}" (${workflowId}) was successful!`, { diff --git a/packages/cli/src/modules/n8n-packages/CLAUDE.md b/packages/cli/src/modules/n8n-packages/CLAUDE.md index 611af0f25ad..91e4c46797c 100644 --- a/packages/cli/src/modules/n8n-packages/CLAUDE.md +++ b/packages/cli/src/modules/n8n-packages/CLAUDE.md @@ -46,14 +46,14 @@ flowchart LR ### Adding an EXPORT property -1. **Module** — `n8n-packages.types.ts` `ExportWorkflowsRequest`; implement in - `n8n-packages.service.ts` `exportWorkflows` (+ `io/` writer or +1. **Module** — `n8n-packages.types.ts` `ExportPackageRequest`; implement in + `n8n-packages.service.ts` `exportPackage` (+ `io/` writer or `entities/*/` exporter as needed). -2. **DTO** — `@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts` - (`ExportWorkflowsRequestDto`, zod). -3. **Public API** — `n8n-packages.handler.ts` `exportWorkflows` reads from +2. **DTO** — `@n8n/api-types/src/dto/packages/export-package-request.dto.ts` + (`ExportPackageRequestDto`, zod). +3. **Public API** — `n8n-packages.handler.ts` `exportPackage` reads from `payload.data`; update the **separate** schema file - `spec/schemas/exportWorkflowsRequest.yml` (export's request schema is a + `spec/schemas/exportPackageRequest.yml` (export's request schema is a `$ref`, unlike import's inline schema). 4. **CLI** — `src/client.ts` `exportPackage(...)`, `src/commands/package/export.ts` flag, and the docs/README. diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts new file mode 100644 index 00000000000..1347ea4ca40 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts @@ -0,0 +1,150 @@ +import { + createTeamProject, + getPersonalProject, + linkUserToProject, + testDb, + testModules, +} from '@n8n/backend-test-utils'; +import type { User } 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('project package export', () => { + let service: N8nPackagesService; + + beforeAll(() => { + service = Container.get(N8nPackagesService); + }); + + async function exportSingleProject(user: User, projectId: string) { + const stream = await service.exportPackage({ user, projectIds: [projectId] }); + return await readExport(stream); + } + + async function exportProjects(user: User, projectIds: string[]) { + const stream = await service.exportPackage({ user, projectIds }); + return await readExport(stream); + } + + it('emits a tar with manifest.json first and project.json for an empty team project', async () => { + const owner = await createOwner(); + const project = await createTeamProject('Empty Project', owner); + + const { manifest, entries } = await exportSingleProject(owner, project.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.projects).toEqual([ + { id: project.id, name: 'Empty Project', target: expect.any(String) }, + ]); + expect(manifest.workflows).toBeUndefined(); + + const projectFile = entries.find( + (e) => e.name === `${manifest.projects![0].target}/project.json`, + ); + expect(projectFile).toBeDefined(); + const serialized = JSON.parse(projectFile!.content.toString()); + expect(serialized).toEqual({ + id: project.id, + name: 'Empty Project', + }); + }); + + it('exports multiple team projects in a single request', async () => { + const owner = await createOwner(); + const firstProject = await createTeamProject('Alpha Project', owner); + const secondProject = await createTeamProject('Beta Project', owner); + + const { manifest, entries } = await exportProjects(owner, [secondProject.id, firstProject.id]); + + expect(manifest.projects).toHaveLength(2); + expect(manifest.projects).toEqual( + expect.arrayContaining([ + { id: firstProject.id, name: 'Alpha Project', target: 'projects/alpha-project' }, + { id: secondProject.id, name: 'Beta Project', target: 'projects/beta-project' }, + ]), + ); + + for (const { id, name, target } of manifest.projects!) { + const projectFile = entries.find((entry) => entry.name === `${target}/project.json`); + expect(projectFile).toBeDefined(); + expect(JSON.parse(projectFile!.content.toString())).toEqual({ id, name }); + } + }); + + it('exports the owner personal project', async () => { + const owner = await createOwner(); + const personalProject = await getPersonalProject(owner); + + const { manifest, entries } = await exportSingleProject(owner, personalProject.id); + + expect(manifest.projects).toEqual([ + { id: personalProject.id, name: personalProject.name, target: expect.any(String) }, + ]); + + const projectFile = entries.find( + (entry) => entry.name === `${manifest.projects![0].target}/project.json`, + ); + expect(projectFile).toBeDefined(); + expect(JSON.parse(projectFile!.content.toString())).toEqual({ + id: personalProject.id, + name: personalProject.name, + }); + }); + + it('allows a project editor to export an empty team project', async () => { + const owner = await createOwner(); + const editor = await createMember(); + const project = await createTeamProject('Editor Project', owner); + await linkUserToProject(editor, project, 'project:editor'); + + const { manifest } = await exportSingleProject(editor, project.id); + + expect(manifest.projects).toEqual([ + { id: project.id, name: 'Editor Project', target: expect.any(String) }, + ]); + }); + + it('rejects export when the user lacks project access', async () => { + const owner = await createOwner(); + const outsider = await createMember(); + const project = await createTeamProject('Private Project', owner); + + await expect(exportSingleProject(outsider, project.id)).rejects.toThrow( + '1 project(s) not found or not accessible. Export aborted.', + ); + }); + + it("rejects exporting another user's personal project", async () => { + const projectOwner = await createMember(); + const otherUser = await createMember(); + const personalProject = await getPersonalProject(projectOwner); + + await expect(exportSingleProject(otherUser, personalProject.id)).rejects.toThrow( + '1 project(s) not found or not accessible. Export aborted.', + ); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts index d5a8a80fb51..bb1b497b421 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -64,7 +64,7 @@ describe('workflow package export — with credentials', () => { credential, }); - const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] }); + const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.credentials).toEqual([ @@ -122,7 +122,7 @@ describe('workflow package export — with credentials', () => { credential, }); - const stream = await service.exportWorkflows({ + const stream = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -152,7 +152,7 @@ describe('workflow package export — with credentials', () => { credentialType: 'httpHeaderAuth', }); - const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] }); + const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.credentials).toBeUndefined(); @@ -195,7 +195,7 @@ describe('workflow package export — with credentials', () => { // credential was never shared with them. The export must still succeed, // recording the credential as a requirement using the name+type carried // in the workflow JSON. - const stream = await service.exportWorkflows({ + const stream = await service.exportPackage({ user: sharee, workflowIds: [workflow.id], }); @@ -249,7 +249,7 @@ describe('workflow package export — with credentials', () => { const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); try { - await service.exportWorkflows({ user: owner, workflowIds: [wfA.id, wfB.id, wfC.id] }); + await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id, wfC.id] }); const exportedEvents = emitSpy.mock.calls.filter( ([name]) => name === 'n8n-package-exported', @@ -281,7 +281,7 @@ describe('workflow package export — with credentials', () => { const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); try { - await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] }); + await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const exportedEvents = emitSpy.mock.calls.filter( ([name]) => name === 'n8n-package-exported', @@ -303,7 +303,7 @@ describe('workflow package export — with credentials', () => { try { await expect( - service.exportWorkflows({ user: owner, workflowIds: ['does-not-exist'] }), + service.exportPackage({ user: owner, workflowIds: ['does-not-exist'] }), ).rejects.toThrow(/not found or not accessible/i); const exportedEvents = emitSpy.mock.calls.filter( 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 deleted file mode 100644 index aee8d1fa0ad..00000000000 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.controller.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { AuthenticatedRequest } from '@n8n/db'; -import { ControllerRegistryMetadata } from '@n8n/decorators'; -import { Container } from '@n8n/di'; -import type { Response } from 'express'; -import { PassThrough } from 'node:stream'; -import { mock } from 'vitest-mock-extended'; - -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('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 index d437e457d32..ef31ad1b484 100644 --- 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 @@ -37,7 +37,7 @@ describe('workflow package export', () => { }); async function exportSingleWorkflow(user: User, workflowId: string) { - const stream = await service.exportWorkflows({ user, workflowIds: [workflowId] }); + const stream = await service.exportPackage({ user, workflowIds: [workflowId] }); return await readExport(stream); } @@ -91,7 +91,7 @@ describe('workflow package export', () => { const wfA = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project); const wfB = await createWorkflow({ name: 'Beta', nodes: [], connections: {} }, project); - const stream = await service.exportWorkflows({ + const stream = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -112,7 +112,7 @@ describe('workflow package export', () => { const wfA = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); const wfB = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); - const stream = await service.exportWorkflows({ + const stream = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -140,7 +140,7 @@ describe('workflow package export', () => { const wf = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project); await expect( - service.exportWorkflows({ + service.exportPackage({ user: owner, workflowIds: [wf.id, 'missing-1', 'missing-2'], }), @@ -159,7 +159,7 @@ describe('workflow package export', () => { const outsider = await createMember(); await expect( - service.exportWorkflows({ user: outsider, workflowIds: [ownerWorkflow.id] }), + service.exportPackage({ user: outsider, workflowIds: [ownerWorkflow.id] }), ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); }); @@ -180,7 +180,7 @@ describe('workflow package export', () => { ); const error = (await service - .exportWorkflows({ + .exportPackage({ user: member, workflowIds: [memberWorkflow.id, ownerWorkflow.id], }) @@ -201,9 +201,9 @@ describe('workflow package export', () => { memberAPersonal, ); - await expect( - service.exportWorkflows({ user: memberB, workflowIds: [wf.id] }), - ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); + await expect(service.exportPackage({ 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 () => { @@ -218,7 +218,7 @@ describe('workflow package export', () => { await shareWorkflowWithUsers(wf, [sharee]); await expect( - service.exportWorkflows({ user: bystander, workflowIds: [wf.id] }), + service.exportPackage({ user: bystander, workflowIds: [wf.id] }), ).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.'); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.exporter.test.ts new file mode 100644 index 00000000000..3f562d1b438 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.exporter.test.ts @@ -0,0 +1,161 @@ +import type { Project, User } from '@n8n/db'; +import type { Readable } from 'node:stream'; +import { mock } from 'vitest-mock-extended'; + +import type { PackageWriter } from '../../../io/package-writer'; +import { ProjectExporter } from '../project.exporter'; +import { ProjectSerializer } from '../project.serializer'; + +import type { ProjectService } from '@/services/project.service.ee'; + +const user = mock({ id: 'user-1' }); + +function makeProject(overrides: Partial = {}): Project { + return { + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'billing', + type: 'team', + description: null, + icon: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + ...overrides, + } as Project; +} + +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({ + projects = [], +}: { + projects?: Project[]; +} = {}) { + const projectService = mock(); + + projectService.findProjectsByIdsForUser.mockImplementation(async (_user, projectIds) => { + const accessibleProjects = projects.filter((project) => projectIds.includes(project.id)); + return [...accessibleProjects].sort((left, right) => { + const createdAtDiff = left.createdAt.getTime() - right.createdAt.getTime(); + return createdAtDiff !== 0 ? createdAtDiff : left.id.localeCompare(right.id); + }); + }); + + const exporter = new ProjectExporter(projectService, new ProjectSerializer()); + return { exporter, projectService }; +} + +describe('ProjectExporter', () => { + it('checks project:export scope for the requested projects', async () => { + const project = makeProject(); + const { exporter, projectService } = makeExporter({ projects: [project] }); + const writer = new CapturingWriter(); + + await exporter.export({ user, projectIds: [project.id], writer }); + + expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith( + user, + [project.id], + ['project:export'], + ); + }); + + it('throws when the user lacks access to a requested project', async () => { + const project = makeProject(); + const { exporter } = makeExporter({ projects: [] }); + const writer = new CapturingWriter(); + + await expect(exporter.export({ user, projectIds: [project.id], writer })).rejects.toThrow( + '1 project(s) not found or not accessible. Export aborted.', + ); + }); + + it('exports an empty team project with project.json only', async () => { + const project = makeProject(); + const { exporter } = makeExporter({ projects: [project] }); + const writer = new CapturingWriter(); + + const { entries } = await exporter.export({ user, projectIds: [project.id], writer }); + + expect(entries).toEqual([ + { + id: project.id, + name: project.name, + target: 'projects/billing', + }, + ]); + expect(writer.directories).toEqual(['projects/billing']); + expect(writer.files).toEqual([ + { + path: 'projects/billing/project.json', + content: expect.stringContaining('"name": "billing"'), + }, + ]); + }); + + it('suffixes duplicate project names and sorts by createdAt for stable targets', async () => { + const olderProject = makeProject({ + id: 'project-older', + name: 'Billing', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }); + const newerProject = makeProject({ + id: 'project-newer', + name: 'Billing', + createdAt: new Date('2024-02-01T00:00:00.000Z'), + }); + const { exporter } = makeExporter({ + projects: [newerProject, olderProject], + }); + const writer = new CapturingWriter(); + + const { entries } = await exporter.export({ + user, + projectIds: [newerProject.id, olderProject.id], + writer, + }); + + expect(entries).toEqual([ + { + id: olderProject.id, + name: olderProject.name, + target: 'projects/billing', + }, + { + id: newerProject.id, + name: newerProject.name, + target: 'projects/billing-2', + }, + ]); + }); + + it('exports a personal project', async () => { + const project = makeProject({ id: 'personal-1', name: 'Personal', type: 'personal' }); + const { exporter } = makeExporter({ projects: [project] }); + const writer = new CapturingWriter(); + + const { entries } = await exporter.export({ user, projectIds: [project.id], writer }); + + expect(entries).toEqual([ + { + id: project.id, + name: project.name, + target: 'projects/personal', + }, + ]); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.serializer.test.ts b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.serializer.test.ts new file mode 100644 index 00000000000..733be254572 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/project/__tests__/project.serializer.test.ts @@ -0,0 +1,57 @@ +import type { Project } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import { ProjectSerializer } from '../project.serializer'; + +describe('ProjectSerializer', () => { + const serializer = new ProjectSerializer(); + + const baseProject = mock({ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'billing', + type: 'team', + description: null, + icon: null, + }); + + it('should serialize a project with only id and name when no optional fields', () => { + const result = serializer.serialize(baseProject); + + expect(result).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'billing', + }); + }); + + it('should include description when non-null', () => { + const project = { ...baseProject, description: 'Billing project' }; + const result = serializer.serialize(project as Project); + + expect(result).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'billing', + description: 'Billing project', + }); + }); + + it('should include icon when non-null', () => { + const project = { ...baseProject, icon: { type: 'emoji' as const, value: 'receipt' } }; + const result = serializer.serialize(project as Project); + + expect(result).toEqual({ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'billing', + icon: { type: 'emoji', value: 'receipt' }, + }); + }); + + it('should omit timestamps and relations', () => { + const result = serializer.serialize(baseProject); + + expect(result).not.toHaveProperty('createdAt'); + expect(result).not.toHaveProperty('updatedAt'); + expect(result).not.toHaveProperty('type'); + expect(result).not.toHaveProperty('creatorId'); + expect(result).not.toHaveProperty('projectRelations'); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts new file mode 100644 index 00000000000..b0418ee093a --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts @@ -0,0 +1,79 @@ +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +import { ProjectSerializer } from './project.serializer'; +import type { PackageWriter } from '../../io/package-writer'; +import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator'; +import type { ManifestEntry } from '../../spec/manifest.schema'; + +import { ProjectService } from '@/services/project.service.ee'; + +export interface ProjectExportRequest { + user: User; + projectIds: string[]; + writer: PackageWriter; +} + +export interface ProjectExportResult { + entries: ManifestEntry[]; +} + +@Service() +export class ProjectExporter { + constructor( + private readonly projectService: ProjectService, + private readonly projectSerializer: ProjectSerializer, + ) {} + + async export(request: ProjectExportRequest): Promise { + const projects = await this.projectService.findProjectsByIdsForUser( + request.user, + request.projectIds, + ['project:export'], + ); + + this.assertAllRequestedProjectsFound(request.projectIds, projects); + + const entries: ManifestEntry[] = []; + const targets = new UniqueFilenameAllocator('projects', 'project'); + + for (const project of projects) { + const target = targets.allocate(project.name); + const serialized = this.projectSerializer.serialize(project); + + request.writer.writeDirectory(target); + request.writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t')); + + entries.push({ + id: project.id, + name: project.name, + target, + }); + } + + return { entries }; + } + + private assertAllRequestedProjectsFound( + requestedProjectIds: string[], + foundProjects: Array<{ id: string }>, + ) { + const foundProjectIds = new Set(foundProjects.map(({ id }) => id)); + const missingProjectIds = requestedProjectIds.filter((id) => !foundProjectIds.has(id)); + + if (missingProjectIds.length > 0) { + const displayedProjectIds = missingProjectIds.slice(0, 20); + const omittedCount = missingProjectIds.length - displayedProjectIds.length; + + throw new UserError( + `${missingProjectIds.length} project(s) not found or not accessible. Export aborted.`, + { + description: `Missing project IDs: ${displayedProjectIds.join(', ')}${ + omittedCount > 0 ? `, and ${omittedCount} more` : '' + }`, + }, + ); + } + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts new file mode 100644 index 00000000000..0f69b234f0a --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts @@ -0,0 +1,19 @@ +import type { Project } from '@n8n/db'; +import { Service } from '@n8n/di'; + +import { + serializedProjectSchema, + type SerializedProject, +} from '../../spec/serialized/project.schema'; + +@Service() +export class ProjectSerializer { + serialize(project: Project): SerializedProject { + return serializedProjectSchema.parse({ + id: project.id, + name: project.name, + ...(project.description !== null ? { description: project.description } : {}), + ...(project.icon !== null ? { icon: project.icon } : {}), + }); + } +} 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 index 51e21b2c696..b1d6e5b34ac 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts @@ -2,8 +2,6 @@ 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 { UniqueFilenameAllocator } from '../../io/unique-filename-allocator'; @@ -11,6 +9,8 @@ import type { ManifestEntry } from '../../spec/manifest.schema'; import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor'; import type { WorkflowCredentialRequirement } from '../credential/credential.types'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + export interface WorkflowExportRequest { user: User; workflowIds: string[]; @@ -46,7 +46,7 @@ export class WorkflowExporter { const entries: ManifestEntry[] = []; const credentials: WorkflowCredentialRequirement[] = []; - const fileNames = new UniqueFilenameAllocator('workflows'); + const fileNames = new UniqueFilenameAllocator('workflows', 'workflow'); for (const workflow of workflows) { const target = fileNames.allocate(workflow.name); 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 index 5f91ba41376..79607d18df4 100644 --- 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 @@ -2,27 +2,23 @@ import { generateSlug } from '../slug.utils'; describe('generateSlug', () => { it('lower-cases and hyphenates the name', () => { - expect(generateSlug('My Workflow')).toBe('my-workflow'); + expect(generateSlug('My Workflow', 'workflow')).toBe('my-workflow'); }); it('collapses whitespace and strips disallowed characters', () => { - expect(generateSlug(' Hello, World! ')).toBe('hello-world'); + expect(generateSlug(' Hello, World! ', 'workflow')).toBe('hello-world'); }); it('removes emojis', () => { - expect(generateSlug('💳 Payments')).toBe('payments'); + expect(generateSlug('💳 Payments', 'workflow')).toBe('payments'); }); - it('falls back to "workflow" when the name yields an empty slug', () => { - expect(generateSlug('--')).toBe('workflow'); - }); - - it('uses a caller-provided fallback when the name yields an empty slug', () => { - expect(generateSlug('--', 'credential')).toBe('credential'); + it('uses the fallback when the name yields an empty slug', () => { + expect(generateSlug('--', 'workflow')).toBe('workflow'); expect(generateSlug('', 'credential')).toBe('credential'); }); it('strips leading and trailing hyphens', () => { - expect(generateSlug('---foo---')).toBe('foo'); + expect(generateSlug('---foo---', 'workflow')).toBe('foo'); }); }); diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts index 3ba19aa4f22..3099919114b 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/unique-filename-allocator.test.ts @@ -2,20 +2,20 @@ import { UniqueFilenameAllocator } from '../unique-filename-allocator'; describe('UniqueFilenameAllocator', () => { it('returns the slugged base path on first allocation', () => { - const allocator = new UniqueFilenameAllocator('workflows'); + const allocator = new UniqueFilenameAllocator('workflows', 'workflow'); expect(allocator.allocate('My Workflow')).toBe('workflows/my-workflow'); }); it('suffixes -2 when a slug collides with an earlier allocation', () => { - const allocator = new UniqueFilenameAllocator('workflows'); + const allocator = new UniqueFilenameAllocator('workflows', 'workflow'); allocator.allocate('Same Name'); expect(allocator.allocate('Same Name')).toBe('workflows/same-name-2'); }); it('keeps incrementing the suffix until it finds a free slot', () => { - const allocator = new UniqueFilenameAllocator('workflows'); + const allocator = new UniqueFilenameAllocator('workflows', 'workflow'); allocator.allocate('Same Name'); allocator.allocate('Same Name'); @@ -39,8 +39,8 @@ describe('UniqueFilenameAllocator', () => { }); it('keeps the per-instance used set independent across allocators', () => { - const a = new UniqueFilenameAllocator('workflows'); - const b = new UniqueFilenameAllocator('workflows'); + const a = new UniqueFilenameAllocator('workflows', 'workflow'); + const b = new UniqueFilenameAllocator('workflows', 'workflow'); expect(a.allocate('Same Name')).toBe('workflows/same-name'); expect(b.allocate('Same Name')).toBe('workflows/same-name'); diff --git a/packages/cli/src/modules/n8n-packages/io/slug.utils.ts b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts index 3052f9ad14d..01306b284e7 100644 --- a/packages/cli/src/modules/n8n-packages/io/slug.utils.ts +++ b/packages/cli/src/modules/n8n-packages/io/slug.utils.ts @@ -1,9 +1,7 @@ -const DEFAULT_EMPTY_SLUG_FALLBACK = 'workflow'; - /** * Generates a filesystem-safe slug from an entity name */ -export function generateSlug(name: string, fallback: string = DEFAULT_EMPTY_SLUG_FALLBACK): string { +export function generateSlug(name: string, fallback: string): string { let slug = name; slug = slug .toLowerCase() diff --git a/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts b/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts index c8ba3b54875..a7b41a3d047 100644 --- a/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts +++ b/packages/cli/src/modules/n8n-packages/io/unique-filename-allocator.ts @@ -5,7 +5,7 @@ export class UniqueFilenameAllocator { constructor( private readonly baseDir: string, - private readonly fallback?: string, + private readonly fallback: string, ) {} allocate(name: string): string { diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts deleted file mode 100644 index f538acf0095..00000000000 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ExportWorkflowsRequestDto } from '@n8n/api-types'; -import { AuthenticatedRequest } from '@n8n/db'; -import { Body, 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') - 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 index aa58878cedf..fb2a592dbb6 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.module.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.module.ts @@ -6,6 +6,6 @@ import { BackendModule } from '@n8n/decorators'; }) export class N8nPackagesModule implements ModuleInterface { async init() { - await import('./n8n-packages.controller'); + await import('./n8n-packages.service'); } } diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 7385aa533cc..0c736c92767 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -7,10 +7,11 @@ import { EventService } from '@/events/event.service'; import { ImportPipeline } from './engine/import-pipeline'; import { CredentialExporter } from './entities/credential/credential.exporter'; +import { ProjectExporter } from './entities/project/project.exporter'; import { WorkflowExporter } from './entities/workflow/workflow.exporter'; import { TarPackageWriter } from './io/tar/tar-package-writer'; import type { - ExportWorkflowsRequest, + ExportPackageRequest, ImportPackageRequest, ImportResult, } from './n8n-packages.types'; @@ -20,6 +21,7 @@ import { packageManifestSchema } from './spec/manifest.schema'; @Service() export class N8nPackagesService { constructor( + private readonly projectExporter: ProjectExporter, private readonly workflowExporter: WorkflowExporter, private readonly credentialExporter: CredentialExporter, private readonly instanceSettings: InstanceSettings, @@ -27,33 +29,48 @@ export class N8nPackagesService { private readonly eventService: EventService, ) {} - async exportWorkflows(request: ExportWorkflowsRequest): Promise { + async exportPackage(request: ExportPackageRequest): Promise { const writer = new TarPackageWriter(); + const workflowIds = request.workflowIds ?? []; + const projectIds = request.projectIds ?? []; - const { entries: workflowEntries, requirements: workflowRequirements } = - await this.workflowExporter.export({ - user: request.user, - workflowIds: request.workflowIds, - writer, - }); + const workflowExportResult = + workflowIds.length > 0 + ? await this.workflowExporter.export({ + user: request.user, + workflowIds, + writer, + }) + : undefined; - const { entries: credentialEntries, requirements: credentialRequirements } = - await this.credentialExporter.export({ - user: request.user, - requirements: workflowRequirements.credentials, - writer, - }); + const projectExportResult = + projectIds.length > 0 + ? await this.projectExporter.export({ + user: request.user, + projectIds, + writer, + }) + : undefined; + + const credentialExportResult = await this.credentialExporter.export({ + user: request.user, + requirements: workflowExportResult?.requirements?.credentials ?? [], + writer, + }); const manifest = packageManifestSchema.parse({ packageFormatVersion: FORMAT_VERSION, exportedAt: new Date().toISOString(), sourceN8nVersion: N8N_VERSION, sourceId: this.instanceSettings.instanceId, - workflows: workflowEntries, - ...(credentialEntries.length > 0 ? { credentials: credentialEntries } : {}), - ...(credentialRequirements.length > 0 - ? { requirements: { credentials: credentialRequirements } } + ...(credentialExportResult.entries.length > 0 + ? { credentials: credentialExportResult.entries } : {}), + ...(credentialExportResult.requirements.length > 0 + ? { requirements: { credentials: credentialExportResult.requirements } } + : {}), + ...(workflowExportResult?.entries ? { workflows: workflowExportResult.entries } : {}), + ...(projectExportResult?.entries ? { projects: projectExportResult.entries } : {}), }); writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t')); @@ -64,8 +81,8 @@ export class N8nPackagesService { this.eventService.emit('n8n-package-exported', { user: request.user, counts: { - workflows: workflowEntries.length, - credentials: credentialEntries.length, + workflows: workflowExportResult?.entries.length ?? 0, + credentials: credentialExportResult.entries.length, }, }); diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index 9c2cd85d2e6..0faf9ca5b31 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -35,9 +35,10 @@ export type WorkflowConflictPolicy = export type WorkflowIdPolicy = (typeof WorkflowIdPolicy)[keyof typeof WorkflowIdPolicy]; -export interface ExportWorkflowsRequest { +export interface ExportPackageRequest { user: User; - workflowIds: string[]; + workflowIds?: string[]; + projectIds?: string[]; } export type ImportPackageRequest = { diff --git a/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts index 4acf95eb477..d268ca51d79 100644 --- a/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts +++ b/packages/cli/src/modules/n8n-packages/spec/manifest.schema.ts @@ -9,6 +9,26 @@ export const manifestEntrySchema = z.object({ target: z.string().min(1), }); +type ManifestEntryInput = z.infer; + +function assertNoDuplicateIds( + items: Array> | undefined, + ctx: z.RefinementCtx, +): void { + if (!items) return; + + const seenIds = new Set(); + for (const item of items) { + if (seenIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate id in manifest: ${item.id}`, + }); + } + seenIds.add(item.id); + } +} + export const packageManifestSchema = z .object({ packageFormatVersion: z.literal(FORMAT_VERSION), @@ -16,21 +36,14 @@ export const packageManifestSchema = z sourceN8nVersion: z.string().min(1), sourceId: z.string().min(1), workflows: z.array(manifestEntrySchema).optional(), + projects: z.array(manifestEntrySchema).optional(), credentials: z.array(manifestEntrySchema).optional(), requirements: packageRequirementsSchema.optional(), }) .superRefine((manifest, ctx) => { - if (!manifest.workflows) return; - const seen = new Set(); - for (const entry of manifest.workflows) { - if (seen.has(entry.id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate workflow id in manifest: ${entry.id}`, - }); - } - seen.add(entry.id); - } + assertNoDuplicateIds(manifest.workflows, ctx); + assertNoDuplicateIds(manifest.projects, ctx); + assertNoDuplicateIds(manifest.credentials, ctx); }); export type ManifestEntry = z.infer; diff --git a/packages/cli/src/modules/n8n-packages/spec/serialized/project.schema.ts b/packages/cli/src/modules/n8n-packages/spec/serialized/project.schema.ts new file mode 100644 index 00000000000..aba65c6f4f0 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/serialized/project.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const projectIconSchema = z.object({ + type: z.enum(['emoji', 'icon']), + value: z.string(), +}); + +export const serializedProjectSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + icon: projectIconSchema.optional(), +}); + +export type SerializedProject = z.infer; diff --git a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts index 911f00081ed..3b8e5b9839e 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.controller.ee.ts @@ -10,11 +10,6 @@ import { hasGlobalScope } from '@n8n/permissions'; import * as express from 'express'; import type { PullResult } from 'simple-git'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { EventService } from '@/events/event.service'; -import { IWorkflowToImport } from '@/interfaces'; - import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants'; import { sourceControlEnabledMiddleware } from './middleware/source-control-enabled-middleware.ee'; import { SourceControlContextFactory } from './source-control-context.factory'; @@ -27,6 +22,11 @@ import { SourceControlRequest } from './types/requests'; import { SourceControlGetStatus } from './types/source-control-get-status'; import type { SourceControlPreferences } from './types/source-control-preferences'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { EventService } from '@/events/event.service'; +import { IWorkflowToImport } from '@/interfaces'; + @RestController('/source-control') export class SourceControlController { constructor( diff --git a/packages/cli/src/public-api/v1/__tests__/scope-parity.test.ts b/packages/cli/src/public-api/v1/__tests__/scope-parity.test.ts index c09bcf266fb..eede51c580b 100644 --- a/packages/cli/src/public-api/v1/__tests__/scope-parity.test.ts +++ b/packages/cli/src/public-api/v1/__tests__/scope-parity.test.ts @@ -99,7 +99,10 @@ describe('Public API scope parity', () => { test('every API key scope in API_KEY_RESOURCES is consumed by at least one endpoint', () => { const consumed = new Set( - ops.map((op) => op.requiredScope).filter((s): s is string => s !== null && s !== 'none'), + ops.flatMap((op) => { + if (op.requiredScope === null || op.requiredScope === 'none') return []; + return op.requiredScope.split(',').map((scope) => scope.trim()); + }), ); const declared = new Set(); for (const [resource, operations] of Object.entries(API_KEY_RESOURCES)) { diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts new file mode 100644 index 00000000000..1bd87a51cfc --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -0,0 +1,198 @@ +import { mockInstance } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { Container } from '@n8n/di'; +import type { Response } from 'express'; +import { PassThrough } from 'node:stream'; +import type { Mocked } from 'vitest'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; +import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; + +const mockMiddleware = vi.fn(async (_req: unknown, _res: unknown, next: unknown) => + (next as () => void)(), +) as unknown as middlewares.ScopeTaggedMiddleware; +vi.spyOn(middlewares, 'publicApiCompositeScope').mockReturnValue(mockMiddleware); +vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware); + +// Loaded after the middleware spies above are installed (the handler captures +// middleware at module-evaluation time). Typed loosely to invoke route entries. +let handler: Record unknown>>; +// exportPackage = [publicApiCompositeScope(...), businessLogic]; index 1 is the handler under test. +let exportPackage: (...args: unknown[]) => unknown; + +beforeAll(async () => { + handler = (await import('../n8n-packages.handler')) as unknown as typeof handler; + exportPackage = handler.exportPackage[1]; +}); + +describe('n8n-packages handler', () => { + let packagesEnabled: boolean; + let mockService: Mocked; + + function makeRequest( + body: { workflowIds?: string[]; projectIds?: string[] }, + apiKeyScopes?: string[], + ) { + return { + user: { id: 'user-1' }, + body, + tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined, + } as unknown as AuthenticatedRequest; + } + + function makeResponse() { + const res = new PassThrough() as unknown as Response & PassThrough; + res.setHeader = vi.fn().mockReturnValue(res); + return res; + } + + async function run(req: AuthenticatedRequest, res: Response) { + let caught: unknown; + try { + await exportPackage(req, res); + } catch (error) { + caught = error; + } + return caught; + } + + beforeEach(() => { + vi.clearAllMocks(); + packagesEnabled = true; + mockService = mockInstance(N8nPackagesService); + + vi.spyOn(Container, 'get').mockImplementation((serviceClass) => { + if (serviceClass === GlobalConfig) { + return { publicApi: { packagesEnabled } } as never; + } + if ( + serviceClass === N8nPackagesService || + (typeof serviceClass === 'function' && serviceClass.name === 'N8nPackagesService') + ) { + return mockService; + } + return {} as never; + }); + }); + + it('throws NotFoundError when packages are disabled', async () => { + packagesEnabled = false; + + const caught = await run( + makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), + makeResponse(), + ); + + expect(caught).toBeInstanceOf(NotFoundError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestError when the payload fails DTO validation', async () => { + const caught = await run( + makeRequest({ workflowIds: [''] }, ['workflow:export']), + makeResponse(), + ); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestError when both workflowIds and projectIds are provided', async () => { + const caught = await run( + makeRequest({ workflowIds: ['wf-1'], projectIds: ['project-1'] }, [ + 'workflow:export', + 'project:export', + ]), + makeResponse(), + ); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(caught).toMatchObject({ + message: 'Provide either workflowIds or projectIds, not both', + }); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestError when neither workflowIds nor projectIds are provided', async () => { + const caught = await run(makeRequest({}, ['workflow:export']), makeResponse()); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(caught).toMatchObject({ + message: 'At least one workflowId or projectId is required', + }); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when the API key carries no scopes', async () => { + const caught = await run(makeRequest({ workflowIds: ['wf-1'] }), makeResponse()); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when exporting workflows without workflow:export scope', async () => { + const caught = await run( + makeRequest({ workflowIds: ['wf-1'] }, ['project:export']), + makeResponse(), + ); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when exporting projects without project:export scope', async () => { + const caught = await run( + makeRequest({ projectIds: ['project-1'] }, ['workflow:export']), + makeResponse(), + ); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('streams the export for a valid workflow request', async () => { + const stream = new PassThrough(); + mockService.exportPackage.mockResolvedValue(stream); + const res = makeResponse(); + + const resultPromise = run( + makeRequest({ workflowIds: ['wf-1', 'wf-2'] }, ['workflow:export']), + res, + ); + stream.end(Buffer.from('package-bytes')); + const caught = await resultPromise; + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + user: { id: 'user-1' }, + workflowIds: ['wf-1', 'wf-2'], + projectIds: [], + }); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip'); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="export.n8np"', + ); + }); + + it('streams the export for a valid project request', async () => { + const stream = new PassThrough(); + mockService.exportPackage.mockResolvedValue(stream); + const res = makeResponse(); + + const resultPromise = run(makeRequest({ projectIds: ['project-1'] }, ['project:export']), res); + stream.end(Buffer.from('package-bytes')); + const caught = await resultPromise; + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + user: { id: 'user-1' }, + workflowIds: [], + projectIds: ['project-1'], + }); + }); +}); 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 index d3fd761bb79..f91e42eec96 100644 --- 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 @@ -1,59 +1,113 @@ -import { ExportWorkflowsRequestDto, ImportPackageRequestDto } from '@n8n/api-types'; +import { ExportPackageRequestDto, ImportPackageRequestDto } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import type { AuthenticatedRequest } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { ApiKeyScope } from '@n8n/permissions'; import type { Response } from 'express'; +import type { Readable } from 'node:stream'; + +import type { PackageRequest } from '../../../types'; +import type { PublicAPIEndpoint } from '../../shared/handler.types'; +import { + publicApiCompositeScope, + publicApiScope, +} from '../../shared/middlewares/global.middleware'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; import { resolveImportPackageUpload } from '@/modules/n8n-packages/utils/import-package-upload'; -import type { PackageRequest } from '../../../types'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { publicApiScope } from '../../shared/middlewares/global.middleware'; +const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export'; -type ExportWorkflowsRequest = AuthenticatedRequest<{}, {}, { workflowIds: string[] }>; +type ExportPackageRequest = AuthenticatedRequest< + {}, + {}, + { workflowIds?: string[]; projectIds?: string[] } +>; type ImportPackageRequest = PackageRequest.Import & { files?: Express.Multer.File[]; }; type N8nPackagesHandlers = { - exportWorkflows: PublicAPIEndpoint; + exportPackage: PublicAPIEndpoint; importPackage: PublicAPIEndpoint; }; +function assertPackageExportApiKeyScopes( + req: AuthenticatedRequest, + workflowIds: string[], + projectIds: string[], +) { + const apiKeyScopes = req.tokenGrant?.apiKeyScopes; + if (!apiKeyScopes) { + throw new ForbiddenError('Forbidden'); + } + + const requiredScopes: ApiKeyScope[] = []; + if (workflowIds.length > 0) { + requiredScopes.push('workflow:export'); + } + if (projectIds.length > 0) { + requiredScopes.push('project:export'); + } + + for (const scope of requiredScopes) { + if (!apiKeyScopes.includes(scope)) { + throw new ForbiddenError('Forbidden'); + } + } +} + +async function streamPackageExport(res: Response, stream: Readable): Promise { + 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); + }); +} + const n8nPackagesHandlers: N8nPackagesHandlers = { - exportWorkflows: [ - publicApiScope('workflow:export'), + exportPackage: [ + publicApiCompositeScope(PACKAGE_EXPORT_SCOPES), async (req, res) => { if (!Container.get(GlobalConfig).publicApi.packagesEnabled) { throw new NotFoundError('Not Found'); } - const payload = ExportWorkflowsRequestDto.safeParse(req.body); + const payload = ExportPackageRequestDto.safeParse(req.body); if (!payload.success) { throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; ')); } + if (payload.data.workflowIds && payload.data.projectIds) { + throw new BadRequestError('Provide either workflowIds or projectIds, not both'); + } - const stream = await Container.get(N8nPackagesService).exportWorkflows({ + const workflowIds = payload.data.workflowIds ?? []; + const projectIds = payload.data.projectIds ?? []; + + if (workflowIds.length === 0 && projectIds.length === 0) { + throw new BadRequestError('At least one workflowId or projectId is required'); + } + + assertPackageExportApiKeyScopes(req, workflowIds, projectIds); + + const stream = await Container.get(N8nPackagesService).exportPackage({ user: req.user, - workflowIds: payload.data.workflowIds, + workflowIds, + projectIds, }); - 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); - }); + return await streamPackageExport(res, stream); }, ], importPackage: [ 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 index 46b1c364836..9301367ecc7 100644 --- 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 @@ -1,27 +1,31 @@ post: - x-eov-operation-id: exportWorkflows - x-required-scope: workflow:export + x-eov-operation-id: exportPackage + x-required-scope: project:export,workflow:export x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler tags: - N8nPackage - summary: 'Beta: Export workflows as an n8n package' + summary: 'Beta: Export workflows and projects 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. + Export either workflows or projects as a gzipped tar archive (.n8np). + Provide `workflowIds` or `projectIds`, but not both. Empty projects export + project metadata only. The response is streamed as `application/gzip` with a + `Content-Disposition` attachment header. Requires the n8n Packages feature to be + licensed. + + API key scopes: `workflow:export` is required when exporting workflows, and `project:export` is required when exporting projects. requestBody: - description: Workflows to include in the exported package. + description: Workflows or projects to include in the exported package. required: true content: application/json: schema: - $ref: '../schemas/exportWorkflowsRequest.yml' + $ref: '../schemas/exportPackageRequest.yml' responses: '200': - description: A gzipped tar archive containing the exported workflows. + description: A gzipped tar archive containing the exported package contents. content: application/gzip: schema: 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/exportPackageRequest.yml similarity index 58% rename from packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportWorkflowsRequest.yml rename to packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml index d38f57d36c8..ddf5a8618ef 100644 --- 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/exportPackageRequest.yml @@ -1,11 +1,8 @@ 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: @@ -13,3 +10,11 @@ properties: minLength: 1 example: - 2tUt1wbLX592XDdX + projectIds: + type: array + description: IDs of the projects to include in the exported package. + items: + type: string + minLength: 1 + example: + - Ox8O54VQrmBrb4qL diff --git a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts index 2bde71d18de..542a9eae335 100644 --- a/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts @@ -128,6 +128,20 @@ function makePublicApiScopeEnforcementMiddleware(endpointScope: ApiKeyScope) { export const publicApiScope = (apiKeyScope: ApiKeyScope) => tagMiddleware(makePublicApiScopeEnforcementMiddleware(apiKeyScope), apiKeyScope); +/** + * Tags an endpoint with a composite `x-required-scope` value (comma-separated). + * Actual scope enforcement is deferred to the handler, which picks the required + * scope based on the request payload. + */ +export function publicApiCompositeScope(requiredScopes: string): ScopeTaggedMiddleware { + return Object.assign( + (_req: Request, _res: Response, next: NextFunction) => { + next(); + }, + { __apiKeyScope: requiredScopes as ApiKeyScope }, + ); +} + export const apiKeyHasScopeWithGlobalScopeFallback = ( config: { scope: ApiKeyScope & Scope } | { apiKeyScope: ApiKeyScope; globalScope: Scope }, ) => { diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index f34a85c013b..30291e8378d 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -713,6 +713,33 @@ export class ProjectService { return projects.map((p) => p.id); } + async findProjectsByIdsForUser( + user: User, + projectIds: string[], + scopes: Scope[], + ): Promise { + if (projectIds.length === 0) { + return []; + } + + const where: FindOptionsWhere = { + id: In(projectIds), + }; + + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = await this.roleService.rolesWithScope('project', scopes); + where.projectRelations = { + role: In(projectRoles), + userId: user.id, + }; + } + + return await this.projectRepository.find({ + where, + order: { createdAt: 'ASC', id: 'ASC' }, + }); + } + /** * Add a user to a team project with specified roles. * diff --git a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts new file mode 100644 index 00000000000..baa1d7fa88b --- /dev/null +++ b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts @@ -0,0 +1,43 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; + +import { createOwnerWithApiKey } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); + +let owner: User; +let authOwnerAgent: SuperAgentTest; + +beforeAll(async () => { + owner = await createOwnerWithApiKey(); + Container.get(InstanceSettings).markAsLeader(); +}); + +beforeEach(async () => { + authOwnerAgent = testServer.publicApiAgentFor(owner); + Container.get(GlobalConfig).publicApi.packagesEnabled = true; +}); + +afterEach(async () => { + Container.get(GlobalConfig).publicApi.packagesEnabled = false; + await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']); +}); + +describe('POST /n8n-packages/export', () => { + test('rejects requests that provide both workflowIds and projectIds', async () => { + const response = await authOwnerAgent.post('/n8n-packages/export').send({ + workflowIds: ['wf-1'], + projectIds: ['project-1'], + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + message: 'Provide either workflowIds or projectIds, not both', + }); + }); +}); diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index 48983999e60..5a88be71798 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -15,13 +15,13 @@ import { } from 'n8n-workflow'; import { agent as testAgent } from 'supertest'; -import { NodeTypes } from '@/node-types'; -import { WebhookServer } from '@/webhooks/webhook-server'; - import { createUser } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; import { initActiveWorkflowManager } from './shared/utils'; +import { NodeTypes } from '@/node-types'; +import { WebhookServer } from '@/webhooks/webhook-server'; + vi.unmock('node:fs'); class WebhookTestingNode implements INodeType {