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