feat(API): Support exporting projects via the public API (#33084)

Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com>
This commit is contained in:
Ali Elkhateeb
2026-07-01 13:54:46 +00:00
committed by GitHub
co-authored by James Gee
parent f008844d75
commit 0f46be02aa
48 changed files with 1174 additions and 263 deletions
+1 -1
View File
@@ -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';
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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(),
}) {}
@@ -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),
}) {}
+9 -3
View File
@@ -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`
+14 -1
View File
@@ -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', () => {
@@ -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',
);
});
});
+4 -2
View File
@@ -421,9 +421,11 @@ export class N8nClient {
// ─── Packages (beta) ───────────────────────────────────────────
async exportPackage(workflowIds: string[]): Promise<Buffer> {
async exportPackage(
fields: { workflowIds: string[] } | { projectIds: string[] },
): Promise<Buffer> {
return await this.request<Buffer>('POST', '/n8n-packages/export', {
body: { workflowIds },
body: fields,
responseType: 'binary',
});
}
@@ -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<void> {
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,
});
});
}
@@ -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:*",
@@ -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,
@@ -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',
@@ -103,6 +103,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'project:read',
'project:update',
'project:delete',
'project:export',
'insights:list',
'insights:read',
'folder:move',
@@ -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',
@@ -88,6 +88,10 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
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.',
+1 -1
View File
@@ -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 = {
+8 -3
View File
@@ -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!`, {
@@ -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.
@@ -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.',
);
});
});
@@ -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(
@@ -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<N8nPackagesService>();
service.exportWorkflows.mockResolvedValue(new PassThrough());
const controller = new N8nPackagesController(service);
const req = { user: { id: 'user-1' } } as unknown as AuthenticatedRequest;
const res = mock<Response>();
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<N8nPackagesService>();
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<Response>(), { 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();
});
});
});
@@ -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.');
});
@@ -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<User>({ id: 'user-1' });
function makeProject(overrides: Partial<Project> = {}): 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>();
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',
},
]);
});
});
@@ -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<Project>({
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');
});
});
@@ -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<ProjectExportResult> {
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` : ''
}`,
},
);
}
}
}
@@ -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 } : {}),
});
}
}
@@ -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);
@@ -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');
});
});
@@ -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');
@@ -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()
@@ -5,7 +5,7 @@ export class UniqueFilenameAllocator {
constructor(
private readonly baseDir: string,
private readonly fallback?: string,
private readonly fallback: string,
) {}
allocate(name: string): string {
@@ -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<Readable> {
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,
});
}
}
@@ -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');
}
}
@@ -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<Readable> {
async exportPackage(request: ExportPackageRequest): Promise<Readable> {
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,
},
});
@@ -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 = {
@@ -9,6 +9,26 @@ export const manifestEntrySchema = z.object({
target: z.string().min(1),
});
type ManifestEntryInput = z.infer<typeof manifestEntrySchema>;
function assertNoDuplicateIds(
items: Array<Pick<ManifestEntryInput, 'id'>> | undefined,
ctx: z.RefinementCtx,
): void {
if (!items) return;
const seenIds = new Set<string>();
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<string>();
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<typeof manifestEntrySchema>;
@@ -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<typeof serializedProjectSchema>;
@@ -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(
@@ -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<string>();
for (const [resource, operations] of Object.entries(API_KEY_RESOURCES)) {
@@ -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<string, Array<(...args: unknown[]) => 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<N8nPackagesService>;
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'],
});
});
});
@@ -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<ExportWorkflowsRequest>;
exportPackage: PublicAPIEndpoint<ExportPackageRequest>;
importPackage: PublicAPIEndpoint<ImportPackageRequest>;
};
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<Response> {
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
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 = {
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<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);
});
return await streamPackageExport(res, stream);
},
],
importPackage: [
@@ -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:
@@ -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
@@ -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 },
) => {
@@ -713,6 +713,33 @@ export class ProjectService {
return projects.map((p) => p.id);
}
async findProjectsByIdsForUser(
user: User,
projectIds: string[],
scopes: Scope[],
): Promise<Project[]> {
if (projectIds.length === 0) {
return [];
}
const where: FindOptionsWhere<Project> = {
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.
*
@@ -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',
});
});
});
@@ -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 {