refactor(API): Migrate n8n-packages export endpoint to PublicApiController (no-changelog) (#40756)
Some checks failed
Build: Benchmark Image / build (push) Has been cancelled
CI: Master (Build, Test, Lint) / Build & format (push) Has been cancelled
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Has been cancelled
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Has been cancelled
CI: Master (Build, Test, Lint) / Lint (push) Has been cancelled
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Has been cancelled
Util: Publish API Schema / Cloudflare Pages (push) Has been cancelled
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Has been cancelled

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Wooler 2026-10-13 11:56:11 +00:00 committed by GitHub
parent 1ee379898b
commit 4fcb628fca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 938 additions and 999 deletions

View File

@ -198,6 +198,13 @@ describe('ExportPackageRequestDto', () => {
});
});
describe('unknown fields', () => {
it('rejects a key the DTO does not declare', () => {
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['a'], evil: 'x' });
expect(result.success).toBe(false);
});
});
describe('includeArchivedWorkflows', () => {
it.each([true, false])('accepts %s', (includeArchivedWorkflows) => {
const result = ExportPackageRequestDto.safeParse({

View File

@ -1,25 +1,59 @@
import { z } from 'zod';
import '../../openapi-extend';
import { exportPackageRequestFieldDocs } from './export-package-request.openapi';
import { Z } from '../../zod-class';
export class ExportPackageRequestDto extends Z.class({
agentIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
folderIds: 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(),
includeVariableValues: z.boolean().default(true),
includeTags: z.boolean().default(true),
dependencyPolicy: z
.enum(['fail', 'reference-only', 'include-in-package'])
.optional()
.default('fail'),
versionPolicy: z
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
.optional()
.default('latest'),
credentialExportPolicy: z
.enum(['expression-values-only', 'no-values'])
.optional()
.default('expression-values-only'),
includeArchivedWorkflows: z.boolean().default(false),
}) {}
export class ExportPackageRequestDto extends Z.class(
{
agentIds: z
.array(z.string().trim().min(1))
.min(1)
.max(300)
.optional()
.openapi(exportPackageRequestFieldDocs.agentIds),
workflowIds: z
.array(z.string().trim().min(1))
.min(1)
.max(300)
.optional()
.openapi(exportPackageRequestFieldDocs.workflowIds),
folderIds: z
.array(z.string().trim().min(1))
.min(1)
.max(300)
.optional()
.openapi(exportPackageRequestFieldDocs.folderIds),
projectIds: z
.array(z.string().trim().min(1))
.min(1)
.max(300)
.optional()
.openapi(exportPackageRequestFieldDocs.projectIds),
includeVariableValues: z
.boolean()
.default(true)
.openapi(exportPackageRequestFieldDocs.includeVariableValues),
includeTags: z.boolean().default(true).openapi(exportPackageRequestFieldDocs.includeTags),
dependencyPolicy: z
.enum(['fail', 'reference-only', 'include-in-package'])
.optional()
.default('fail')
.openapi(exportPackageRequestFieldDocs.dependencyPolicy),
versionPolicy: z
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
.optional()
.default('latest')
.openapi(exportPackageRequestFieldDocs.versionPolicy),
credentialExportPolicy: z
.enum(['expression-values-only', 'no-values'])
.optional()
.default('expression-values-only')
.openapi(exportPackageRequestFieldDocs.credentialExportPolicy),
includeArchivedWorkflows: z
.boolean()
.default(false)
.openapi(exportPackageRequestFieldDocs.includeArchivedWorkflows),
},
{ strict: true },
) {}

View File

@ -0,0 +1,50 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const exportPackageRequestFieldDocs = {
agentIds: {
description: 'IDs of the agents to include. Requires the agents module.',
example: ['uT9LdQx7rK2MvB4f'],
},
workflowIds: {
description: 'IDs of the workflows to include in the exported package.',
example: ['2tUt1wbLX592XDdX'],
},
folderIds: {
description:
'IDs of the folders to include in the exported package. Each folder is exported with its nested folders.',
example: ['9xKp2mNqRzAbCdEf'],
},
projectIds: {
description:
'IDs of the projects to include in the exported package. Includes their agents when the agents module is enabled.',
example: ['Ox8O54VQrmBrb4qL'],
},
includeVariableValues: {
description:
'Whether values of variables referenced by the exported entities are bundled into the package. When `false`, variables still travel as name/type files and are listed in the package requirements, but no values travel with the package.',
},
includeTags: {
description:
'Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag files, tag references, or tag requirements travel with the package.',
},
dependencyPolicy: {
description:
'Policy for workflow and agent dependencies outside the selected package contents. Includes static workflow references and disabled agent references. `fail` aborts when a required workflow or agent is absent from the package. `include-in-package` adds accessible dependencies recursively. `reference-only` records external requirements without including those definitions. The same policy applies to workflows and agents.',
example: 'fail' as const,
},
versionPolicy: {
description:
'Which version of each workflow and agent travels in the package. `latest` exports the current draft. `published-strict` requires a published version. `prefer-published` uses the published version when available and the draft otherwise. `ignore-unpublished` skips unpublished selections. Dependencies that cannot be included cause the export to fail only when `dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`, the export records them as external requirements. The selected version supplies the definition and its references. Workflow names, settings, and tags always use their current values. Agent IDs, names, and MCP availability always use their current values.',
example: 'latest' as const,
},
credentialExportPolicy: {
description:
'Whether expression values from credential data are bundled into the package. `expression-values-only` includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); literal values never travel either way. `no-values` keeps credential data out of the package entirely, so each credential file carries only its id, name and type.',
example: 'expression-values-only' as const,
},
includeArchivedWorkflows: {
description:
'Whether folder and project exports include their archived workflows. When `false` (default) they are left out. When `true` they travel with `isArchived: true` and are archived on import. Workflows listed in `workflowIds` always export, also when archived.',
example: false,
},
} satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -163,15 +163,6 @@ export default defineConfig(
'n8n-local-rules/require-public-api-controller': 'error',
},
},
{
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',
},
},
{
files: ['./src/**/*.ts'],
ignores: ['./src/modules/engine-v2/**/*.ts'],

View File

@ -156,15 +156,6 @@ export default defineConfig({
'n8n-local-rules/require-public-api-controller': 'error',
},
},
{
// Ratchet allowlist: legacy `export =` handler tuples pending migration to
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',
},
},
{
files: ['./src/**/*.ts'],
excludeFiles: ['./src/modules/engine-v2/**/*.ts'],

View File

@ -69,11 +69,11 @@ flowchart LR
`n8n-packages.service.ts` `exportPackage` (+ `io/` writer or
`entities/*/` exporter as needed).
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/exportPackageRequest.yml` (export's request schema is a
`$ref`, unlike import's inline schema).
(`ExportPackageRequestDto`, zod). Add the field's OpenAPI wording to
`export-package-request.openapi.ts`.
3. **Public API** — `packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts`
`exportPackage` reads from `body`. The request schema comes from
`ExportPackageRequestDto`, so do not edit a YAML file.
4. **CLI** — `src/client.ts` `exportPackage(...)`,
`src/commands/package/export.ts` flag, and the docs/README.

View File

@ -0,0 +1,438 @@
import { ExportPackageRequestDto } from '@n8n/api-types';
import type { EventService } from '@n8n/backend-services';
import type { AuthenticatedRequest } from '@n8n/db';
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { Readable } from 'node:stream';
import { mock } from 'vitest-mock-extended';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import {
PackageEntityAccessDeniedError,
PackageEntityNotFoundError,
PackageExportBlockedError,
} from '@/modules/n8n-packages/entities/package-export.errors';
import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import { N8nPackagesPublicController } from '../n8n-packages.public.controller';
const EXPORT_COUNTS = {
agents: 0,
workflows: 2,
folders: 1,
credentials: 0,
dataTables: 0,
variables: 0,
tags: 0,
};
const DEFAULT_SERVICE_OPTIONS = {
user: { id: 'user-1' },
agentIds: [],
workflowIds: [],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
};
describe('N8nPackagesPublicController', () => {
let controller: N8nPackagesPublicController;
const mockService = mock<N8nPackagesService>();
const mockEventService = mock<EventService>();
function makeRequest(apiKeyScopes?: string[]) {
return {
user: { id: 'user-1' },
tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined,
} as unknown as AuthenticatedRequest;
}
function mockExportStream() {
mockService.exportPackage.mockResolvedValue({
stream: Readable.from([Buffer.from('package-bytes')]),
counts: EXPORT_COUNTS,
});
}
async function runAndCatch(
body: Record<string, unknown>,
apiKeyScopes?: string[],
res: Response = mock<Response>(),
) {
try {
await controller.exportPackage(
makeRequest(apiKeyScopes),
res,
ExportPackageRequestDto.parse(body),
);
return undefined;
} catch (error) {
return error;
}
}
function emittedEvent(name: 'n8n-package-export-failed' | 'n8n-package-import-failed') {
const call = mockEventService.emit.mock.calls.find(([eventName]) => eventName === name);
return call?.[1] as RelayEventMap[typeof name] | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
mockService.exportPackage.mockReset();
controller = new N8nPackagesPublicController(mockService, mockEventService);
});
describe('exportPackage', () => {
it('throws BadRequestError when both workflowIds and projectIds are provided', async () => {
const caught = await runAndCatch({ workflowIds: ['wf-1'], projectIds: ['project-1'] }, [
'workflow:export',
'project:export',
]);
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both',
});
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws BadRequestError when folderIds and projectIds are both provided', async () => {
const caught = await runAndCatch({ folderIds: ['fld-1'], projectIds: ['project-1'] }, [
'workflow:export',
'project:export',
]);
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both',
});
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws BadRequestError when neither workflowIds, folderIds nor projectIds are provided', async () => {
const caught = await runAndCatch({}, ['workflow:export']);
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'At least one agentId, workflowId, folderId, or projectId is required',
});
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws ForbiddenError when the API key carries no scopes', async () => {
const caught = await runAndCatch({ workflowIds: ['wf-1'] });
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({
reason: 'access-denied',
workflowIds: ['wf-1'],
});
});
it('throws ForbiddenError when exporting workflows without workflow:export scope', async () => {
const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['project:export']);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws ForbiddenError when exporting projects without project:export scope', async () => {
const caught = await runAndCatch({ projectIds: ['project-1'] }, ['workflow:export']);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws ForbiddenError when exporting folders without workflow:export scope', async () => {
const caught = await runAndCatch({ folderIds: ['fld-1'] }, ['project:export']);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => {
mockExportStream();
const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export']);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1'],
});
});
it('allows value-less export without variable:list scope', async () => {
mockExportStream();
const caught = await runAndCatch({ workflowIds: ['wf-1'], includeVariableValues: false }, [
'workflow:export',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1'],
includeVariableValues: false,
});
});
it('propagates the ForbiddenError thrown by the service scope gate and emits access-denied', async () => {
mockService.exportPackage.mockRejectedValue(
new ForbiddenError('missing the variable:list scope'),
);
const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export']);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({
reason: 'access-denied',
workflowIds: ['wf-1'],
});
});
it('emits n8n-package-export-failed with reason=entity-not-found when the service rejects with NotFoundError', async () => {
mockService.exportPackage.mockRejectedValue(new NotFoundError('not found'));
await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']);
expect(emittedEvent('n8n-package-export-failed')).toEqual({
user: { id: 'user-1' },
reason: 'entity-not-found',
workflowIds: ['wf-1'],
});
});
it('emits n8n-package-export-failed with reason=blocked when the service rejects a blocked export', async () => {
mockService.exportPackage.mockRejectedValue(new PackageExportBlockedError('Export blocked'));
await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']);
expect(emittedEvent('n8n-package-export-failed')).toEqual({
user: { id: 'user-1' },
reason: 'blocked',
workflowIds: ['wf-1'],
});
});
it.each([
[
'access-denied',
new PackageEntityAccessDeniedError('workflows denied', { description: 'x' }),
],
[
'entity-not-found',
new PackageEntityNotFoundError('workflows missing', { description: 'y' }),
],
])(
'rethrows %s as a generic UserError with the same message, hiding which case occurred',
async (reason, thrownError) => {
mockService.exportPackage.mockRejectedValue(thrownError);
const caught = await runAndCatch({ workflowIds: ['wf-1'] }, [
'workflow:export',
'variable:list',
]);
expect(caught).toBeInstanceOf(UserError);
expect(caught).not.toBeInstanceOf(PackageEntityAccessDeniedError);
expect(caught).not.toBeInstanceOf(PackageEntityNotFoundError);
expect(caught).toMatchObject({ message: thrownError.message });
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason });
},
);
it('returns the export stream and headers for a valid workflow request', async () => {
mockExportStream();
const result = await controller.exportPackage(
makeRequest(['workflow:export', 'variable:list']),
mock<Response>(),
ExportPackageRequestDto.parse({ workflowIds: ['wf-1', 'wf-2'] }),
);
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1', 'wf-2'],
canExportVariableValues: true,
});
expect(result.body).toBeInstanceOf(Readable);
expect(result.headers).toEqual({
'Content-Disposition': 'attachment; filename="export.n8np"',
'X-N8n-Export-Counts': JSON.stringify(EXPORT_COUNTS),
'Access-Control-Expose-Headers': 'X-N8n-Export-Counts',
});
expect(mockEventService.emit).not.toHaveBeenCalled();
});
it('does not emit n8n-package-export-failed when the client disconnects after the stream is returned', async () => {
const prematureClose = Object.assign(new Error('Premature close'), {
code: 'ERR_STREAM_PREMATURE_CLOSE',
});
const stream = new Readable({ read() {} });
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
await controller.exportPackage(
makeRequest(['workflow:export']),
mock<Response>(),
ExportPackageRequestDto.parse({ workflowIds: ['wf-1'] }),
);
// The response reads the stream after the controller returns, so the disconnect happens here.
stream.on('error', () => undefined);
stream.destroy(prematureClose);
await new Promise((resolve) => setImmediate(resolve));
expect(mockEventService.emit).not.toHaveBeenCalled();
});
it('forwards a non-default dependency policy', async () => {
mockExportStream();
const caught = await runAndCatch(
{ workflowIds: ['wf-1'], dependencyPolicy: 'reference-only' },
['workflow:export'],
);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1'],
dependencyPolicy: 'reference-only',
});
});
it('forwards a non-default version policy', async () => {
mockExportStream();
const caught = await runAndCatch(
{ workflowIds: ['wf-1'], versionPolicy: 'published-strict' },
['workflow:export'],
);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ versionPolicy: 'published-strict' }),
);
});
it('forwards agentIds and requires the agent:export scope for them', async () => {
mockExportStream();
const caught = await runAndCatch({ agentIds: ['agent-1'] }, [
'agent:export',
'variable:list',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
agentIds: ['agent-1'],
canExportVariableValues: true,
});
});
it('throws ForbiddenError when exporting agents without agent:export scope', async () => {
const caught = await runAndCatch({ agentIds: ['agent-1'] }, ['workflow:export']);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('forwards a non-default credential export policy', async () => {
mockExportStream();
const caught = await runAndCatch(
{ workflowIds: ['wf-1'], credentialExportPolicy: 'no-values' },
['workflow:export'],
);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ credentialExportPolicy: 'no-values' }),
);
});
it('forwards includeArchivedWorkflows', async () => {
mockExportStream();
const caught = await runAndCatch({ workflowIds: ['wf-1'], includeArchivedWorkflows: true }, [
'workflow:export',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ includeArchivedWorkflows: true }),
);
});
it('streams the export for a valid project request', async () => {
mockExportStream();
const caught = await runAndCatch({ projectIds: ['project-1'] }, [
'project:export',
'variable:list',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
projectIds: ['project-1'],
canExportVariableValues: true,
});
});
it('streams the export for a valid folder request', async () => {
mockExportStream();
const caught = await runAndCatch({ folderIds: ['fld-1'] }, [
'workflow:export',
'variable:list',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
folderIds: ['fld-1'],
canExportVariableValues: true,
});
});
it('forwards includeVariableValues=false to the service', async () => {
mockExportStream();
const caught = await runAndCatch({ workflowIds: ['wf-1'], includeVariableValues: false }, [
'workflow:export',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1'],
includeVariableValues: false,
});
});
it('forwards includeTags=false to the service', async () => {
mockExportStream();
const caught = await runAndCatch({ workflowIds: ['wf-1'], includeTags: false }, [
'workflow:export',
]);
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
...DEFAULT_SERVICE_OPTIONS,
workflowIds: ['wf-1'],
includeTags: false,
});
});
});
});

View File

@ -1,4 +1,5 @@
import {
ExportPackageRequestDto,
ImportBlockedErrorDto,
ImportPackageRequestDto,
ImportPackageSelectionRequestDto,
@ -18,11 +19,18 @@ import {
Body,
Post,
PublicApiController,
type BinaryResult,
} from '@n8n/decorators';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import {
PackageEntityAccessDeniedError,
PackageEntityNotFoundError,
} from '@/modules/n8n-packages/entities/package-export.errors';
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import type { ImportResult } from '@/modules/n8n-packages/n8n-packages.types';
import type { ExportPackageResult, ImportResult } from '@/modules/n8n-packages/n8n-packages.types';
import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier';
import {
IMPORT_PACKAGE_FIELD_SIZE_BYTES,
@ -31,6 +39,10 @@ import {
} from '@/modules/n8n-packages/utils/import-package-upload';
import {
EXPORT_200_DESCRIPTION,
EXPORT_COUNTS_HEADER_DESCRIPTION,
EXPORT_DESCRIPTION,
EXPORT_SUMMARY,
IMPORT_409_DESCRIPTION,
IMPORT_422_DESCRIPTION,
IMPORT_DESCRIPTION,
@ -39,9 +51,11 @@ import {
IMPORT_SELECTION_DESCRIPTION,
IMPORT_SELECTION_SUMMARY,
IMPORT_SUMMARY,
IMPORT_TAGS,
PACKAGE_TAGS,
} from './openapi/n8n-packages.openapi';
const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts';
function uploadLimits(maxParts: number) {
const maxFileSizeBytes = Container.get(GlobalConfig).endpoints.payloadSizeMax * 1024 * 1024;
return {
@ -52,6 +66,38 @@ function uploadLimits(maxParts: number) {
};
}
function assertPackageExportApiKeyScopes(
apiKeyScopes: string[] | undefined,
agentIds: string[],
workflowIds: string[],
folderIds: string[],
projectIds: string[],
): string[] {
if (!apiKeyScopes) {
throw new ForbiddenError('Forbidden');
}
const requiredScopes: string[] = [];
if (agentIds.length > 0) {
requiredScopes.push('agent:export');
}
// Folders are exported as a workflow-organization concern, so they share the workflow:export scope.
if (workflowIds.length > 0 || folderIds.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');
}
}
return apiKeyScopes;
}
@PublicApiController('/n8n-packages')
export class N8nPackagesPublicController {
constructor(
@ -63,7 +109,7 @@ export class N8nPackagesPublicController {
@ApiKeyScope('workflow:import')
@ApiSummary(IMPORT_SUMMARY)
@ApiDescription(IMPORT_DESCRIPTION)
@ApiTags(IMPORT_TAGS)
@ApiTags(PACKAGE_TAGS)
@ApiResponse(200, ImportResultDto)
@ApiErrorResponse(404)
@ApiErrorResponse(409, { dto: ImportBlockedErrorDto, description: IMPORT_409_DESCRIPTION })
@ -106,7 +152,7 @@ export class N8nPackagesPublicController {
@ApiKeyScope('workflow:import')
@ApiSummary(IMPORT_SELECTION_SUMMARY)
@ApiDescription(IMPORT_SELECTION_DESCRIPTION)
@ApiTags(IMPORT_TAGS)
@ApiTags(PACKAGE_TAGS)
@ApiResponse(200, ImportResultDto)
@ApiErrorResponse(404)
@ApiErrorResponse(409, {
@ -156,4 +202,90 @@ export class N8nPackagesPublicController {
throw error;
}
}
@Post('/export')
@ApiKeyScope({ anyOf: ['project:export', 'workflow:export', 'agent:export'] })
@ApiSummary(EXPORT_SUMMARY)
@ApiDescription(EXPORT_DESCRIPTION)
@ApiTags(PACKAGE_TAGS)
@ApiResponse(200, {
mediaType: 'application/gzip',
description: EXPORT_200_DESCRIPTION,
headers: { [EXPORT_COUNTS_HEADER]: { description: EXPORT_COUNTS_HEADER_DESCRIPTION } },
})
@ApiErrorResponse(404)
async exportPackage(
req: AuthenticatedRequest,
_res: Response,
@Body({ required: true }) body: ExportPackageRequestDto,
): Promise<BinaryResult> {
const { agentIds = [], workflowIds = [], folderIds = [], projectIds = [] } = body;
let exportResult: ExportPackageResult;
try {
const hasLooseSelection = [agentIds, workflowIds, folderIds].some((ids) => ids.length > 0);
if (projectIds.length > 0 && hasLooseSelection) {
throw new BadRequestError(
'Provide either agentIds/workflowIds/folderIds or projectIds, not both',
);
}
if (!hasLooseSelection && projectIds.length === 0) {
throw new BadRequestError(
'At least one agentId, workflowId, folderId, or projectId is required',
);
}
const apiKeyScopes = assertPackageExportApiKeyScopes(
req.tokenGrant?.apiKeyScopes,
agentIds,
workflowIds,
folderIds,
projectIds,
);
exportResult = await this.n8nPackagesService.exportPackage({
user: req.user,
agentIds,
workflowIds,
folderIds,
projectIds,
includeVariableValues: body.includeVariableValues,
canExportVariableValues: apiKeyScopes.includes('variable:list'),
includeTags: body.includeTags,
dependencyPolicy: body.dependencyPolicy,
versionPolicy: body.versionPolicy,
credentialExportPolicy: body.credentialExportPolicy,
includeArchivedWorkflows: body.includeArchivedWorkflows,
});
} catch (error) {
this.eventService.emit('n8n-package-export-failed', {
user: req.user,
reason: classifyPackageFailure(error),
...(agentIds.length ? { agentIds } : {}),
...(workflowIds.length ? { workflowIds } : {}),
...(folderIds.length ? { folderIds } : {}),
...(projectIds.length ? { projectIds } : {}),
});
if (
error instanceof PackageEntityAccessDeniedError ||
error instanceof PackageEntityNotFoundError
) {
throw new UserError(error.message, { description: error.description });
}
throw error;
}
return {
body: exportResult.stream,
headers: {
'Content-Disposition': 'attachment; filename="export.n8np"',
[EXPORT_COUNTS_HEADER]: JSON.stringify(exportResult.counts),
// Cross-origin browser clients can only read the counts header if it is exposed.
'Access-Control-Expose-Headers': EXPORT_COUNTS_HEADER,
},
};
}
}

View File

@ -1,4 +1,37 @@
export const IMPORT_TAGS = ['N8nPackage'];
export const PACKAGE_TAGS = ['N8nPackage'];
export const EXPORT_SUMMARY =
'Beta: Export agents, workflows, folders, or projects as an n8n package';
export const EXPORT_DESCRIPTION =
'**Beta** — breaking changes may still occur without major version bump.\n\n' +
'Export agents, workflows, and folders, or whole projects, as a gzipped tar\n' +
'archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`.\n' +
'Do not combine these two groups. Each exported folder includes its nested\n' +
'folders. Whole projects include their agents when the agents module is enabled.\n' +
'Explicit agent selections require the agents module. Empty projects export\n' +
'project metadata only. `versionPolicy` selects the version for both agents and\n' +
'workflows and defaults to `latest`. `dependencyPolicy` applies to both entity\n' +
'types and defaults to `fail` when a required definition is absent from the\n' +
'package. The response is streamed as `application/gzip` with a `Content-Disposition`\n' +
'attachment header. Requires the n8n Packages feature to be licensed.\n\n' +
'API key scopes: `agent:export` is required for explicitly selected agents.\n' +
'`workflow:export` is required for explicitly selected workflows or folders.\n' +
'`project:export` is sufficient for whole-project exports, including their\n' +
'agents and workflows. Included dependencies need no additional API key scope.\n' +
"The caller's project permissions apply to all included entities. When\n" +
'`includeVariableValues` is true (the default) and exported entities reference\n' +
'variables, `variable:list` is also required. Exports that reference no variables\n' +
'do not need it.\n\n' +
'Agent definitions include referenced skills, custom tools, and schedules.\n' +
'Knowledge base files and runtime data are excluded. Agent package imports\n' +
'remain unavailable.\n';
export const EXPORT_200_DESCRIPTION =
'A gzipped tar archive containing the exported package contents.';
export const EXPORT_COUNTS_HEADER_DESCRIPTION =
'JSON-serialized per-entity counts of what actually ended up in the\n' +
'package (after folder bundling and auto-inclusion), e.g.\n' +
'`{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`.\n';
export const IMPORT_SUMMARY = 'Beta: Import an n8n package into a project';
export const IMPORT_DESCRIPTION =

View File

@ -1,591 +0,0 @@
import { EventService } from '@n8n/backend-services';
import { mockInstance } from '@n8n/backend-test-utils';
import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { PassThrough } from 'node:stream';
import type { Mocked } from 'vitest';
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import {
PackageEntityAccessDeniedError,
PackageEntityNotFoundError,
PackageExportBlockedError,
} from '@/modules/n8n-packages/entities/package-export.errors';
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<string, Array<(...args: unknown[]) => unknown>>;
// exportPackage = [middleware(...), businessLogic]; index 1 is the handler under test.
let exportPackage: (...args: unknown[]) => unknown;
beforeAll(async () => {
handler = (await import('../n8n-packages.handler.js')) as unknown as typeof handler;
exportPackage = handler.exportPackage[1];
});
const EXPORT_COUNTS = {
agents: 0,
workflows: 2,
folders: 1,
credentials: 0,
dataTables: 0,
variables: 0,
tags: 0,
};
describe('n8n-packages handler', () => {
let mockService: Mocked<N8nPackagesService>;
let mockEventService: Mocked<EventService>;
function makeRequest(
body: {
workflowIds?: string[];
folderIds?: string[];
projectIds?: string[];
includeVariableValues?: boolean;
includeTags?: boolean;
dependencyPolicy?: string;
versionPolicy?: string;
credentialExportPolicy?: string;
includeArchivedWorkflows?: boolean;
},
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;
}
function emittedEvent(name: 'n8n-package-export-failed' | 'n8n-package-import-failed') {
const call = mockEventService.emit.mock.calls.find(([eventName]) => eventName === name);
return call?.[1] as RelayEventMap[typeof name] | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
mockService = mockInstance(N8nPackagesService);
mockEventService = mockInstance(EventService);
vi.spyOn(Container, 'get').mockImplementation((serviceClass) => {
if (
serviceClass === N8nPackagesService ||
(typeof serviceClass === 'function' && serviceClass.name === 'N8nPackagesService')
) {
return mockService;
}
if (
serviceClass === EventService ||
(typeof serviceClass === 'function' && serviceClass.name === 'EventService')
) {
return mockEventService;
}
return {} as never;
});
});
describe('exportPackage', () => {
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();
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason: 'validation' });
});
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 agentIds/workflowIds/folderIds or projectIds, not both',
});
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws BadRequestError when folderIds and projectIds are both provided', async () => {
const caught = await run(
makeRequest({ folderIds: ['fld-1'], projectIds: ['project-1'] }, [
'workflow:export',
'project:export',
]),
makeResponse(),
);
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both',
});
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('throws BadRequestError when neither workflowIds, folderIds nor projectIds are provided', async () => {
const caught = await run(makeRequest({}, ['workflow:export']), makeResponse());
expect(caught).toBeInstanceOf(BadRequestError);
expect(caught).toMatchObject({
message: 'At least one agentId, workflowId, folderId, 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();
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({
reason: 'access-denied',
workflowIds: ['wf-1'],
});
});
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('throws ForbiddenError when exporting folders without workflow:export scope', async () => {
const caught = await run(
makeRequest({ folderIds: ['fld-1'] }, ['project:export']),
makeResponse(),
);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(mockService.exportPackage).not.toHaveBeenCalled();
});
it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), res);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('allows value-less export without variable:list scope', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], includeVariableValues: false }, ['workflow:export']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: false,
canExportVariableValues: false,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('propagates the ForbiddenError thrown by the service scope gate and emits access-denied', async () => {
mockService.exportPackage.mockRejectedValue(
new ForbiddenError('missing the variable:list scope'),
);
const caught = await run(
makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']),
makeResponse(),
);
expect(caught).toBeInstanceOf(ForbiddenError);
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({
reason: 'access-denied',
workflowIds: ['wf-1'],
});
});
it('emits n8n-package-export-failed with reason=entity-not-found when the service rejects with NotFoundError', async () => {
mockService.exportPackage.mockRejectedValue(new NotFoundError('not found'));
await run(
makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']),
makeResponse(),
);
expect(emittedEvent('n8n-package-export-failed')).toEqual({
user: { id: 'user-1' },
reason: 'entity-not-found',
workflowIds: ['wf-1'],
});
});
it('emits n8n-package-export-failed with reason=blocked when the service rejects a blocked export', async () => {
mockService.exportPackage.mockRejectedValue(new PackageExportBlockedError('Export blocked'));
await run(
makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']),
makeResponse(),
);
expect(emittedEvent('n8n-package-export-failed')).toEqual({
user: { id: 'user-1' },
reason: 'blocked',
workflowIds: ['wf-1'],
});
});
it.each([
[
'access-denied',
new PackageEntityAccessDeniedError('workflows denied', { description: 'x' }),
],
[
'entity-not-found',
new PackageEntityNotFoundError('workflows missing', { description: 'y' }),
],
])(
'rethrows %s as a generic UserError with the same message, hiding which case occurred',
async (reason, thrownError) => {
mockService.exportPackage.mockRejectedValue(thrownError);
const caught = await run(
makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']),
makeResponse(),
);
expect(caught).toBeInstanceOf(UserError);
expect(caught).not.toBeInstanceOf(PackageEntityAccessDeniedError);
expect(caught).not.toBeInstanceOf(PackageEntityNotFoundError);
expect(caught).toMatchObject({ message: thrownError.message });
expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason });
},
);
it('streams the export for a valid workflow request', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1', 'wf-2'] }, ['workflow:export', 'variable:list']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1', 'wf-2'],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="export.n8np"',
);
expect(res.setHeader).toHaveBeenCalledWith(
'X-N8n-Export-Counts',
JSON.stringify(EXPORT_COUNTS),
);
expect(res.setHeader).toHaveBeenCalledWith(
'Access-Control-Expose-Headers',
'X-N8n-Export-Counts',
);
expect(mockEventService.emit).not.toHaveBeenCalled();
});
it('forwards a non-default missing workflow dependency policy', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest(
{
workflowIds: ['wf-1'],
dependencyPolicy: 'reference-only',
},
['workflow:export'],
),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: true,
dependencyPolicy: 'reference-only',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('forwards a non-default workflow version policy', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], versionPolicy: 'published-strict' }, [
'workflow:export',
]),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ versionPolicy: 'published-strict' }),
);
});
it('forwards a non-default credential export policy', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], credentialExportPolicy: 'no-values' }, [
'workflow:export',
]),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ credentialExportPolicy: 'no-values' }),
);
});
it('forwards includeArchivedWorkflows', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], includeArchivedWorkflows: true }, ['workflow:export']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith(
expect.objectContaining({ includeArchivedWorkflows: true }),
);
});
it('streams the export for a valid project request', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ projectIds: ['project-1'] }, ['project:export', 'variable:list']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: [],
folderIds: [],
projectIds: ['project-1'],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('streams the export for a valid folder request', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ folderIds: ['fld-1'] }, ['workflow:export', 'variable:list']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: [],
folderIds: ['fld-1'],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('forwards includeVariableValues=false to the service', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], includeVariableValues: false }, ['workflow:export']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: false,
canExportVariableValues: false,
includeTags: true,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
it('forwards includeTags=false to the service', async () => {
const stream = new PassThrough();
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
const res = makeResponse();
const resultPromise = run(
makeRequest({ workflowIds: ['wf-1'], includeTags: false }, ['workflow:export']),
res,
);
stream.end(Buffer.from('package-bytes'));
const caught = await resultPromise;
expect(caught).toBeUndefined();
expect(mockService.exportPackage).toHaveBeenCalledWith({
agentIds: [],
user: { id: 'user-1' },
workflowIds: ['wf-1'],
folderIds: [],
projectIds: [],
includeVariableValues: true,
canExportVariableValues: false,
includeTags: false,
dependencyPolicy: 'fail',
versionPolicy: 'latest',
credentialExportPolicy: 'expression-values-only',
includeArchivedWorkflows: false,
});
});
});
});

View File

@ -1,167 +0,0 @@
import { ExportPackageRequestDto } from '@n8n/api-types';
import { EventService } from '@n8n/backend-services';
import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import type { ApiKeyScope } from '@n8n/permissions';
import type { Response } from 'express';
import { UserError } from 'n8n-workflow';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import {
PackageEntityAccessDeniedError,
PackageEntityNotFoundError,
} from '@/modules/n8n-packages/entities/package-export.errors';
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import type { ExportPackageResult } from '@/modules/n8n-packages/n8n-packages.types';
import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { publicApiCompositeScope } from '../../shared/middlewares/global.middleware';
const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export,agent:export';
/** Header carrying the JSON-serialized true per-entity counts of the exported package. */
const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts';
type ExportPackageRequest = AuthenticatedRequest<{}, {}, ExportPackageRequestDto>;
type N8nPackagesHandlers = {
exportPackage: PublicAPIEndpoint<ExportPackageRequest>;
};
function assertPackageExportApiKeyScopes(
req: AuthenticatedRequest,
agentIds: string[],
workflowIds: string[],
folderIds: string[],
projectIds: string[],
): string[] {
const apiKeyScopes = req.tokenGrant?.apiKeyScopes;
if (!apiKeyScopes) {
throw new ForbiddenError('Forbidden');
}
const requiredScopes: ApiKeyScope[] = [];
if (agentIds.length > 0) {
requiredScopes.push('agent:export');
}
// Folders are exported as a workflow-organization concern, so they share the workflow:export scope.
if (workflowIds.length > 0 || folderIds.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');
}
}
return apiKeyScopes;
}
async function streamPackageExport(
res: Response,
{ stream, counts }: ExportPackageResult,
): Promise<Response> {
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
res.setHeader(EXPORT_COUNTS_HEADER, JSON.stringify(counts));
// Cross-origin browser clients can only read the counts header if it's exposed.
res.setHeader('Access-Control-Expose-Headers', EXPORT_COUNTS_HEADER);
return await new Promise<Response>((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 = {
exportPackage: [
publicApiCompositeScope(PACKAGE_EXPORT_SCOPES),
async (req, res) => {
let agentIds: string[] = [];
let workflowIds: string[] = [];
let folderIds: string[] = [];
let projectIds: string[] = [];
let includeVariableValues: boolean = true;
try {
const payload = ExportPackageRequestDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; '));
}
agentIds = payload.data.agentIds ?? [];
workflowIds = payload.data.workflowIds ?? [];
folderIds = payload.data.folderIds ?? [];
projectIds = payload.data.projectIds ?? [];
includeVariableValues = payload.data.includeVariableValues;
const hasLooseSelection = [agentIds, workflowIds, folderIds].some((ids) => ids.length > 0);
if (projectIds.length > 0 && hasLooseSelection) {
throw new BadRequestError(
'Provide either agentIds/workflowIds/folderIds or projectIds, not both',
);
}
if (!hasLooseSelection && projectIds.length === 0) {
throw new BadRequestError(
'At least one agentId, workflowId, folderId, or projectId is required',
);
}
const apiKeyScopes = assertPackageExportApiKeyScopes(
req,
agentIds,
workflowIds,
folderIds,
projectIds,
);
const exportResult = await Container.get(N8nPackagesService).exportPackage({
user: req.user,
agentIds,
workflowIds,
folderIds,
projectIds,
includeVariableValues,
canExportVariableValues: apiKeyScopes.includes('variable:list'),
includeTags: payload.data.includeTags,
dependencyPolicy: payload.data.dependencyPolicy,
versionPolicy: payload.data.versionPolicy,
credentialExportPolicy: payload.data.credentialExportPolicy,
includeArchivedWorkflows: payload.data.includeArchivedWorkflows,
});
return await streamPackageExport(res, exportResult);
} catch (error) {
Container.get(EventService).emit('n8n-package-export-failed', {
user: req.user,
reason: classifyPackageFailure(error),
...(agentIds.length ? { agentIds } : {}),
...(workflowIds.length ? { workflowIds } : {}),
...(folderIds.length ? { folderIds } : {}),
...(projectIds.length ? { projectIds } : {}),
});
if (
error instanceof PackageEntityAccessDeniedError ||
error instanceof PackageEntityNotFoundError
) {
throw new UserError(error.message, { description: error.description });
}
throw error;
}
},
],
};
export = n8nPackagesHandlers;

View File

@ -0,0 +1,149 @@
operationId: exportPackage
tags:
- N8nPackage
summary: 'Beta: Export agents, workflows, folders, or projects as an n8n package'
description: |
**Beta** — breaking changes may still occur without major version bump.
Export agents, workflows, and folders, or whole projects, as a gzipped tar
archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`.
Do not combine these two groups. Each exported folder includes its nested
folders. Whole projects include their agents when the agents module is enabled.
Explicit agent selections require the agents module. Empty projects export
project metadata only. `versionPolicy` selects the version for both agents and
workflows and defaults to `latest`. `dependencyPolicy` applies to both entity
types and defaults to `fail` when a required definition is absent from the
package. The response is streamed as `application/gzip` with a `Content-Disposition`
attachment header. Requires the n8n Packages feature to be licensed.
API key scopes: `agent:export` is required for explicitly selected agents.
`workflow:export` is required for explicitly selected workflows or folders.
`project:export` is sufficient for whole-project exports, including their
agents and workflows. Included dependencies need no additional API key scope.
The caller's project permissions apply to all included entities. When
`includeVariableValues` is true (the default) and exported entities reference
variables, `variable:list` is also required. Exports that reference no variables
do not need it.
Agent definitions include referenced skills, custom tools, and schedules.
Knowledge base files and runtime data are excluded. Agent package imports
remain unavailable.
x-required-scope: project:export,workflow:export,agent:export
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
agentIds:
type: array
items:
type: string
minLength: 1
minItems: 1
maxItems: 300
description: IDs of the agents to include. Requires the agents module.
example:
- uT9LdQx7rK2MvB4f
workflowIds:
type: array
items:
type: string
minLength: 1
minItems: 1
maxItems: 300
description: IDs of the workflows to include in the exported package.
example:
- 2tUt1wbLX592XDdX
folderIds:
type: array
items:
type: string
minLength: 1
minItems: 1
maxItems: 300
description: IDs of the folders to include in the exported package. Each folder is exported with its nested folders.
example:
- 9xKp2mNqRzAbCdEf
projectIds:
type: array
items:
type: string
minLength: 1
minItems: 1
maxItems: 300
description: IDs of the projects to include in the exported package. Includes their agents when the agents module is enabled.
example:
- Ox8O54VQrmBrb4qL
includeVariableValues:
type: boolean
default: true
description: Whether values of variables referenced by the exported entities are bundled into the package. When `false`, variables still travel as name/type files and are listed in the package requirements, but no values travel with the package.
includeTags:
type: boolean
default: true
description: Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag files, tag references, or tag requirements travel with the package.
dependencyPolicy:
type: string
enum:
- fail
- reference-only
- include-in-package
default: fail
description: Policy for workflow and agent dependencies outside the selected package contents. Includes static workflow references and disabled agent references. `fail` aborts when a required workflow or agent is absent from the package. `include-in-package` adds accessible dependencies recursively. `reference-only` records external requirements without including those definitions. The same policy applies to workflows and agents.
example: fail
versionPolicy:
type: string
enum:
- published-strict
- prefer-published
- ignore-unpublished
- latest
default: latest
description: Which version of each workflow and agent travels in the package. `latest` exports the current draft. `published-strict` requires a published version. `prefer-published` uses the published version when available and the draft otherwise. `ignore-unpublished` skips unpublished selections. Dependencies that cannot be included cause the export to fail only when `dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`, the export records them as external requirements. The selected version supplies the definition and its references. Workflow names, settings, and tags always use their current values. Agent IDs, names, and MCP availability always use their current values.
example: latest
credentialExportPolicy:
type: string
enum:
- expression-values-only
- no-values
default: expression-values-only
description: Whether expression values from credential data are bundled into the package. `expression-values-only` includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); literal values never travel either way. `no-values` keeps credential data out of the package entirely, so each credential file carries only its id, name and type.
example: expression-values-only
includeArchivedWorkflows:
type: boolean
default: false
description: 'Whether folder and project exports include their archived workflows. When `false` (default) they are left out. When `true` they travel with `isArchived: true` and are archived on import. Workflows listed in `workflowIds` always export, also when archived.'
example: false
additionalProperties: false
responses:
'200':
description: A gzipped tar archive containing the exported package contents.
headers:
X-N8n-Export-Counts:
description: |
JSON-serialized per-entity counts of what actually ended up in the
package (after folder bundling and auto-inclusion), e.g.
`{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`.
required: true
schema:
type: string
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
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -1,64 +0,0 @@
post:
x-eov-operation-id: exportPackage
x-required-scope: project:export,workflow:export,agent:export
x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler
tags:
- N8nPackage
summary: 'Beta: Export agents, workflows, folders, or projects as an n8n package'
description: |
**Beta** — breaking changes may still occur without major version bump.
Export agents, workflows, and folders, or whole projects, as a gzipped tar
archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`.
Do not combine these two groups. Each exported folder includes its nested
folders. Whole projects include their agents when the agents module is enabled.
Explicit agent selections require the agents module. Empty projects export
project metadata only. `versionPolicy` selects the version for both agents and
workflows and defaults to `latest`. `dependencyPolicy` applies to both entity
types and defaults to `fail` when a required definition is absent from the
package. The response is streamed as `application/gzip` with a `Content-Disposition`
attachment header. Requires the n8n Packages feature to be licensed.
API key scopes: `agent:export` is required for explicitly selected agents.
`workflow:export` is required for explicitly selected workflows or folders.
`project:export` is sufficient for whole-project exports, including their
agents and workflows. Included dependencies need no additional API key scope.
The caller's project permissions apply to all included entities. When
`includeVariableValues` is true (the default) and exported entities reference
variables, `variable:list` is also required. Exports that reference no variables
do not need it.
Agent definitions include referenced skills, custom tools, and schedules.
Knowledge base files and runtime data are excluded. Agent package imports
remain unavailable.
requestBody:
description: Agents, workflows, folders, or projects to include in the exported package.
required: true
content:
application/json:
schema:
$ref: '../schemas/exportPackageRequest.yml'
responses:
'200':
description: A gzipped tar archive containing the exported package contents.
headers:
X-N8n-Export-Counts:
description: |
JSON-serialized per-entity counts of what actually ended up in the
package (after folder bundling and auto-inclusion), e.g.
`{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`.
schema:
type: string
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'

View File

@ -1,120 +0,0 @@
type: object
additionalProperties: false
description: >-
Selects what to export. Provide `agentIds`, `workflowIds`, and/or `folderIds`
to export loose entities, or `projectIds` to export whole projects.
Do not combine these two groups. Supply at least one ID.
properties:
agentIds:
type: array
minItems: 1
maxItems: 300
description: IDs of the agents to include. Requires the agents module.
items:
type: string
minLength: 1
example:
- uT9LdQx7rK2MvB4f
workflowIds:
type: array
maxItems: 300
description: IDs of the workflows to include in the exported package.
items:
type: string
minLength: 1
example:
- 2tUt1wbLX592XDdX
folderIds:
type: array
maxItems: 300
description: >-
IDs of the folders to include in the exported package. Each folder is
exported with its nested folders.
items:
type: string
minLength: 1
example:
- 9xKp2mNqRzAbCdEf
projectIds:
type: array
description: >-
IDs of the projects to include in the exported package.
Includes their agents when the agents module is enabled.
items:
type: string
minLength: 1
example:
- Ox8O54VQrmBrb4qL
includeVariableValues:
type: boolean
default: true
description: >-
Whether values of variables referenced by the exported entities are
bundled into the package. When `false`, variables still travel as
name/type files and are listed in the package requirements, but no
values travel with the package.
includeTags:
type: boolean
default: true
description: >-
Whether tags assigned to the exported workflows are bundled into the
package. When `false`, no tag files, tag references, or tag
requirements travel with the package.
dependencyPolicy:
type: string
enum:
- fail
- reference-only
- include-in-package
description: >-
Policy for workflow and agent dependencies outside the selected package
contents. Includes static workflow references and disabled agent references.
`fail` aborts when a required workflow or agent is absent from the package.
`include-in-package` adds accessible dependencies recursively.
`reference-only` records external requirements without including those
definitions. The same policy applies to workflows and agents.
example: fail
default: fail
versionPolicy:
type: string
enum:
- published-strict
- prefer-published
- ignore-unpublished
- latest
description: >-
Which version of each workflow and agent travels in the package. `latest`
exports the current draft. `published-strict` requires a published version.
`prefer-published` uses the published version when available and the draft
otherwise. `ignore-unpublished` skips unpublished selections.
Dependencies that cannot be included cause the export to fail only when
`dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`,
the export records them as external requirements.
The selected version supplies the definition and its references.
Workflow names, settings, and tags always use their current values.
Agent IDs, names, and MCP availability always use their current values.
example: latest
default: latest
credentialExportPolicy:
type: string
enum:
- expression-values-only
- no-values
description: >-
Whether expression values from credential data are bundled into the
package. `expression-values-only` includes credential fields whose value
is an n8n expression (for example `={{ $secrets.apiKey }}`); literal
values never travel either way. `no-values` keeps credential data out of
the package entirely, so each credential file carries only its id, name
and type.
example: expression-values-only
default: expression-values-only
includeArchivedWorkflows:
type: boolean
description: >-
Whether folder and project exports include their archived workflows. When
`false` (default) they are left out. When `true` they travel with
`isArchived: true` and are archived on import. Workflows listed in
`workflowIds` always export, also when archived.
example: false
default: false

View File

@ -189,6 +189,9 @@ paths:
/n8n-packages/import-selection:
post:
$ref: ./handlers/n8n-packages/spec/paths/importPackageSelection.generated.yml
/n8n-packages/export:
post:
$ref: ./handlers/n8n-packages/spec/paths/exportPackage.generated.yml
/node-type-policies/instance:
get:
$ref: ./handlers/node-type-policies/spec/paths/getNodeTypeInstancePolicy.generated.yml

View File

@ -78,9 +78,7 @@ tags:
- name: Workflow
description: Operations about workflows
paths:
/n8n-packages/export:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
paths: {}
components:
schemas:
$ref: './shared/spec/schemas/_index.yml'

View File

@ -192,16 +192,31 @@ describe('POST /n8n-packages/export', () => {
const response = await authOwnerAgent
.post('/n8n-packages/export')
.buffer(true)
// supertest buffers only text and JSON bodies by default
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
})
.send({ folderIds: [folder.id] });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('application/gzip');
expect(response.headers['content-disposition']).toContain('export.n8np');
const counts = JSON.parse(response.headers['x-n8n-export-counts']);
expect(typeof counts.workflows).toBe('number');
expect(typeof counts.folders).toBe('number');
expect(response.headers['access-control-expose-headers']).toBe('X-N8n-Export-Counts');
// Gzip magic number
const body: Buffer = response.body;
expect([body[0], body[1]]).toEqual([0x1f, 0x8b]);
});
// The OpenAPI request schema has `additionalProperties: false`, so acceptance
// proves `includeTags` is declared in exportPackageRequest.yml.
test('accepts includeTags=false through the OpenAPI request validator', async () => {
// The request DTO is strict, so acceptance proves `includeTags` is a declared field.
test('accepts includeTags=false through the request DTO', async () => {
const project = await createTeamProject('Export project', owner);
const folder = await createFolder(project, { name: 'to_production' });
@ -212,8 +227,8 @@ describe('POST /n8n-packages/export', () => {
expect(response.statusCode).toBe(200);
});
// Acceptance proves `credentialExportPolicy` is declared in exportPackageRequest.yml.
test('accepts credentialExportPolicy=no-values through the OpenAPI request validator', async () => {
// Acceptance proves `credentialExportPolicy` is a declared field of the request DTO.
test('accepts credentialExportPolicy=no-values through the request DTO', async () => {
const project = await createTeamProject('Export project', owner);
const folder = await createFolder(project, { name: 'to_production' });
@ -234,4 +249,44 @@ describe('POST /n8n-packages/export', () => {
expect(response.statusCode).toBe(400);
});
test('rejects an unauthenticated request with 401', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
.post('/n8n-packages/export')
.send({ workflowIds: ['wf-1'] });
expect(response.statusCode).toBe(401);
});
test('rejects a key with neither project:export nor workflow:export with 403, before the handler runs', async () => {
const readOnlyOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] });
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const response = await testServer
.publicApiAgentFor(readOnlyOwner)
.post('/n8n-packages/export')
.send({ workflowIds: ['wf-1'] });
expect(response.statusCode).toBe(403);
expect(emitSpy).not.toHaveBeenCalledWith('n8n-package-export-failed', expect.anything());
});
test('rejects a non-JSON Content-Type with 415', async () => {
const response = await authOwnerAgent
.post('/n8n-packages/export')
.set('Content-Type', 'text/plain')
.send('workflowIds');
expect(response.statusCode).toBe(415);
});
test('rejects an unknown body field with 400', async () => {
const response = await authOwnerAgent
.post('/n8n-packages/export')
.send({ workflowIds: ['wf-1'], evil: 'x' });
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('evil');
});
});