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

This commit is contained in:
James Gee 2026-05-22 22:53:58 +02:00 committed by GitHub
parent 65b7919a8a
commit ca56b6b90a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1252 additions and 4 deletions

View File

@ -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 {

View File

@ -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);
});
});

View File

@ -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),
}) {}

View File

@ -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',

View File

@ -55,6 +55,7 @@ export class ModuleRegistry {
'instance-version-history',
'encryption-key-manager',
'oauth-jwe',
'n8n-packages',
'runtime-credentials',
'mcp-registry',
];

View File

@ -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];

View File

@ -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;
}

View File

@ -183,6 +183,7 @@ describe('GlobalConfig', () => {
disabled: false,
path: 'api',
swaggerUiDisabled: false,
packagesEnabled: false,
},
templates: {
enabled: true,

View File

@ -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 = {

View File

@ -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",

View File

@ -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,

View File

@ -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',

View File

@ -82,6 +82,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'projectVariable:list',
'workflow:create',
'workflow:read',
'workflow:export',
'workflow:update',
'workflow:publish',
'workflow:unpublish',

View File

@ -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',

View File

@ -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',

View File

@ -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.',

View File

@ -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",

View File

@ -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> = {

View File

@ -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();
});
});
});

View File

@ -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);
});
});
});

View File

@ -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 };
}

View File

@ -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');
});
});

View File

@ -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` : ''
}`,
},
);
}
}
}

View File

@ -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,
});
}
}

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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;
}

View 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;
}

View File

@ -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);
}
}

View File

@ -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,
});
}
}

View 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');
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,6 @@
import type { User } from '@n8n/db';
export interface ExportWorkflowsRequest {
user: User;
workflowIds: string[];
}

View File

@ -0,0 +1 @@
export const FORMAT_VERSION = '1';

View File

@ -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>;

View File

@ -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>;

View File

@ -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;

View File

@ -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'

View File

@ -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

View File

@ -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'

View File

@ -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 });
}

View File

@ -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',

View File

@ -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",

View File

@ -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', () => {

View File

@ -20,6 +20,7 @@ const UI_OPERATIONS = {
workflow: [
'read',
'execute',
'export',
'update',
'create',
'publish',

View File

@ -261,6 +261,9 @@
},
"PATCH /projects/{projectId}/folders/{folderId}": {
"status": "gap"
},
"POST /n8n-packages/export": {
"status": "gap"
}
}
}

View File

@ -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

View File

@ -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