diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts index 591f71bb9b9..02b7200bb86 100644 --- a/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/export-package-request.dto.test.ts @@ -198,6 +198,13 @@ describe('ExportPackageRequestDto', () => { }); }); + describe('unknown fields', () => { + it('rejects a key the DTO does not declare', () => { + const result = ExportPackageRequestDto.safeParse({ workflowIds: ['a'], evil: 'x' }); + expect(result.success).toBe(false); + }); + }); + describe('includeArchivedWorkflows', () => { it.each([true, false])('accepts %s', (includeArchivedWorkflows) => { const result = ExportPackageRequestDto.safeParse({ diff --git a/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts index 113e4edfee8..08086826b7c 100644 --- a/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/packages/export-package-request.dto.ts @@ -1,25 +1,59 @@ import { z } from 'zod'; +import '../../openapi-extend'; +import { exportPackageRequestFieldDocs } from './export-package-request.openapi'; import { Z } from '../../zod-class'; -export class ExportPackageRequestDto extends Z.class({ - agentIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), - workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), - folderIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), - projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(), - includeVariableValues: z.boolean().default(true), - includeTags: z.boolean().default(true), - dependencyPolicy: z - .enum(['fail', 'reference-only', 'include-in-package']) - .optional() - .default('fail'), - versionPolicy: z - .enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest']) - .optional() - .default('latest'), - credentialExportPolicy: z - .enum(['expression-values-only', 'no-values']) - .optional() - .default('expression-values-only'), - includeArchivedWorkflows: z.boolean().default(false), -}) {} +export class ExportPackageRequestDto extends Z.class( + { + agentIds: z + .array(z.string().trim().min(1)) + .min(1) + .max(300) + .optional() + .openapi(exportPackageRequestFieldDocs.agentIds), + workflowIds: z + .array(z.string().trim().min(1)) + .min(1) + .max(300) + .optional() + .openapi(exportPackageRequestFieldDocs.workflowIds), + folderIds: z + .array(z.string().trim().min(1)) + .min(1) + .max(300) + .optional() + .openapi(exportPackageRequestFieldDocs.folderIds), + projectIds: z + .array(z.string().trim().min(1)) + .min(1) + .max(300) + .optional() + .openapi(exportPackageRequestFieldDocs.projectIds), + includeVariableValues: z + .boolean() + .default(true) + .openapi(exportPackageRequestFieldDocs.includeVariableValues), + includeTags: z.boolean().default(true).openapi(exportPackageRequestFieldDocs.includeTags), + dependencyPolicy: z + .enum(['fail', 'reference-only', 'include-in-package']) + .optional() + .default('fail') + .openapi(exportPackageRequestFieldDocs.dependencyPolicy), + versionPolicy: z + .enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest']) + .optional() + .default('latest') + .openapi(exportPackageRequestFieldDocs.versionPolicy), + credentialExportPolicy: z + .enum(['expression-values-only', 'no-values']) + .optional() + .default('expression-values-only') + .openapi(exportPackageRequestFieldDocs.credentialExportPolicy), + includeArchivedWorkflows: z + .boolean() + .default(false) + .openapi(exportPackageRequestFieldDocs.includeArchivedWorkflows), + }, + { strict: true }, +) {} diff --git a/packages/@n8n/api-types/src/dto/packages/export-package-request.openapi.ts b/packages/@n8n/api-types/src/dto/packages/export-package-request.openapi.ts new file mode 100644 index 00000000000..996b65ea78a --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/export-package-request.openapi.ts @@ -0,0 +1,50 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +export const exportPackageRequestFieldDocs = { + agentIds: { + description: 'IDs of the agents to include. Requires the agents module.', + example: ['uT9LdQx7rK2MvB4f'], + }, + workflowIds: { + description: 'IDs of the workflows to include in the exported package.', + example: ['2tUt1wbLX592XDdX'], + }, + folderIds: { + description: + 'IDs of the folders to include in the exported package. Each folder is exported with its nested folders.', + example: ['9xKp2mNqRzAbCdEf'], + }, + projectIds: { + description: + 'IDs of the projects to include in the exported package. Includes their agents when the agents module is enabled.', + example: ['Ox8O54VQrmBrb4qL'], + }, + includeVariableValues: { + description: + 'Whether values of variables referenced by the exported entities are bundled into the package. When `false`, variables still travel as name/type files and are listed in the package requirements, but no values travel with the package.', + }, + includeTags: { + description: + 'Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag files, tag references, or tag requirements travel with the package.', + }, + dependencyPolicy: { + description: + 'Policy for workflow and agent dependencies outside the selected package contents. Includes static workflow references and disabled agent references. `fail` aborts when a required workflow or agent is absent from the package. `include-in-package` adds accessible dependencies recursively. `reference-only` records external requirements without including those definitions. The same policy applies to workflows and agents.', + example: 'fail' as const, + }, + versionPolicy: { + description: + 'Which version of each workflow and agent travels in the package. `latest` exports the current draft. `published-strict` requires a published version. `prefer-published` uses the published version when available and the draft otherwise. `ignore-unpublished` skips unpublished selections. Dependencies that cannot be included cause the export to fail only when `dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`, the export records them as external requirements. The selected version supplies the definition and its references. Workflow names, settings, and tags always use their current values. Agent IDs, names, and MCP availability always use their current values.', + example: 'latest' as const, + }, + credentialExportPolicy: { + description: + 'Whether expression values from credential data are bundled into the package. `expression-values-only` includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); literal values never travel either way. `no-values` keeps credential data out of the package entirely, so each credential file carries only its id, name and type.', + example: 'expression-values-only' as const, + }, + includeArchivedWorkflows: { + description: + 'Whether folder and project exports include their archived workflows. When `false` (default) they are left out. When `true` they travel with `isArchived: true` and are archived on import. Workflows listed in `workflowIds` always export, also when archived.', + example: false, + }, +} satisfies Record; diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 485177e5e32..9e798fbe2e6 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -163,15 +163,6 @@ export default defineConfig( 'n8n-local-rules/require-public-api-controller': 'error', }, }, - { - // Ratchet allowlist: legacy `export =` handler tuples pending migration to - // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler - // must fail CI. Entries are removed as each handler becomes a controller. - files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'], - rules: { - 'n8n-local-rules/require-public-api-controller': 'off', - }, - }, { files: ['./src/**/*.ts'], ignores: ['./src/modules/engine-v2/**/*.ts'], diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 1b284b8f210..725dc807a21 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -156,15 +156,6 @@ export default defineConfig({ 'n8n-local-rules/require-public-api-controller': 'error', }, }, - { - // Ratchet allowlist: legacy `export =` handler tuples pending migration to - // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler - // must fail CI. Entries are removed as each handler becomes a controller. - files: ['./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts'], - rules: { - 'n8n-local-rules/require-public-api-controller': 'off', - }, - }, { files: ['./src/**/*.ts'], excludeFiles: ['./src/modules/engine-v2/**/*.ts'], diff --git a/packages/cli/src/modules/n8n-packages/CLAUDE.md b/packages/cli/src/modules/n8n-packages/CLAUDE.md index 4db8f2a0825..982cfb629a6 100644 --- a/packages/cli/src/modules/n8n-packages/CLAUDE.md +++ b/packages/cli/src/modules/n8n-packages/CLAUDE.md @@ -69,11 +69,11 @@ flowchart LR `n8n-packages.service.ts` `exportPackage` (+ `io/` writer or `entities/*/` exporter as needed). 2. **DTO** — `@n8n/api-types/src/dto/packages/export-package-request.dto.ts` - (`ExportPackageRequestDto`, zod). -3. **Public API** — `n8n-packages.handler.ts` `exportPackage` reads from - `payload.data`; update the **separate** schema file - `spec/schemas/exportPackageRequest.yml` (export's request schema is a - `$ref`, unlike import's inline schema). + (`ExportPackageRequestDto`, zod). Add the field's OpenAPI wording to + `export-package-request.openapi.ts`. +3. **Public API** — `packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts` + `exportPackage` reads from `body`. The request schema comes from + `ExportPackageRequestDto`, so do not edit a YAML file. 4. **CLI** — `src/client.ts` `exportPackage(...)`, `src/commands/package/export.ts` flag, and the docs/README. diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/n8n-packages.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/n8n-packages.public.controller.test.ts new file mode 100644 index 00000000000..88a772e2ef7 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/__tests__/n8n-packages.public.controller.test.ts @@ -0,0 +1,438 @@ +import { ExportPackageRequestDto } from '@n8n/api-types'; +import type { EventService } from '@n8n/backend-services'; +import type { AuthenticatedRequest } from '@n8n/db'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; +import type { Response } from 'express'; +import { UserError } from 'n8n-workflow'; +import { Readable } from 'node:stream'; +import { mock } from 'vitest-mock-extended'; + +import type { RelayEventMap } from '@/events/maps/relay.event-map'; +import { + PackageEntityAccessDeniedError, + PackageEntityNotFoundError, + PackageExportBlockedError, +} from '@/modules/n8n-packages/entities/package-export.errors'; +import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; + +import { N8nPackagesPublicController } from '../n8n-packages.public.controller'; + +const EXPORT_COUNTS = { + agents: 0, + workflows: 2, + folders: 1, + credentials: 0, + dataTables: 0, + variables: 0, + tags: 0, +}; + +const DEFAULT_SERVICE_OPTIONS = { + user: { id: 'user-1' }, + agentIds: [], + workflowIds: [], + folderIds: [], + projectIds: [], + includeVariableValues: true, + canExportVariableValues: false, + includeTags: true, + dependencyPolicy: 'fail', + versionPolicy: 'latest', + credentialExportPolicy: 'expression-values-only', + includeArchivedWorkflows: false, +}; + +describe('N8nPackagesPublicController', () => { + let controller: N8nPackagesPublicController; + const mockService = mock(); + const mockEventService = mock(); + + function makeRequest(apiKeyScopes?: string[]) { + return { + user: { id: 'user-1' }, + tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined, + } as unknown as AuthenticatedRequest; + } + + function mockExportStream() { + mockService.exportPackage.mockResolvedValue({ + stream: Readable.from([Buffer.from('package-bytes')]), + counts: EXPORT_COUNTS, + }); + } + + async function runAndCatch( + body: Record, + apiKeyScopes?: string[], + res: Response = mock(), + ) { + try { + await controller.exportPackage( + makeRequest(apiKeyScopes), + res, + ExportPackageRequestDto.parse(body), + ); + return undefined; + } catch (error) { + return error; + } + } + + function emittedEvent(name: 'n8n-package-export-failed' | 'n8n-package-import-failed') { + const call = mockEventService.emit.mock.calls.find(([eventName]) => eventName === name); + return call?.[1] as RelayEventMap[typeof name] | undefined; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockService.exportPackage.mockReset(); + controller = new N8nPackagesPublicController(mockService, mockEventService); + }); + + describe('exportPackage', () => { + it('throws BadRequestError when both workflowIds and projectIds are provided', async () => { + const caught = await runAndCatch({ workflowIds: ['wf-1'], projectIds: ['project-1'] }, [ + 'workflow:export', + 'project:export', + ]); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(caught).toMatchObject({ + message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', + }); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestError when folderIds and projectIds are both provided', async () => { + const caught = await runAndCatch({ folderIds: ['fld-1'], projectIds: ['project-1'] }, [ + 'workflow:export', + 'project:export', + ]); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(caught).toMatchObject({ + message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', + }); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws BadRequestError when neither workflowIds, folderIds nor projectIds are provided', async () => { + const caught = await runAndCatch({}, ['workflow:export']); + + expect(caught).toBeInstanceOf(BadRequestError); + expect(caught).toMatchObject({ + message: 'At least one agentId, workflowId, folderId, or projectId is required', + }); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when the API key carries no scopes', async () => { + const caught = await runAndCatch({ workflowIds: ['wf-1'] }); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ + reason: 'access-denied', + workflowIds: ['wf-1'], + }); + }); + + it('throws ForbiddenError when exporting workflows without workflow:export scope', async () => { + const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['project:export']); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when exporting projects without project:export scope', async () => { + const caught = await runAndCatch({ projectIds: ['project-1'] }, ['workflow:export']); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when exporting folders without workflow:export scope', async () => { + const caught = await runAndCatch({ folderIds: ['fld-1'] }, ['project:export']); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => { + mockExportStream(); + + const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export']); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1'], + }); + }); + + it('allows value-less export without variable:list scope', async () => { + mockExportStream(); + + const caught = await runAndCatch({ workflowIds: ['wf-1'], includeVariableValues: false }, [ + 'workflow:export', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1'], + includeVariableValues: false, + }); + }); + + it('propagates the ForbiddenError thrown by the service scope gate and emits access-denied', async () => { + mockService.exportPackage.mockRejectedValue( + new ForbiddenError('missing the variable:list scope'), + ); + + const caught = await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export']); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ + reason: 'access-denied', + workflowIds: ['wf-1'], + }); + }); + + it('emits n8n-package-export-failed with reason=entity-not-found when the service rejects with NotFoundError', async () => { + mockService.exportPackage.mockRejectedValue(new NotFoundError('not found')); + + await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']); + + expect(emittedEvent('n8n-package-export-failed')).toEqual({ + user: { id: 'user-1' }, + reason: 'entity-not-found', + workflowIds: ['wf-1'], + }); + }); + + it('emits n8n-package-export-failed with reason=blocked when the service rejects a blocked export', async () => { + mockService.exportPackage.mockRejectedValue(new PackageExportBlockedError('Export blocked')); + + await runAndCatch({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']); + + expect(emittedEvent('n8n-package-export-failed')).toEqual({ + user: { id: 'user-1' }, + reason: 'blocked', + workflowIds: ['wf-1'], + }); + }); + + it.each([ + [ + 'access-denied', + new PackageEntityAccessDeniedError('workflows denied', { description: 'x' }), + ], + [ + 'entity-not-found', + new PackageEntityNotFoundError('workflows missing', { description: 'y' }), + ], + ])( + 'rethrows %s as a generic UserError with the same message, hiding which case occurred', + async (reason, thrownError) => { + mockService.exportPackage.mockRejectedValue(thrownError); + + const caught = await runAndCatch({ workflowIds: ['wf-1'] }, [ + 'workflow:export', + 'variable:list', + ]); + + expect(caught).toBeInstanceOf(UserError); + expect(caught).not.toBeInstanceOf(PackageEntityAccessDeniedError); + expect(caught).not.toBeInstanceOf(PackageEntityNotFoundError); + expect(caught).toMatchObject({ message: thrownError.message }); + expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason }); + }, + ); + + it('returns the export stream and headers for a valid workflow request', async () => { + mockExportStream(); + + const result = await controller.exportPackage( + makeRequest(['workflow:export', 'variable:list']), + mock(), + ExportPackageRequestDto.parse({ workflowIds: ['wf-1', 'wf-2'] }), + ); + + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1', 'wf-2'], + canExportVariableValues: true, + }); + expect(result.body).toBeInstanceOf(Readable); + expect(result.headers).toEqual({ + 'Content-Disposition': 'attachment; filename="export.n8np"', + 'X-N8n-Export-Counts': JSON.stringify(EXPORT_COUNTS), + 'Access-Control-Expose-Headers': 'X-N8n-Export-Counts', + }); + expect(mockEventService.emit).not.toHaveBeenCalled(); + }); + + it('does not emit n8n-package-export-failed when the client disconnects after the stream is returned', async () => { + const prematureClose = Object.assign(new Error('Premature close'), { + code: 'ERR_STREAM_PREMATURE_CLOSE', + }); + const stream = new Readable({ read() {} }); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); + + await controller.exportPackage( + makeRequest(['workflow:export']), + mock(), + ExportPackageRequestDto.parse({ workflowIds: ['wf-1'] }), + ); + // The response reads the stream after the controller returns, so the disconnect happens here. + stream.on('error', () => undefined); + stream.destroy(prematureClose); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockEventService.emit).not.toHaveBeenCalled(); + }); + + it('forwards a non-default dependency policy', async () => { + mockExportStream(); + + const caught = await runAndCatch( + { workflowIds: ['wf-1'], dependencyPolicy: 'reference-only' }, + ['workflow:export'], + ); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1'], + dependencyPolicy: 'reference-only', + }); + }); + + it('forwards a non-default version policy', async () => { + mockExportStream(); + + const caught = await runAndCatch( + { workflowIds: ['wf-1'], versionPolicy: 'published-strict' }, + ['workflow:export'], + ); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith( + expect.objectContaining({ versionPolicy: 'published-strict' }), + ); + }); + + it('forwards agentIds and requires the agent:export scope for them', async () => { + mockExportStream(); + + const caught = await runAndCatch({ agentIds: ['agent-1'] }, [ + 'agent:export', + 'variable:list', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + agentIds: ['agent-1'], + canExportVariableValues: true, + }); + }); + + it('throws ForbiddenError when exporting agents without agent:export scope', async () => { + const caught = await runAndCatch({ agentIds: ['agent-1'] }, ['workflow:export']); + + expect(caught).toBeInstanceOf(ForbiddenError); + expect(mockService.exportPackage).not.toHaveBeenCalled(); + }); + + it('forwards a non-default credential export policy', async () => { + mockExportStream(); + + const caught = await runAndCatch( + { workflowIds: ['wf-1'], credentialExportPolicy: 'no-values' }, + ['workflow:export'], + ); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith( + expect.objectContaining({ credentialExportPolicy: 'no-values' }), + ); + }); + + it('forwards includeArchivedWorkflows', async () => { + mockExportStream(); + + const caught = await runAndCatch({ workflowIds: ['wf-1'], includeArchivedWorkflows: true }, [ + 'workflow:export', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith( + expect.objectContaining({ includeArchivedWorkflows: true }), + ); + }); + + it('streams the export for a valid project request', async () => { + mockExportStream(); + + const caught = await runAndCatch({ projectIds: ['project-1'] }, [ + 'project:export', + 'variable:list', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + projectIds: ['project-1'], + canExportVariableValues: true, + }); + }); + + it('streams the export for a valid folder request', async () => { + mockExportStream(); + + const caught = await runAndCatch({ folderIds: ['fld-1'] }, [ + 'workflow:export', + 'variable:list', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + folderIds: ['fld-1'], + canExportVariableValues: true, + }); + }); + + it('forwards includeVariableValues=false to the service', async () => { + mockExportStream(); + + const caught = await runAndCatch({ workflowIds: ['wf-1'], includeVariableValues: false }, [ + 'workflow:export', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1'], + includeVariableValues: false, + }); + }); + + it('forwards includeTags=false to the service', async () => { + mockExportStream(); + + const caught = await runAndCatch({ workflowIds: ['wf-1'], includeTags: false }, [ + 'workflow:export', + ]); + + expect(caught).toBeUndefined(); + expect(mockService.exportPackage).toHaveBeenCalledWith({ + ...DEFAULT_SERVICE_OPTIONS, + workflowIds: ['wf-1'], + includeTags: false, + }); + }); + }); +}); diff --git a/packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts b/packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts index 8cf4493f0a7..4eacab8c851 100644 --- a/packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/n8n-packages.public.controller.ts @@ -1,4 +1,5 @@ import { + ExportPackageRequestDto, ImportBlockedErrorDto, ImportPackageRequestDto, ImportPackageSelectionRequestDto, @@ -18,11 +19,18 @@ import { Body, Post, PublicApiController, + type BinaryResult, } from '@n8n/decorators'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; import type { Response } from 'express'; +import { UserError } from 'n8n-workflow'; +import { + PackageEntityAccessDeniedError, + PackageEntityNotFoundError, +} from '@/modules/n8n-packages/entities/package-export.errors'; import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; -import type { ImportResult } from '@/modules/n8n-packages/n8n-packages.types'; +import type { ExportPackageResult, ImportResult } from '@/modules/n8n-packages/n8n-packages.types'; import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier'; import { IMPORT_PACKAGE_FIELD_SIZE_BYTES, @@ -31,6 +39,10 @@ import { } from '@/modules/n8n-packages/utils/import-package-upload'; import { + EXPORT_200_DESCRIPTION, + EXPORT_COUNTS_HEADER_DESCRIPTION, + EXPORT_DESCRIPTION, + EXPORT_SUMMARY, IMPORT_409_DESCRIPTION, IMPORT_422_DESCRIPTION, IMPORT_DESCRIPTION, @@ -39,9 +51,11 @@ import { IMPORT_SELECTION_DESCRIPTION, IMPORT_SELECTION_SUMMARY, IMPORT_SUMMARY, - IMPORT_TAGS, + PACKAGE_TAGS, } from './openapi/n8n-packages.openapi'; +const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts'; + function uploadLimits(maxParts: number) { const maxFileSizeBytes = Container.get(GlobalConfig).endpoints.payloadSizeMax * 1024 * 1024; return { @@ -52,6 +66,38 @@ function uploadLimits(maxParts: number) { }; } +function assertPackageExportApiKeyScopes( + apiKeyScopes: string[] | undefined, + agentIds: string[], + workflowIds: string[], + folderIds: string[], + projectIds: string[], +): string[] { + if (!apiKeyScopes) { + throw new ForbiddenError('Forbidden'); + } + + const requiredScopes: string[] = []; + if (agentIds.length > 0) { + requiredScopes.push('agent:export'); + } + // Folders are exported as a workflow-organization concern, so they share the workflow:export scope. + if (workflowIds.length > 0 || folderIds.length > 0) { + requiredScopes.push('workflow:export'); + } + if (projectIds.length > 0) { + requiredScopes.push('project:export'); + } + + for (const scope of requiredScopes) { + if (!apiKeyScopes.includes(scope)) { + throw new ForbiddenError('Forbidden'); + } + } + + return apiKeyScopes; +} + @PublicApiController('/n8n-packages') export class N8nPackagesPublicController { constructor( @@ -63,7 +109,7 @@ export class N8nPackagesPublicController { @ApiKeyScope('workflow:import') @ApiSummary(IMPORT_SUMMARY) @ApiDescription(IMPORT_DESCRIPTION) - @ApiTags(IMPORT_TAGS) + @ApiTags(PACKAGE_TAGS) @ApiResponse(200, ImportResultDto) @ApiErrorResponse(404) @ApiErrorResponse(409, { dto: ImportBlockedErrorDto, description: IMPORT_409_DESCRIPTION }) @@ -106,7 +152,7 @@ export class N8nPackagesPublicController { @ApiKeyScope('workflow:import') @ApiSummary(IMPORT_SELECTION_SUMMARY) @ApiDescription(IMPORT_SELECTION_DESCRIPTION) - @ApiTags(IMPORT_TAGS) + @ApiTags(PACKAGE_TAGS) @ApiResponse(200, ImportResultDto) @ApiErrorResponse(404) @ApiErrorResponse(409, { @@ -156,4 +202,90 @@ export class N8nPackagesPublicController { throw error; } } + + @Post('/export') + @ApiKeyScope({ anyOf: ['project:export', 'workflow:export', 'agent:export'] }) + @ApiSummary(EXPORT_SUMMARY) + @ApiDescription(EXPORT_DESCRIPTION) + @ApiTags(PACKAGE_TAGS) + @ApiResponse(200, { + mediaType: 'application/gzip', + description: EXPORT_200_DESCRIPTION, + headers: { [EXPORT_COUNTS_HEADER]: { description: EXPORT_COUNTS_HEADER_DESCRIPTION } }, + }) + @ApiErrorResponse(404) + async exportPackage( + req: AuthenticatedRequest, + _res: Response, + @Body({ required: true }) body: ExportPackageRequestDto, + ): Promise { + const { agentIds = [], workflowIds = [], folderIds = [], projectIds = [] } = body; + + let exportResult: ExportPackageResult; + + try { + const hasLooseSelection = [agentIds, workflowIds, folderIds].some((ids) => ids.length > 0); + if (projectIds.length > 0 && hasLooseSelection) { + throw new BadRequestError( + 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', + ); + } + + if (!hasLooseSelection && projectIds.length === 0) { + throw new BadRequestError( + 'At least one agentId, workflowId, folderId, or projectId is required', + ); + } + + const apiKeyScopes = assertPackageExportApiKeyScopes( + req.tokenGrant?.apiKeyScopes, + agentIds, + workflowIds, + folderIds, + projectIds, + ); + + exportResult = await this.n8nPackagesService.exportPackage({ + user: req.user, + agentIds, + workflowIds, + folderIds, + projectIds, + includeVariableValues: body.includeVariableValues, + canExportVariableValues: apiKeyScopes.includes('variable:list'), + includeTags: body.includeTags, + dependencyPolicy: body.dependencyPolicy, + versionPolicy: body.versionPolicy, + credentialExportPolicy: body.credentialExportPolicy, + includeArchivedWorkflows: body.includeArchivedWorkflows, + }); + } catch (error) { + this.eventService.emit('n8n-package-export-failed', { + user: req.user, + reason: classifyPackageFailure(error), + ...(agentIds.length ? { agentIds } : {}), + ...(workflowIds.length ? { workflowIds } : {}), + ...(folderIds.length ? { folderIds } : {}), + ...(projectIds.length ? { projectIds } : {}), + }); + + if ( + error instanceof PackageEntityAccessDeniedError || + error instanceof PackageEntityNotFoundError + ) { + throw new UserError(error.message, { description: error.description }); + } + throw error; + } + + return { + body: exportResult.stream, + headers: { + 'Content-Disposition': 'attachment; filename="export.n8np"', + [EXPORT_COUNTS_HEADER]: JSON.stringify(exportResult.counts), + // Cross-origin browser clients can only read the counts header if it is exposed. + 'Access-Control-Expose-Headers': EXPORT_COUNTS_HEADER, + }, + }; + } } diff --git a/packages/cli/src/public-api/v1/controllers/openapi/n8n-packages.openapi.ts b/packages/cli/src/public-api/v1/controllers/openapi/n8n-packages.openapi.ts index 8bac76a7df0..3b44a43f881 100644 --- a/packages/cli/src/public-api/v1/controllers/openapi/n8n-packages.openapi.ts +++ b/packages/cli/src/public-api/v1/controllers/openapi/n8n-packages.openapi.ts @@ -1,4 +1,37 @@ -export const IMPORT_TAGS = ['N8nPackage']; +export const PACKAGE_TAGS = ['N8nPackage']; + +export const EXPORT_SUMMARY = + 'Beta: Export agents, workflows, folders, or projects as an n8n package'; +export const EXPORT_DESCRIPTION = + '**Beta** — breaking changes may still occur without major version bump.\n\n' + + 'Export agents, workflows, and folders, or whole projects, as a gzipped tar\n' + + 'archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`.\n' + + 'Do not combine these two groups. Each exported folder includes its nested\n' + + 'folders. Whole projects include their agents when the agents module is enabled.\n' + + 'Explicit agent selections require the agents module. Empty projects export\n' + + 'project metadata only. `versionPolicy` selects the version for both agents and\n' + + 'workflows and defaults to `latest`. `dependencyPolicy` applies to both entity\n' + + 'types and defaults to `fail` when a required definition is absent from the\n' + + 'package. The response is streamed as `application/gzip` with a `Content-Disposition`\n' + + 'attachment header. Requires the n8n Packages feature to be licensed.\n\n' + + 'API key scopes: `agent:export` is required for explicitly selected agents.\n' + + '`workflow:export` is required for explicitly selected workflows or folders.\n' + + '`project:export` is sufficient for whole-project exports, including their\n' + + 'agents and workflows. Included dependencies need no additional API key scope.\n' + + "The caller's project permissions apply to all included entities. When\n" + + '`includeVariableValues` is true (the default) and exported entities reference\n' + + 'variables, `variable:list` is also required. Exports that reference no variables\n' + + 'do not need it.\n\n' + + 'Agent definitions include referenced skills, custom tools, and schedules.\n' + + 'Knowledge base files and runtime data are excluded. Agent package imports\n' + + 'remain unavailable.\n'; + +export const EXPORT_200_DESCRIPTION = + 'A gzipped tar archive containing the exported package contents.'; +export const EXPORT_COUNTS_HEADER_DESCRIPTION = + 'JSON-serialized per-entity counts of what actually ended up in the\n' + + 'package (after folder bundling and auto-inclusion), e.g.\n' + + '`{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`.\n'; export const IMPORT_SUMMARY = 'Beta: Import an n8n package into a project'; export const IMPORT_DESCRIPTION = diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts deleted file mode 100644 index 26ac8efc7a8..00000000000 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { EventService } from '@n8n/backend-services'; -import { mockInstance } from '@n8n/backend-test-utils'; -import type { AuthenticatedRequest } from '@n8n/db'; -import { Container } from '@n8n/di'; -import type { Response } from 'express'; -import { UserError } from 'n8n-workflow'; -import { PassThrough } from 'node:stream'; -import type { Mocked } from 'vitest'; - -import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors'; -import type { RelayEventMap } from '@/events/maps/relay.event-map'; -import { - PackageEntityAccessDeniedError, - PackageEntityNotFoundError, - PackageExportBlockedError, -} from '@/modules/n8n-packages/entities/package-export.errors'; -import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; -import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; - -const mockMiddleware = vi.fn(async (_req: unknown, _res: unknown, next: unknown) => - (next as () => void)(), -) as unknown as middlewares.ScopeTaggedMiddleware; -vi.spyOn(middlewares, 'publicApiCompositeScope').mockReturnValue(mockMiddleware); -vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware); - -// Loaded after the middleware spies above are installed (the handler captures -// middleware at module-evaluation time). Typed loosely to invoke route entries. -let handler: Record unknown>>; -// exportPackage = [middleware(...), businessLogic]; index 1 is the handler under test. -let exportPackage: (...args: unknown[]) => unknown; - -beforeAll(async () => { - handler = (await import('../n8n-packages.handler.js')) as unknown as typeof handler; - exportPackage = handler.exportPackage[1]; -}); - -const EXPORT_COUNTS = { - agents: 0, - workflows: 2, - folders: 1, - credentials: 0, - dataTables: 0, - variables: 0, - tags: 0, -}; - -describe('n8n-packages handler', () => { - let mockService: Mocked; - let mockEventService: Mocked; - - function makeRequest( - body: { - workflowIds?: string[]; - folderIds?: string[]; - projectIds?: string[]; - includeVariableValues?: boolean; - includeTags?: boolean; - dependencyPolicy?: string; - versionPolicy?: string; - credentialExportPolicy?: string; - includeArchivedWorkflows?: boolean; - }, - apiKeyScopes?: string[], - ) { - return { - user: { id: 'user-1' }, - body, - tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined, - } as unknown as AuthenticatedRequest; - } - - function makeResponse() { - const res = new PassThrough() as unknown as Response & PassThrough; - res.setHeader = vi.fn().mockReturnValue(res); - return res; - } - - async function run(req: AuthenticatedRequest, res: Response) { - let caught: unknown; - try { - await exportPackage(req, res); - } catch (error) { - caught = error; - } - return caught; - } - - function emittedEvent(name: 'n8n-package-export-failed' | 'n8n-package-import-failed') { - const call = mockEventService.emit.mock.calls.find(([eventName]) => eventName === name); - return call?.[1] as RelayEventMap[typeof name] | undefined; - } - - beforeEach(() => { - vi.clearAllMocks(); - mockService = mockInstance(N8nPackagesService); - mockEventService = mockInstance(EventService); - - vi.spyOn(Container, 'get').mockImplementation((serviceClass) => { - if ( - serviceClass === N8nPackagesService || - (typeof serviceClass === 'function' && serviceClass.name === 'N8nPackagesService') - ) { - return mockService; - } - if ( - serviceClass === EventService || - (typeof serviceClass === 'function' && serviceClass.name === 'EventService') - ) { - return mockEventService; - } - return {} as never; - }); - }); - - describe('exportPackage', () => { - it('throws BadRequestError when the payload fails DTO validation', async () => { - const caught = await run( - makeRequest({ workflowIds: [''] }, ['workflow:export']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(BadRequestError); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason: 'validation' }); - }); - - it('throws BadRequestError when both workflowIds and projectIds are provided', async () => { - const caught = await run( - makeRequest({ workflowIds: ['wf-1'], projectIds: ['project-1'] }, [ - 'workflow:export', - 'project:export', - ]), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(BadRequestError); - expect(caught).toMatchObject({ - message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', - }); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('throws BadRequestError when folderIds and projectIds are both provided', async () => { - const caught = await run( - makeRequest({ folderIds: ['fld-1'], projectIds: ['project-1'] }, [ - 'workflow:export', - 'project:export', - ]), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(BadRequestError); - expect(caught).toMatchObject({ - message: 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', - }); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('throws BadRequestError when neither workflowIds, folderIds nor projectIds are provided', async () => { - const caught = await run(makeRequest({}, ['workflow:export']), makeResponse()); - - expect(caught).toBeInstanceOf(BadRequestError); - expect(caught).toMatchObject({ - message: 'At least one agentId, workflowId, folderId, or projectId is required', - }); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('throws ForbiddenError when the API key carries no scopes', async () => { - const caught = await run(makeRequest({ workflowIds: ['wf-1'] }), makeResponse()); - - expect(caught).toBeInstanceOf(ForbiddenError); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ - reason: 'access-denied', - workflowIds: ['wf-1'], - }); - }); - - it('throws ForbiddenError when exporting workflows without workflow:export scope', async () => { - const caught = await run( - makeRequest({ workflowIds: ['wf-1'] }, ['project:export']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(ForbiddenError); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('throws ForbiddenError when exporting projects without project:export scope', async () => { - const caught = await run( - makeRequest({ projectIds: ['project-1'] }, ['workflow:export']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(ForbiddenError); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('throws ForbiddenError when exporting folders without workflow:export scope', async () => { - const caught = await run( - makeRequest({ folderIds: ['fld-1'] }, ['project:export']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(ForbiddenError); - expect(mockService.exportPackage).not.toHaveBeenCalled(); - }); - - it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run(makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), res); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1'], - folderIds: [], - projectIds: [], - includeVariableValues: true, - canExportVariableValues: false, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('allows value-less export without variable:list scope', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], includeVariableValues: false }, ['workflow:export']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1'], - folderIds: [], - projectIds: [], - includeVariableValues: false, - canExportVariableValues: false, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('propagates the ForbiddenError thrown by the service scope gate and emits access-denied', async () => { - mockService.exportPackage.mockRejectedValue( - new ForbiddenError('missing the variable:list scope'), - ); - - const caught = await run( - makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(ForbiddenError); - expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ - reason: 'access-denied', - workflowIds: ['wf-1'], - }); - }); - - it('emits n8n-package-export-failed with reason=entity-not-found when the service rejects with NotFoundError', async () => { - mockService.exportPackage.mockRejectedValue(new NotFoundError('not found')); - - await run( - makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']), - makeResponse(), - ); - - expect(emittedEvent('n8n-package-export-failed')).toEqual({ - user: { id: 'user-1' }, - reason: 'entity-not-found', - workflowIds: ['wf-1'], - }); - }); - - it('emits n8n-package-export-failed with reason=blocked when the service rejects a blocked export', async () => { - mockService.exportPackage.mockRejectedValue(new PackageExportBlockedError('Export blocked')); - - await run( - makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']), - makeResponse(), - ); - - expect(emittedEvent('n8n-package-export-failed')).toEqual({ - user: { id: 'user-1' }, - reason: 'blocked', - workflowIds: ['wf-1'], - }); - }); - - it.each([ - [ - 'access-denied', - new PackageEntityAccessDeniedError('workflows denied', { description: 'x' }), - ], - [ - 'entity-not-found', - new PackageEntityNotFoundError('workflows missing', { description: 'y' }), - ], - ])( - 'rethrows %s as a generic UserError with the same message, hiding which case occurred', - async (reason, thrownError) => { - mockService.exportPackage.mockRejectedValue(thrownError); - - const caught = await run( - makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export', 'variable:list']), - makeResponse(), - ); - - expect(caught).toBeInstanceOf(UserError); - expect(caught).not.toBeInstanceOf(PackageEntityAccessDeniedError); - expect(caught).not.toBeInstanceOf(PackageEntityNotFoundError); - expect(caught).toMatchObject({ message: thrownError.message }); - expect(emittedEvent('n8n-package-export-failed')).toMatchObject({ reason }); - }, - ); - - it('streams the export for a valid workflow request', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1', 'wf-2'] }, ['workflow:export', 'variable:list']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1', 'wf-2'], - folderIds: [], - projectIds: [], - includeVariableValues: true, - canExportVariableValues: true, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip'); - expect(res.setHeader).toHaveBeenCalledWith( - 'Content-Disposition', - 'attachment; filename="export.n8np"', - ); - expect(res.setHeader).toHaveBeenCalledWith( - 'X-N8n-Export-Counts', - JSON.stringify(EXPORT_COUNTS), - ); - expect(res.setHeader).toHaveBeenCalledWith( - 'Access-Control-Expose-Headers', - 'X-N8n-Export-Counts', - ); - expect(mockEventService.emit).not.toHaveBeenCalled(); - }); - - it('forwards a non-default missing workflow dependency policy', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest( - { - workflowIds: ['wf-1'], - dependencyPolicy: 'reference-only', - }, - ['workflow:export'], - ), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1'], - folderIds: [], - projectIds: [], - includeVariableValues: true, - canExportVariableValues: false, - includeTags: true, - dependencyPolicy: 'reference-only', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('forwards a non-default workflow version policy', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], versionPolicy: 'published-strict' }, [ - 'workflow:export', - ]), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith( - expect.objectContaining({ versionPolicy: 'published-strict' }), - ); - }); - - it('forwards a non-default credential export policy', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], credentialExportPolicy: 'no-values' }, [ - 'workflow:export', - ]), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith( - expect.objectContaining({ credentialExportPolicy: 'no-values' }), - ); - }); - - it('forwards includeArchivedWorkflows', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], includeArchivedWorkflows: true }, ['workflow:export']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith( - expect.objectContaining({ includeArchivedWorkflows: true }), - ); - }); - - it('streams the export for a valid project request', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ projectIds: ['project-1'] }, ['project:export', 'variable:list']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: [], - folderIds: [], - projectIds: ['project-1'], - includeVariableValues: true, - canExportVariableValues: true, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('streams the export for a valid folder request', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ folderIds: ['fld-1'] }, ['workflow:export', 'variable:list']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: [], - folderIds: ['fld-1'], - projectIds: [], - includeVariableValues: true, - canExportVariableValues: true, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('forwards includeVariableValues=false to the service', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], includeVariableValues: false }, ['workflow:export']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1'], - folderIds: [], - projectIds: [], - includeVariableValues: false, - canExportVariableValues: false, - includeTags: true, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - - it('forwards includeTags=false to the service', async () => { - const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); - const res = makeResponse(); - - const resultPromise = run( - makeRequest({ workflowIds: ['wf-1'], includeTags: false }, ['workflow:export']), - res, - ); - stream.end(Buffer.from('package-bytes')); - const caught = await resultPromise; - - expect(caught).toBeUndefined(); - expect(mockService.exportPackage).toHaveBeenCalledWith({ - agentIds: [], - user: { id: 'user-1' }, - workflowIds: ['wf-1'], - folderIds: [], - projectIds: [], - includeVariableValues: true, - canExportVariableValues: false, - includeTags: false, - dependencyPolicy: 'fail', - versionPolicy: 'latest', - credentialExportPolicy: 'expression-values-only', - includeArchivedWorkflows: false, - }); - }); - }); -}); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts deleted file mode 100644 index 0d7f89fa2d0..00000000000 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ExportPackageRequestDto } from '@n8n/api-types'; -import { EventService } from '@n8n/backend-services'; -import type { AuthenticatedRequest } from '@n8n/db'; -import { Container } from '@n8n/di'; -import type { ApiKeyScope } from '@n8n/permissions'; -import type { Response } from 'express'; -import { UserError } from 'n8n-workflow'; - -import { BadRequestError, ForbiddenError } from '@n8n/errors'; -import { - PackageEntityAccessDeniedError, - PackageEntityNotFoundError, -} from '@/modules/n8n-packages/entities/package-export.errors'; -import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; -import type { ExportPackageResult } from '@/modules/n8n-packages/n8n-packages.types'; -import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier'; - -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { publicApiCompositeScope } from '../../shared/middlewares/global.middleware'; - -const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export,agent:export'; - -/** Header carrying the JSON-serialized true per-entity counts of the exported package. */ -const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts'; - -type ExportPackageRequest = AuthenticatedRequest<{}, {}, ExportPackageRequestDto>; - -type N8nPackagesHandlers = { - exportPackage: PublicAPIEndpoint; -}; - -function assertPackageExportApiKeyScopes( - req: AuthenticatedRequest, - agentIds: string[], - workflowIds: string[], - folderIds: string[], - projectIds: string[], -): string[] { - const apiKeyScopes = req.tokenGrant?.apiKeyScopes; - if (!apiKeyScopes) { - throw new ForbiddenError('Forbidden'); - } - - const requiredScopes: ApiKeyScope[] = []; - if (agentIds.length > 0) { - requiredScopes.push('agent:export'); - } - // Folders are exported as a workflow-organization concern, so they share the workflow:export scope. - if (workflowIds.length > 0 || folderIds.length > 0) { - requiredScopes.push('workflow:export'); - } - if (projectIds.length > 0) { - requiredScopes.push('project:export'); - } - - for (const scope of requiredScopes) { - if (!apiKeyScopes.includes(scope)) { - throw new ForbiddenError('Forbidden'); - } - } - - return apiKeyScopes; -} - -async function streamPackageExport( - res: Response, - { stream, counts }: ExportPackageResult, -): Promise { - res.setHeader('Content-Type', 'application/gzip'); - res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"'); - res.setHeader(EXPORT_COUNTS_HEADER, JSON.stringify(counts)); - // Cross-origin browser clients can only read the counts header if it's exposed. - res.setHeader('Access-Control-Expose-Headers', EXPORT_COUNTS_HEADER); - - return await new Promise((resolve, reject) => { - stream.on('error', reject); - res.on('finish', () => resolve(res)); - res.on('close', () => { - if (!res.writableFinished) stream.destroy(); - resolve(res); - }); - stream.pipe(res); - }); -} - -const n8nPackagesHandlers: N8nPackagesHandlers = { - exportPackage: [ - publicApiCompositeScope(PACKAGE_EXPORT_SCOPES), - async (req, res) => { - let agentIds: string[] = []; - let workflowIds: string[] = []; - let folderIds: string[] = []; - let projectIds: string[] = []; - let includeVariableValues: boolean = true; - - try { - const payload = ExportPackageRequestDto.safeParse(req.body); - if (!payload.success) { - throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; ')); - } - - agentIds = payload.data.agentIds ?? []; - workflowIds = payload.data.workflowIds ?? []; - folderIds = payload.data.folderIds ?? []; - projectIds = payload.data.projectIds ?? []; - includeVariableValues = payload.data.includeVariableValues; - - const hasLooseSelection = [agentIds, workflowIds, folderIds].some((ids) => ids.length > 0); - if (projectIds.length > 0 && hasLooseSelection) { - throw new BadRequestError( - 'Provide either agentIds/workflowIds/folderIds or projectIds, not both', - ); - } - - if (!hasLooseSelection && projectIds.length === 0) { - throw new BadRequestError( - 'At least one agentId, workflowId, folderId, or projectId is required', - ); - } - - const apiKeyScopes = assertPackageExportApiKeyScopes( - req, - agentIds, - workflowIds, - folderIds, - projectIds, - ); - - const exportResult = await Container.get(N8nPackagesService).exportPackage({ - user: req.user, - agentIds, - workflowIds, - folderIds, - projectIds, - includeVariableValues, - canExportVariableValues: apiKeyScopes.includes('variable:list'), - includeTags: payload.data.includeTags, - dependencyPolicy: payload.data.dependencyPolicy, - versionPolicy: payload.data.versionPolicy, - credentialExportPolicy: payload.data.credentialExportPolicy, - includeArchivedWorkflows: payload.data.includeArchivedWorkflows, - }); - - return await streamPackageExport(res, exportResult); - } catch (error) { - Container.get(EventService).emit('n8n-package-export-failed', { - user: req.user, - reason: classifyPackageFailure(error), - ...(agentIds.length ? { agentIds } : {}), - ...(workflowIds.length ? { workflowIds } : {}), - ...(folderIds.length ? { folderIds } : {}), - ...(projectIds.length ? { projectIds } : {}), - }); - - if ( - error instanceof PackageEntityAccessDeniedError || - error instanceof PackageEntityNotFoundError - ) { - throw new UserError(error.message, { description: error.description }); - } - throw error; - } - }, - ], -}; - -export = n8nPackagesHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/exportPackage.generated.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/exportPackage.generated.yml new file mode 100644 index 00000000000..de280e49eb8 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/exportPackage.generated.yml @@ -0,0 +1,149 @@ +operationId: exportPackage +tags: + - N8nPackage +summary: 'Beta: Export agents, workflows, folders, or projects as an n8n package' +description: | + **Beta** — breaking changes may still occur without major version bump. + + Export agents, workflows, and folders, or whole projects, as a gzipped tar + archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`. + Do not combine these two groups. Each exported folder includes its nested + folders. Whole projects include their agents when the agents module is enabled. + Explicit agent selections require the agents module. Empty projects export + project metadata only. `versionPolicy` selects the version for both agents and + workflows and defaults to `latest`. `dependencyPolicy` applies to both entity + types and defaults to `fail` when a required definition is absent from the + package. The response is streamed as `application/gzip` with a `Content-Disposition` + attachment header. Requires the n8n Packages feature to be licensed. + + API key scopes: `agent:export` is required for explicitly selected agents. + `workflow:export` is required for explicitly selected workflows or folders. + `project:export` is sufficient for whole-project exports, including their + agents and workflows. Included dependencies need no additional API key scope. + The caller's project permissions apply to all included entities. When + `includeVariableValues` is true (the default) and exported entities reference + variables, `variable:list` is also required. Exports that reference no variables + do not need it. + + Agent definitions include referenced skills, custom tools, and schedules. + Knowledge base files and runtime data are excluded. Agent package imports + remain unavailable. +x-required-scope: project:export,workflow:export,agent:export +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + agentIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + maxItems: 300 + description: IDs of the agents to include. Requires the agents module. + example: + - uT9LdQx7rK2MvB4f + workflowIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + maxItems: 300 + description: IDs of the workflows to include in the exported package. + example: + - 2tUt1wbLX592XDdX + folderIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + maxItems: 300 + description: IDs of the folders to include in the exported package. Each folder is exported with its nested folders. + example: + - 9xKp2mNqRzAbCdEf + projectIds: + type: array + items: + type: string + minLength: 1 + minItems: 1 + maxItems: 300 + description: IDs of the projects to include in the exported package. Includes their agents when the agents module is enabled. + example: + - Ox8O54VQrmBrb4qL + includeVariableValues: + type: boolean + default: true + description: Whether values of variables referenced by the exported entities are bundled into the package. When `false`, variables still travel as name/type files and are listed in the package requirements, but no values travel with the package. + includeTags: + type: boolean + default: true + description: Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag files, tag references, or tag requirements travel with the package. + dependencyPolicy: + type: string + enum: + - fail + - reference-only + - include-in-package + default: fail + description: Policy for workflow and agent dependencies outside the selected package contents. Includes static workflow references and disabled agent references. `fail` aborts when a required workflow or agent is absent from the package. `include-in-package` adds accessible dependencies recursively. `reference-only` records external requirements without including those definitions. The same policy applies to workflows and agents. + example: fail + versionPolicy: + type: string + enum: + - published-strict + - prefer-published + - ignore-unpublished + - latest + default: latest + description: Which version of each workflow and agent travels in the package. `latest` exports the current draft. `published-strict` requires a published version. `prefer-published` uses the published version when available and the draft otherwise. `ignore-unpublished` skips unpublished selections. Dependencies that cannot be included cause the export to fail only when `dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`, the export records them as external requirements. The selected version supplies the definition and its references. Workflow names, settings, and tags always use their current values. Agent IDs, names, and MCP availability always use their current values. + example: latest + credentialExportPolicy: + type: string + enum: + - expression-values-only + - no-values + default: expression-values-only + description: Whether expression values from credential data are bundled into the package. `expression-values-only` includes credential fields whose value is an n8n expression (for example `={{ $secrets.apiKey }}`); literal values never travel either way. `no-values` keeps credential data out of the package entirely, so each credential file carries only its id, name and type. + example: expression-values-only + includeArchivedWorkflows: + type: boolean + default: false + description: 'Whether folder and project exports include their archived workflows. When `false` (default) they are left out. When `true` they travel with `isArchived: true` and are archived on import. Workflows listed in `workflowIds` always export, also when archived.' + example: false + additionalProperties: false +responses: + '200': + description: A gzipped tar archive containing the exported package contents. + headers: + X-N8n-Export-Counts: + description: | + JSON-serialized per-entity counts of what actually ended up in the + package (after folder bundling and auto-inclusion), e.g. + `{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`. + required: true + schema: + type: string + content: + application/gzip: + schema: + type: string + format: binary + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml deleted file mode 100644 index e149915b24d..00000000000 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml +++ /dev/null @@ -1,64 +0,0 @@ -post: - x-eov-operation-id: exportPackage - x-required-scope: project:export,workflow:export,agent:export - x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler - tags: - - N8nPackage - summary: 'Beta: Export agents, workflows, folders, or projects as an n8n package' - description: | - **Beta** — breaking changes may still occur without major version bump. - - Export agents, workflows, and folders, or whole projects, as a gzipped tar - archive (.n8np). Provide `agentIds`/`workflowIds`/`folderIds`, or `projectIds`. - Do not combine these two groups. Each exported folder includes its nested - folders. Whole projects include their agents when the agents module is enabled. - Explicit agent selections require the agents module. Empty projects export - project metadata only. `versionPolicy` selects the version for both agents and - workflows and defaults to `latest`. `dependencyPolicy` applies to both entity - types and defaults to `fail` when a required definition is absent from the - package. The response is streamed as `application/gzip` with a `Content-Disposition` - attachment header. Requires the n8n Packages feature to be licensed. - - API key scopes: `agent:export` is required for explicitly selected agents. - `workflow:export` is required for explicitly selected workflows or folders. - `project:export` is sufficient for whole-project exports, including their - agents and workflows. Included dependencies need no additional API key scope. - The caller's project permissions apply to all included entities. When - `includeVariableValues` is true (the default) and exported entities reference - variables, `variable:list` is also required. Exports that reference no variables - do not need it. - - Agent definitions include referenced skills, custom tools, and schedules. - Knowledge base files and runtime data are excluded. Agent package imports - remain unavailable. - requestBody: - description: Agents, workflows, folders, or projects to include in the exported package. - required: true - content: - application/json: - schema: - $ref: '../schemas/exportPackageRequest.yml' - responses: - '200': - description: A gzipped tar archive containing the exported package contents. - headers: - X-N8n-Export-Counts: - description: | - JSON-serialized per-entity counts of what actually ended up in the - package (after folder bundling and auto-inclusion), e.g. - `{"agents":1,"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0,"tags":0}`. - schema: - type: string - content: - application/gzip: - schema: - type: string - format: binary - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml deleted file mode 100644 index cb779494897..00000000000 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/exportPackageRequest.yml +++ /dev/null @@ -1,120 +0,0 @@ -type: object -additionalProperties: false -description: >- - Selects what to export. Provide `agentIds`, `workflowIds`, and/or `folderIds` - to export loose entities, or `projectIds` to export whole projects. - Do not combine these two groups. Supply at least one ID. -properties: - agentIds: - type: array - minItems: 1 - maxItems: 300 - description: IDs of the agents to include. Requires the agents module. - items: - type: string - minLength: 1 - example: - - uT9LdQx7rK2MvB4f - workflowIds: - type: array - maxItems: 300 - description: IDs of the workflows to include in the exported package. - items: - type: string - minLength: 1 - example: - - 2tUt1wbLX592XDdX - folderIds: - type: array - maxItems: 300 - description: >- - IDs of the folders to include in the exported package. Each folder is - exported with its nested folders. - items: - type: string - minLength: 1 - example: - - 9xKp2mNqRzAbCdEf - projectIds: - type: array - description: >- - IDs of the projects to include in the exported package. - Includes their agents when the agents module is enabled. - items: - type: string - minLength: 1 - example: - - Ox8O54VQrmBrb4qL - includeVariableValues: - type: boolean - default: true - description: >- - Whether values of variables referenced by the exported entities are - bundled into the package. When `false`, variables still travel as - name/type files and are listed in the package requirements, but no - values travel with the package. - includeTags: - type: boolean - default: true - description: >- - Whether tags assigned to the exported workflows are bundled into the - package. When `false`, no tag files, tag references, or tag - requirements travel with the package. - dependencyPolicy: - type: string - enum: - - fail - - reference-only - - include-in-package - description: >- - Policy for workflow and agent dependencies outside the selected package - contents. Includes static workflow references and disabled agent references. - `fail` aborts when a required workflow or agent is absent from the package. - `include-in-package` adds accessible dependencies recursively. - `reference-only` records external requirements without including those - definitions. The same policy applies to workflows and agents. - example: fail - default: fail - versionPolicy: - type: string - enum: - - published-strict - - prefer-published - - ignore-unpublished - - latest - description: >- - Which version of each workflow and agent travels in the package. `latest` - exports the current draft. `published-strict` requires a published version. - `prefer-published` uses the published version when available and the draft - otherwise. `ignore-unpublished` skips unpublished selections. - Dependencies that cannot be included cause the export to fail only when - `dependencyPolicy` is `fail` or `include-in-package`. With `reference-only`, - the export records them as external requirements. - The selected version supplies the definition and its references. - Workflow names, settings, and tags always use their current values. - Agent IDs, names, and MCP availability always use their current values. - example: latest - default: latest - credentialExportPolicy: - type: string - enum: - - expression-values-only - - no-values - description: >- - Whether expression values from credential data are bundled into the - package. `expression-values-only` includes credential fields whose value - is an n8n expression (for example `={{ $secrets.apiKey }}`); literal - values never travel either way. `no-values` keeps credential data out of - the package entirely, so each credential file carries only its id, name - and type. - example: expression-values-only - default: expression-values-only - includeArchivedWorkflows: - type: boolean - description: >- - Whether folder and project exports include their archived workflows. When - `false` (default) they are left out. When `true` they travel with - `isArchived: true` and are archived on import. Workflows listed in - `workflowIds` always export, also when archived. - example: false - default: false diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 298ca71846c..237e36d7cad 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -189,6 +189,9 @@ paths: /n8n-packages/import-selection: post: $ref: ./handlers/n8n-packages/spec/paths/importPackageSelection.generated.yml + /n8n-packages/export: + post: + $ref: ./handlers/n8n-packages/spec/paths/exportPackage.generated.yml /node-type-policies/instance: get: $ref: ./handlers/node-type-policies/spec/paths/getNodeTypeInstancePolicy.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 2dfb3bda516..4835782f732 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -78,9 +78,7 @@ tags: - name: Workflow description: Operations about workflows -paths: - /n8n-packages/export: - $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' +paths: {} components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts index e25d4b56e24..345e18abe75 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-export.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-export.test.ts @@ -192,16 +192,31 @@ describe('POST /n8n-packages/export', () => { const response = await authOwnerAgent .post('/n8n-packages/export') + .buffer(true) + // supertest buffers only text and JSON bodies by default + .parse((res, callback) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks))); + }) .send({ folderIds: [folder.id] }); expect(response.statusCode).toBe(200); expect(response.headers['content-type']).toContain('application/gzip'); expect(response.headers['content-disposition']).toContain('export.n8np'); + + const counts = JSON.parse(response.headers['x-n8n-export-counts']); + expect(typeof counts.workflows).toBe('number'); + expect(typeof counts.folders).toBe('number'); + expect(response.headers['access-control-expose-headers']).toBe('X-N8n-Export-Counts'); + + // Gzip magic number + const body: Buffer = response.body; + expect([body[0], body[1]]).toEqual([0x1f, 0x8b]); }); - // The OpenAPI request schema has `additionalProperties: false`, so acceptance - // proves `includeTags` is declared in exportPackageRequest.yml. - test('accepts includeTags=false through the OpenAPI request validator', async () => { + // The request DTO is strict, so acceptance proves `includeTags` is a declared field. + test('accepts includeTags=false through the request DTO', async () => { const project = await createTeamProject('Export project', owner); const folder = await createFolder(project, { name: 'to_production' }); @@ -212,8 +227,8 @@ describe('POST /n8n-packages/export', () => { expect(response.statusCode).toBe(200); }); - // Acceptance proves `credentialExportPolicy` is declared in exportPackageRequest.yml. - test('accepts credentialExportPolicy=no-values through the OpenAPI request validator', async () => { + // Acceptance proves `credentialExportPolicy` is a declared field of the request DTO. + test('accepts credentialExportPolicy=no-values through the request DTO', async () => { const project = await createTeamProject('Export project', owner); const folder = await createFolder(project, { name: 'to_production' }); @@ -234,4 +249,44 @@ describe('POST /n8n-packages/export', () => { expect(response.statusCode).toBe(400); }); + + test('rejects an unauthenticated request with 401', async () => { + const response = await testServer + .publicApiAgentWithoutApiKey() + .post('/n8n-packages/export') + .send({ workflowIds: ['wf-1'] }); + + expect(response.statusCode).toBe(401); + }); + + test('rejects a key with neither project:export nor workflow:export with 403, before the handler runs', async () => { + const readOnlyOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] }); + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + + const response = await testServer + .publicApiAgentFor(readOnlyOwner) + .post('/n8n-packages/export') + .send({ workflowIds: ['wf-1'] }); + + expect(response.statusCode).toBe(403); + expect(emitSpy).not.toHaveBeenCalledWith('n8n-package-export-failed', expect.anything()); + }); + + test('rejects a non-JSON Content-Type with 415', async () => { + const response = await authOwnerAgent + .post('/n8n-packages/export') + .set('Content-Type', 'text/plain') + .send('workflowIds'); + + expect(response.statusCode).toBe(415); + }); + + test('rejects an unknown body field with 400', async () => { + const response = await authOwnerAgent + .post('/n8n-packages/export') + .send({ workflowIds: ['wf-1'], evil: 'x' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('evil'); + }); });