feat(core): Validate missing node types on package import (#34598)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jan Kalkan
2026-07-22 21:09:27 +00:00
committed by GitHub
parent 68c7b55f86
commit 4044d58d13
54 changed files with 1350 additions and 51 deletions
@@ -1,4 +1,7 @@
import { ImportPackageRequestDto } from '../import-package-request.dto';
import {
ImportPackageRequestDto,
IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
} from '../import-package-request.dto';
describe('ImportPackageRequestDto', () => {
it('accepts omitted routing fields and defaults credential modes', () => {
@@ -12,6 +15,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -36,6 +40,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -62,6 +67,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -87,6 +93,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'skip',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -276,6 +283,40 @@ describe('ImportPackageRequestDto', () => {
});
});
describe('missingNodeTypeMode', () => {
it('defaults to "fail" when omitted', () => {
const result = ImportPackageRequestDto.safeParse({ workflowConflictPolicy: 'fail' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.missingNodeTypeMode).toBe('fail');
}
});
it('accepts "import-anyway"', () => {
const result = ImportPackageRequestDto.safeParse({
workflowConflictPolicy: 'fail',
missingNodeTypeMode: 'import-anyway',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.missingNodeTypeMode).toBe('import-anyway');
}
});
it('rejects unsupported missingNodeTypeMode values', () => {
expect(
ImportPackageRequestDto.safeParse({
workflowConflictPolicy: 'fail',
missingNodeTypeMode: 'skip',
}).success,
).toBe(false);
});
it('is accepted as a multipart form field', () => {
expect(IMPORT_PACKAGE_REQUEST_FORM_FIELDS).toContain('missingNodeTypeMode');
});
});
it.each([
{ name: 'non-string projectId', request: { projectId: 1, workflowConflictPolicy: 'fail' } },
{ name: 'non-string folderId', request: { folderId: false, workflowConflictPolicy: 'fail' } },
@@ -12,6 +12,7 @@ export const IMPORT_PACKAGE_REQUEST_FORM_FIELDS = [
'workflowConflictPolicy',
'workflowPublishingPolicy',
'workflowIdPolicy',
'missingNodeTypeMode',
'folderConflictPolicy',
'dataTableMatchingMode',
'dataTableMissingMode',
@@ -77,6 +78,7 @@ export class ImportPackageRequestDto extends Z.class({
.optional()
.default('preserve-published-state'),
workflowIdPolicy: z.enum(['new', 'source']).optional().default('new'),
missingNodeTypeMode: z.enum(['fail', 'import-anyway']).optional().default('fail'),
folderConflictPolicy: z.enum(['merge', 'fail']).optional().default('merge'),
dataTableMatchingMode: z.enum(['by-id']).optional().default('by-id'),
dataTableMissingMode: z
@@ -57,6 +57,7 @@ n8n-cli package import --file=export.n8np --conflict-policy=fail --bindings='{"c
| `--folder` | Target folder ID within the project. Defaults to the project root. |
| `--workflow-publishing-policy` | Whether imported workflows end up published. `preserve-published-state` (instance default) never publishes drafts — an updated workflow is republished only when it was already published and the package workflow is published too; `match-source` follows the package workflow's published flag; `publish-all` publishes every imported workflow; `unpublish-all` leaves new workflows unpublished and unpublishes updated ones. |
| `--workflow-id-policy` | Whether imported workflows keep their source ID (`source`) or receive a new one (`new`). |
| `--missing-node-type-mode` | What to do when a workflow uses a node type — or a version of a node type — this instance does not have. `fail` (instance default) rejects the import before anything is written, listing every missing node type and the workflows that use it; `import-anyway` imports the package, but the affected workflows are never published by the import, regardless of the publishing policy. |
| `--folder-conflict-policy` | What to do when a package folder already exists in the target project: `merge` (default, reuse the existing folder and merge the package's children into it) or `fail`. Requires a folders-enabled license when the package contains folders. |
| `--credential-matching-mode` | How credential references are matched on the target instance: `id-only` (default, match by id), `name-and-type` (match by exact name and type), or `type-only` (match by type). For `name-and-type` and `type-only`, candidates are ranked by scope — owned by the target project, then shared into it, then global — and ties within a scope use the most recently updated credential. |
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `must-preexist` requires every referenced credential to already exist. |
+25 -1
View File
@@ -144,6 +144,7 @@ describe('N8nClient packages', () => {
folderId: '',
workflowIdPolicy: 'new',
credentialMatchingMode: undefined,
missingNodeTypeMode: undefined,
},
);
@@ -155,9 +156,11 @@ describe('N8nClient packages', () => {
expect(form.get('workflowConflictPolicy')).toBe('fail');
expect(form.get('projectId')).toBe('proj-1');
expect(form.get('workflowIdPolicy')).toBe('new');
// Empty/undefined fields are omitted entirely.
// Empty/undefined fields are omitted entirely (an omitted CLI flag
// means the instance default decides).
expect(form.has('folderId')).toBe(false);
expect(form.has('credentialMatchingMode')).toBe(false);
expect(form.has('missingNodeTypeMode')).toBe(false);
const pkg = form.get('package');
expect(pkg).toBeInstanceOf(Blob);
@@ -189,6 +192,27 @@ describe('N8nClient packages', () => {
expect(form.get('credentialMissingMode')).toBe('create-stub');
});
it('sends missingNodeTypeMode when provided', async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
workflows: [],
bindings: {},
credentials: { matched: [], stubbed: [] },
}),
);
await client.importPackage(
{ buffer: Buffer.from('package-bytes'), filename: 'export.n8np' },
{
workflowConflictPolicy: 'fail',
missingNodeTypeMode: 'import-anyway',
},
);
const form = (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(form.get('missingNodeTypeMode')).toBe('import-anyway');
});
it('sends the data table modes when provided', async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
@@ -14,6 +14,7 @@ interface ImportFlags {
conflictPolicy: string;
workflowPublishingPolicy?: string;
workflowIdPolicy?: string;
missingNodeTypeMode?: string;
folderConflictPolicy?: string;
credentialMatchingMode?: string;
credentialMissingMode?: string;
@@ -59,6 +60,7 @@ describe('package import command', () => {
conflictPolicy: 'fail',
workflowPublishingPolicy: 'publish-all',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'import-anyway',
folderConflictPolicy: 'merge',
credentialMatchingMode: 'id-only',
credentialMissingMode: 'create-stub',
@@ -82,6 +84,7 @@ describe('package import command', () => {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'publish-all',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'import-anyway',
folderConflictPolicy: 'merge',
credentialMatchingMode: 'id-only',
credentialMissingMode: 'create-stub',
+1
View File
@@ -18,6 +18,7 @@ export interface ImportPackageFields {
workflowConflictPolicy: string;
workflowPublishingPolicy?: string;
workflowIdPolicy?: string;
missingNodeTypeMode?: string;
folderConflictPolicy?: string;
dataTableMatchingMode?: string;
dataTableMissingMode?: string;
@@ -41,6 +41,12 @@ export default class PackageImport extends BaseCommand {
options: ['new', 'source'],
aliases: ['workflow-id-policy'],
}),
missingNodeTypeMode: Flags.string({
description:
'What to do when a workflow uses a node type or version this instance does not have (default on the instance: fail). With import-anyway, affected workflows are imported but never published',
options: ['fail', 'import-anyway'],
aliases: ['missing-node-type-mode'],
}),
folderConflictPolicy: Flags.string({
description: 'What to do when a package folder already exists in the target project',
options: ['merge', 'fail'],
@@ -106,6 +112,7 @@ export default class PackageImport extends BaseCommand {
workflowConflictPolicy: flags.conflictPolicy,
workflowPublishingPolicy: flags.workflowPublishingPolicy,
workflowIdPolicy: flags.workflowIdPolicy,
missingNodeTypeMode: flags.missingNodeTypeMode,
folderConflictPolicy: flags.folderConflictPolicy,
credentialMatchingMode: flags.credentialMatchingMode,
credentialMissingMode: flags.credentialMissingMode,
@@ -8,7 +8,13 @@ type BlockingIssue =
name: string;
}
| { type: 'credential-unresolved'; kind: string; sourceId: string; usedByWorkflows: string[] }
| { type: 'variable-unresolved'; name: string; usedByWorkflows: string[] };
| { type: 'variable-unresolved'; name: string; usedByWorkflows: string[] }
| {
type: 'missing-node-type';
nodeType: string;
typeVersion: number;
usedByWorkflows: string[];
};
function formatIssue(issue: unknown): string {
if (typeof issue !== 'object' || issue === null) return JSON.stringify(issue);
@@ -24,6 +30,10 @@ function formatIssue(issue: unknown): string {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
return `variable "${it.name}" unresolved, used by workflow(s) ${usedBy}`;
}
if (it.type === 'missing-node-type') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
return `node type ${it.nodeType} @ v${it.typeVersion} missing on this instance, used by workflow(s) ${usedBy}`;
}
return JSON.stringify(issue);
}
+123 -1
View File
@@ -1,3 +1,4 @@
import type { Logger } from '@n8n/backend-common';
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
import type {
LoadedClass,
@@ -11,9 +12,10 @@ import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
describe('NodeTypes', () => {
const logger = mock<Logger>();
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials);
const nodeTypes: NodeTypes = new NodeTypes(logger, loadNodesAndCredentials);
const nonVersionedNode: LoadedClass<INodeType> = {
sourcePath: '',
@@ -102,6 +104,50 @@ describe('NodeTypes', () => {
supplyData: undefined,
},
};
// Versioned node whose v1 cannot be used as a tool while v2 can. Plain-object
// descriptions (not mock proxies) so `usableAsTool` reads are real.
const partiallyToolCapableNode: LoadedClass<IVersionedNodeType> = {
sourcePath: '',
type: {
description: {
name: 'n8n-nodes-base.partiallyToolCapable',
displayName: 'Partially Tool Capable',
} as unknown as INodeTypeDescription,
currentVersion: 2,
nodeVersions: {
1: {
description: {
name: 'n8n-nodes-base.partiallyToolCapable',
version: 1,
properties: [],
},
} as unknown as INodeType,
2: {
description: {
name: 'n8n-nodes-base.partiallyToolCapable',
version: 2,
usableAsTool: true,
properties: [],
},
} as unknown as INodeType,
},
getNodeType(version) {
return this.nodeVersions[version === 1 ? 1 : 2];
},
},
};
const multiVersionNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
name: 'n8n-nodes-base.multiVersion',
displayName: 'Multi Version Node',
version: [1, 1.1, 2],
properties: [],
} as unknown as INodeTypeDescription,
supplyData: undefined,
},
};
const hitlSupportingNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
@@ -162,6 +208,8 @@ describe('NodeTypes', () => {
if (nodeType === 'declarativeNode') return declarativeNode;
if (nodeType === 'toolNode') return toolNode;
if (nodeType === 'plainToolNode') return plainToolSupportingNode;
if (nodeType === 'partiallyToolCapable') return partiallyToolCapableNode;
if (nodeType === 'multiVersion') return multiVersionNode;
if (nodeType === 'hitlNode') return hitlSupportingNode;
if (nodeType === 'realTool') return realToolNode;
if (nodeType === 'replacementToolNode') return replacementToolNode;
@@ -266,6 +314,80 @@ describe('NodeTypes', () => {
});
});
describe('getSupportedVersions', () => {
it('should return the single version of a plain node type', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNode')).toEqual([1]);
});
it('should return every version of a plain node type with a version array', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.multiVersion')).toEqual([1, 1.1, 2]);
});
it('should return the nodeVersions keys of a versioned node type', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.versioned')).toEqual([1, 2]);
});
it('should return undefined for an unknown node type', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.unknownNode')).toBeUndefined();
expect(nodeTypes.getSupportedVersions('invalid-package.unknownNode')).toBeUndefined();
});
it('should resolve a Tool-suffixed name against its base node', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.plainToolNodeTool')).toEqual([1]);
});
it('should not count versions that cannot be used as a tool for a Tool-suffixed name', () => {
// The base node exists at version 1 but is not usableAsTool, so no
// version can satisfy the synthetic tool wrapper …
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNodeTool')).toEqual([]);
// … while HitlTool wrappers have no usability requirement.
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNodeHitlTool')).toEqual([1]);
});
it('should keep only tool-capable versions of a versioned node for a Tool-suffixed name', () => {
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.partiallyToolCapableTool')).toEqual([
2,
]);
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.partiallyToolCapable')).toEqual([1, 2]);
});
it('should warn and return undefined when the node fails to load for another reason', () => {
loadNodesAndCredentials.getNode.mockImplementationOnce(() => {
throw new TypeError('boom');
});
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.hitlNode')).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to resolve node type while listing supported versions',
{ nodeType: 'n8n-nodes-base.hitlNode', error: 'boom' },
);
});
it('should warn and return undefined when name resolution surfaces a non-node value', () => {
// A hostile name can make `getNode` return a prototype-chain value
// instead of throwing; the reads after it must still fail closed.
loadNodesAndCredentials.getNode.mockReturnValueOnce(Object as never);
expect(nodeTypes.getSupportedVersions('n8n-nodes-base.poisoned')).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to resolve node type while listing supported versions',
expect.objectContaining({ nodeType: 'n8n-nodes-base.poisoned' }),
);
});
it('should warn and return undefined when the tool-name check itself throws', () => {
loadNodesAndCredentials.recognizesNode.mockImplementationOnce(() => {
throw new TypeError('boom');
});
expect(nodeTypes.getSupportedVersions('constructor.anythingTool')).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Failed to resolve node type while listing supported versions',
{ nodeType: 'constructor.anythingTool', error: 'boom' },
);
});
});
describe('getWithSourcePath', () => {
it('should return description and source path for existing node', () => {
const result = nodeTypes.getWithSourcePath('n8n-nodes-base.nonVersioned', 1);
+23
View File
@@ -5,6 +5,7 @@ import {
shouldAssignExecuteMethod,
getAllKeyPaths,
isWorkflowIdValid,
satisfiesToolCapability,
setMicrosoftObservabilityDefaults,
containsExpression,
stripToolSuffix,
@@ -65,6 +66,28 @@ describe('stripToolSuffix', () => {
});
});
describe('satisfiesToolCapability', () => {
const nodeWith = (usableAsTool: boolean | undefined) =>
({ description: { usableAsTool } }) as INodeType;
it('exempts HITL tool names from the capability requirement', () => {
expect(satisfiesToolCapability('n8n-nodes-base.gmailHitlTool', nodeWith(undefined))).toBe(true);
});
it('accepts a tool name when the resolved node declares usableAsTool', () => {
expect(satisfiesToolCapability('n8n-nodes-base.gmailTool', nodeWith(true))).toBe(true);
});
it.each([undefined, false])(
'rejects a tool name when the resolved node has usableAsTool: %s',
(usableAsTool) => {
expect(satisfiesToolCapability('n8n-nodes-base.gmailTool', nodeWith(usableAsTool))).toBe(
false,
);
},
);
});
describe('shouldAssignExecuteMethod', () => {
it('should return true when node has no execute, poll, trigger, webhook (unless declarative), or methods', () => {
const nodeType = {
@@ -72,6 +72,7 @@ describe('LogStreamingEventRelay', () => {
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
workflowPublishingPolicy: 'preserve-published-state',
missingNodeTypeMode: 'fail',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
@@ -128,6 +129,7 @@ describe('LogStreamingEventRelay', () => {
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
workflowPublishingPolicy: 'preserve-published-state',
missingNodeTypeMode: 'fail',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
@@ -2232,6 +2232,7 @@ describe('TelemetryEventRelay', () => {
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
workflowPublishingPolicy: 'preserve-published-state',
missingNodeTypeMode: 'fail',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
@@ -2277,6 +2278,7 @@ describe('TelemetryEventRelay', () => {
credential_matching_mode: 'id-only',
credential_missing_mode: 'must-preexist',
workflow_publishing_policy: 'preserve-published-state',
missing_node_type_mode: 'fail',
data_table_matching_mode: 'by-id',
data_table_missing_mode: 'create',
data_table_schema_conflict_policy: 'keep-existing',
@@ -1076,6 +1076,7 @@ export class TelemetryEventRelay extends EventRelay {
credential_matching_mode: options.credentialMatchingMode,
credential_missing_mode: options.credentialMissingMode,
workflow_publishing_policy: options.workflowPublishingPolicy,
missing_node_type_mode: options.missingNodeTypeMode,
data_table_matching_mode: options.dataTableMatchingMode,
data_table_missing_mode: options.dataTableMissingMode,
data_table_schema_conflict_policy: options.dataTableSchemaConflictPolicy,
@@ -392,6 +392,10 @@ describe('folder package export — with contained workflows', () => {
usedByWorkflows: [workflow.id],
},
],
// Folder packages fold node type usage too (shared WorkflowExporter path).
nodeTypes: [
{ type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] },
],
});
expect(
entries.find((e) => e.name === `${manifest.credentials![0].target}/credential.json`),
@@ -426,6 +430,7 @@ describe('folder package export — with contained workflows', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [
{ name: 'API_URL', value: 'https://api.example.com', usedByWorkflows: [workflow.id] },
],
@@ -313,6 +313,9 @@ describe('project package export — with folders / workflows', () => {
entries.find((e) => e.name === `${credentialEntry.target}/credential.json`),
).toBeDefined();
expect(manifest.requirements).toEqual({
nodeTypes: [
{ type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] },
],
credentials: [
{
id: credential.id,
@@ -347,6 +350,7 @@ describe('project package export — with folders / workflows', () => {
expect(variableEntry.target).toMatch(new RegExp(`^${projectEntry.target}/variables/[^/]+$`));
expect(entries.find((e) => e.name === `${variableEntry.target}/variable.json`)).toBeDefined();
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [
{ name: 'API_URL', value: 'https://team.example.com', usedByWorkflows: [workflow.id] },
],
@@ -76,6 +76,7 @@ describe('workflow package export — with credentials', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
credentials: [
{
id: credential.id,
@@ -195,6 +196,7 @@ describe('workflow package export — with credentials', () => {
expect(manifest.credentials).toBeUndefined();
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
credentials: [
{
id: 'does-not-exist',
@@ -241,6 +243,7 @@ describe('workflow package export — with credentials', () => {
expect(manifest.credentials).toBeUndefined();
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
credentials: [
{
id: credential.id,
@@ -72,6 +72,7 @@ describe('workflow package export — with data tables', () => {
const { manifest, entries } = await readExport(stream);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
dataTables: [
{
id: dataTable.id,
@@ -72,6 +72,7 @@ describe('workflow package export — with variables', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [
{
name: 'API_URL',
@@ -121,6 +122,7 @@ describe('workflow package export — with variables', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
});
expect(manifest.requirements!.variables![0]).not.toHaveProperty('value');
@@ -154,6 +156,7 @@ describe('workflow package export — with variables', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [
{
name: 'API_URL',
@@ -192,6 +195,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
});
expect(manifest.requirements!.variables![0]).not.toHaveProperty('value');
@@ -292,6 +296,7 @@ describe('workflow package export — with variables', () => {
},
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'legacy-key', value: 'legacy-value', usedByWorkflows: [workflow.id] }],
});
expect(variableFiles(entries)).toHaveLength(1);
@@ -311,6 +316,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'DOES_NOT_EXIST', usedByWorkflows: [workflow.id] }],
});
expect(variableFiles(entries)).toEqual([]);
@@ -337,6 +343,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
});
expect(variableFiles(entries)).toEqual([]);
@@ -446,6 +453,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'PRIVATE_VAR', usedByWorkflows: [workflow.id] }],
});
expect(variableFiles(entries)).toEqual([]);
@@ -488,7 +496,7 @@ describe('workflow package export — with variables', () => {
const { manifest } = await readExport(stream);
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toBeUndefined();
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
});
it('allows a value-less export of referenced variables when values are excluded', async () => {
@@ -510,6 +518,7 @@ describe('workflow package export — with variables', () => {
const { manifest, entries } = await readExport(stream);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
});
// Stubs still travel (name/type only) — the scope gate is value-only.
@@ -111,6 +111,58 @@ describe('workflow package export', () => {
}
});
it('folds node type usage into manifest.requirements.nodeTypes', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const trigger = (id: string) => ({
id,
name: `Trigger ${id}`,
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
});
const wfA = await createWorkflow(
{ name: 'Alpha', nodes: [trigger('t1')], connections: {} },
project,
);
const wfB = await createWorkflow(
{
name: 'Beta',
nodes: [
trigger('t2'),
{
id: 's1',
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [200, 0],
parameters: {},
},
],
connections: {},
},
project,
);
const stream = await service.exportPackage({
user: owner,
workflowIds: [wfA.id, wfB.id],
});
const { manifest } = await readExport(stream);
expect(manifest.requirements).toEqual({
nodeTypes: [
{
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
usedByWorkflows: [wfA.id, wfB.id],
},
{ type: 'n8n-nodes-base.set', typeVersion: 3.4, usedByWorkflows: [wfB.id] },
],
});
});
it('disambiguates targets when two workflows share a name', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
@@ -226,6 +278,13 @@ describe('workflow package export', () => {
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }],
nodeTypes: [
{
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
usedByWorkflows: [parent.id],
},
],
});
});
@@ -271,6 +330,13 @@ describe('workflow package export', () => {
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }],
nodeTypes: [
{
type: '@n8n/n8n-nodes-langchain.toolWorkflow',
typeVersion: 2.2,
usedByWorkflows: [parent.id],
},
],
});
});
@@ -334,6 +400,13 @@ describe('workflow package export', () => {
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: expectedUsedByWorkflows }],
nodeTypes: [
{
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
usedByWorkflows: [parentA.id, parentB.id],
},
],
});
});
@@ -17,6 +17,7 @@ import { DataTableService } from '@/modules/data-table/data-table.service';
import { createFolder } from '@test-integration/db/folders';
import { createOwner } from '@test-integration/db/users';
import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import type { ImportPackageRequest } from '../n8n-packages.types';
@@ -42,6 +43,9 @@ const licenseMocker = new LicenseMocker();
beforeAll(async () => {
await testModules.loadModules(['n8n-packages', 'data-table']);
await testDb.init();
// Register node types so the plan-phase missing-node-type check can resolve
// the node types used by the package fixtures.
await initNodeTypes();
mockDataTableSizeValidator();
licenseMocker.mockLicenseState(Container.get(LicenseState));
service = Container.get(N8nPackagesService);
@@ -65,6 +69,7 @@ async function importPackage(params: ImportParams) {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -10,6 +10,7 @@ import { UnprocessableRequestError } from '@/errors/response-errors/unprocessabl
import { createFolder } from '@test-integration/db/folders';
import { createOwner } from '@test-integration/db/users';
import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import type { FolderConflictPolicy, ImportPackageRequest } from '../n8n-packages.types';
@@ -42,6 +43,7 @@ async function importFolders(params: FolderImportParams) {
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: params.folderConflictPolicy ?? 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -70,6 +72,9 @@ async function findWorkflow(id: string) {
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
// Register node types so the plan-phase missing-node-type check can resolve
// the node types used by the package fixtures.
await initNodeTypes();
licenseMocker.mockLicenseState(Container.get(LicenseState));
licenseMocker.setDefaults({ features: ['feat:folders'] });
});
@@ -21,6 +21,7 @@ import { Container } from '@n8n/di';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialTypes } from '@/credential-types';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import {
@@ -40,6 +41,7 @@ import {
DataTableMatchingMode,
DataTableMissingMode,
DataTableSchemaConflictPolicy,
MissingNodeTypeMode,
WorkflowConflictPolicy,
WorkflowIdPolicy,
WorkflowPublishingPolicy,
@@ -65,6 +67,7 @@ type ImportPackageParams = Omit<
| 'workflowConflictPolicy'
| 'workflowPublishingPolicy'
| 'workflowIdPolicy'
| 'missingNodeTypeMode'
| 'folderConflictPolicy'
| 'dataTableMatchingMode'
| 'dataTableMissingMode'
@@ -80,6 +83,7 @@ type ImportPackageParams = Omit<
| 'workflowConflictPolicy'
| 'workflowPublishingPolicy'
| 'workflowIdPolicy'
| 'missingNodeTypeMode'
| 'folderConflictPolicy'
| 'dataTableMatchingMode'
| 'dataTableMissingMode'
@@ -95,6 +99,7 @@ async function importPackage(params: ImportPackageParams) {
workflowConflictPolicy: WorkflowConflictPolicy.Fail,
workflowPublishingPolicy: WorkflowPublishingPolicy.PreservePublishedState,
workflowIdPolicy: WorkflowIdPolicy.New,
missingNodeTypeMode: MissingNodeTypeMode.Fail,
folderConflictPolicy: FolderConflictPolicy.Merge,
dataTableMatchingMode: DataTableMatchingMode.ById,
dataTableMissingMode: DataTableMissingMode.Create,
@@ -2451,3 +2456,240 @@ describe('Package import workflow publishing policy', () => {
expect(activeWorkflowManager.add).not.toHaveBeenCalled();
});
});
describe('Package import missing node type mode', () => {
const sourceId = 'missing-node-type-mode-test';
const scheduleTriggerNode = () => ({
id: 'schedule-trigger',
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
});
const unknownNode = () => ({
id: 'unknown-node',
name: 'Unknown Node',
type: 'n8n-nodes-community.chatBot',
typeVersion: 1,
position: [200, 0] as [number, number],
parameters: {},
});
const unresolvableCredentialNode = () => ({
id: 'http-node',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [400, 0] as [number, number],
parameters: {},
credentials: {
[PACKAGE_GITHUB_CREDENTIAL_TYPE]: { id: 'missing-cred', name: 'Missing GitHub' },
},
});
it('fail (default) lists every missing pair and writes nothing', async () => {
const owner = await createOwner();
const importPromise = importPackage({
user: owner,
// create-stub so the rollback assertion below proves no stub was written either.
credentialMissingMode: 'create-stub',
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflow({
id: 'wf-alpha',
name: 'Alpha',
nodes: [scheduleTriggerNode(), unknownNode()],
}),
serializedWorkflow({
id: 'wf-beta',
name: 'Beta',
nodes: [scheduleTriggerNode(), unknownNode(), unresolvableCredentialNode()],
}),
serializedWorkflow({
id: 'wf-gamma',
name: 'Gamma',
// Known node type at a version this instance does not have.
nodes: [{ ...serializedWorkflow().nodes[0], typeVersion: 9 }],
}),
],
{ sourceId },
),
});
await expect(importPromise).rejects.toBeInstanceOf(UnprocessableRequestError);
await expect(importPromise).rejects.toMatchObject({
meta: {
issues: expect.arrayContaining([
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['wf-alpha', 'wf-beta'],
},
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-base.manualTrigger',
typeVersion: 9,
usedByWorkflows: ['wf-gamma'],
},
]),
},
});
expect(await Container.get(WorkflowRepository).count()).toBe(0);
expect(await Container.get(CredentialsRepository).count()).toBe(0);
});
it.each<WorkflowPublishingPolicyValue>([
WorkflowPublishingPolicy.PublishAll,
WorkflowPublishingPolicy.MatchSource,
])(
'import-anyway blocks publishing only the affected workflows under "%s"',
async (workflowPublishingPolicy) => {
const owner = await createOwner();
const result = await importPackage({
user: owner,
missingNodeTypeMode: 'import-anyway',
// The affected workflow also gets a stubbed credential: the reported
// reason must still be missing-node-type (it takes precedence).
credentialMissingMode: 'create-stub',
workflowPublishingPolicy,
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflow({
id: 'wf-ok',
name: 'Publishable',
isPublished: true,
nodes: [scheduleTriggerNode()],
}),
serializedWorkflow({
id: 'wf-broken',
name: 'Missing node type',
isPublished: true,
nodes: [scheduleTriggerNode(), unknownNode(), unresolvableCredentialNode()],
}),
],
{ sourceId },
),
});
const ok = result.workflows.find(({ sourceWorkflowId }) => sourceWorkflowId === 'wf-ok');
const broken = result.workflows.find(
({ sourceWorkflowId }) => sourceWorkflowId === 'wf-broken',
);
expect(ok?.activeVersionId).toEqual(expect.any(String));
expect(ok?.publishing).toEqual({ state: 'published' });
expect(broken?.activeVersionId).toBeNull();
expect(broken?.publishing).toEqual({
state: 'blocked',
blockedReason: 'missing-node-type',
});
},
);
it.each(['fail', 'import-anyway'] as const)(
'"%s" imports normally and respects the publishing policy when nothing is missing',
async (missingNodeTypeMode) => {
const owner = await createOwner();
const result = await importPackage({
user: owner,
missingNodeTypeMode,
workflowPublishingPolicy: WorkflowPublishingPolicy.PublishAll,
packageBuffer: await buildImportPackageBuffer(
[serializedWorkflow({ id: 'wf-fine', name: 'Fine', nodes: [scheduleTriggerNode()] })],
{ sourceId },
),
});
expect(result.workflows[0]?.publishing).toEqual({ state: 'published' });
expect(result.workflows[0]?.activeVersionId).toEqual(expect.any(String));
},
);
it('import-anyway keeps the prior published version active when an update has missing node types', async () => {
const owner = await createOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
const active = await createActiveWorkflow({ name: 'Published workflow' }, personalProject);
await Container.get(WorkflowRepository).update(active.id, {
sourceWorkflowId: 'wf-broken-update',
});
const originalActiveVersionId = active.activeVersionId;
expect(originalActiveVersionId).not.toBeNull();
const result = await importPackage({
user: owner,
missingNodeTypeMode: 'import-anyway',
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: WorkflowPublishingPolicy.PreservePublishedState,
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflow({
id: 'wf-broken-update',
name: 'Published workflow updated',
isPublished: true,
nodes: [scheduleTriggerNode(), unknownNode()],
}),
],
{ sourceId },
),
});
const summary = result.workflows.find(
({ sourceWorkflowId }) => sourceWorkflowId === 'wf-broken-update',
);
expect(summary?.status).toBe('updated');
expect(summary?.activeVersionId).toBe(originalActiveVersionId);
expect(summary?.publishing).toEqual({
state: 'unchanged',
skippedPublishReason: 'missing-node-type',
});
const stored = await Container.get(WorkflowRepository).findOneByOrFail({ id: active.id });
expect(stored.activeVersionId).toBe(originalActiveVersionId);
});
it('fail ignores missing node types in workflows the conflict policy skips', async () => {
const owner = await createOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
owner.id,
);
const existing = await createWorkflow({ name: 'Already there' }, personalProject);
await Container.get(WorkflowRepository).update(existing.id, {
sourceWorkflowId: 'wf-skipped-broken',
});
const result = await importPackage({
user: owner,
workflowConflictPolicy: WorkflowConflictPolicy.Skip,
packageBuffer: await buildImportPackageBuffer(
[
serializedWorkflow({
id: 'wf-skipped-broken',
name: 'Already there',
nodes: [scheduleTriggerNode(), unknownNode()],
}),
serializedWorkflow({ id: 'wf-clean', name: 'Clean', nodes: [scheduleTriggerNode()] }),
],
{ sourceId },
),
});
expect(result.workflows).toHaveLength(2);
expect(
result.workflows.find(({ sourceWorkflowId }) => sourceWorkflowId === 'wf-skipped-broken')
?.status,
).toBe('skipped');
expect(
result.workflows.find(({ sourceWorkflowId }) => sourceWorkflowId === 'wf-clean')?.status,
).toBe('created');
});
});
@@ -19,6 +19,7 @@ import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { createOwner } from '@test-integration/db/users';
import { createProjectVariable, createVariable } from '@test-integration/db/variables';
import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import type { ImportPackageRequest } from '../n8n-packages.types';
@@ -46,6 +47,7 @@ async function importProjects(
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
missingNodeTypeMode: 'fail',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
@@ -86,6 +88,9 @@ async function isAdminOf(projectId: string, userId: string): Promise<boolean> {
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
// Register node types so the plan-phase missing-node-type check can resolve
// the node types used by the package fixtures.
await initNodeTypes();
licenseMocker.mockLicenseState(Container.get(LicenseState));
licenseMocker.setDefaults({
features: ['feat:projectRole:admin', 'feat:folders'],
@@ -443,6 +448,60 @@ describe('project shell import', () => {
expect(await findProject('P2')).toBeNull();
});
it('reports missing node types per project scope and writes nothing', async () => {
const unknownNode = {
id: 'unknown-node',
name: 'Unknown Node',
type: 'n8n-nodes-community.chatBot',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
{ target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) },
],
workflows: [
{
target: 'projects/brie/workflows/wfa',
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa', nodes: [unknownNode] }),
},
{
target: 'projects/stilton/workflows/wfb',
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb', nodes: [unknownNode] }),
},
],
});
// Every project scope is planned before anything is written; the same missing
// pair yields one issue per scope, each with that scope's workflows.
const importPromise = importProjects(owner, packageBuffer);
await expect(importPromise).rejects.toBeInstanceOf(UnprocessableRequestError);
await expect(importPromise).rejects.toMatchObject({
meta: {
issues: [
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['WFA'],
},
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['WFB'],
},
],
},
});
expect(await findProject('P1')).toBeNull();
expect(await findProject('P2')).toBeNull();
expect(await Container.get(WorkflowRepository).count()).toBe(0);
});
it('creates a new project under publish-all, planned before the project exists', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
@@ -8,6 +8,7 @@ import { VariablesService } from '@/environments.ee/variables/variables.service.
import { createOwner } from '@test-integration/db/users';
import { createProjectVariable, createVariable } from '@test-integration/db/variables';
import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import type { ImportPackageRequest } from '../n8n-packages.types';
@@ -24,6 +25,7 @@ const licenseMocker = new LicenseMocker();
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
await initNodeTypes();
licenseMocker.mockLicenseState(Container.get(LicenseState));
service = Container.get(N8nPackagesService);
variablesRepository = Container.get(VariablesRepository);
@@ -62,6 +64,7 @@ async function importPackage(params: ImportParams) {
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingMode: 'do-nothing',
missingNodeTypeMode: 'fail',
...params,
});
}
@@ -87,6 +87,7 @@ const request = mock<ImportPackageRequest>({
credentialMatchingMode: 'id-only',
credentialMissingMode: 'create-stub',
workflowPublishingPolicy: 'preserve-published-state',
missingNodeTypeMode: 'fail',
});
const manifest = mock<PackageManifest>({ sourceId: 'src-1', packageFormatVersion: '1' });
@@ -1,5 +1,7 @@
import { Service } from '@n8n/di';
import { NodeTypes } from '@/node-types';
import { toImportBlockedError } from './import-blocked.error';
import { CredentialImporter } from '../entities/credential/credential-importer';
import { workflowsBlockedFromPublish } from '../entities/credential/credential-missing-mode';
@@ -25,6 +27,12 @@ import type {
VariableImportPlan,
VariableImportRequest,
} from '../entities/variable/variable.types';
import {
collectMissingNodeTypes,
missingNodeTypeBlockingFailures,
workflowsWithMissingNodeTypes,
type MissingNodeTypeRequirement,
} from '../entities/workflow/missing-node-type-mode';
import type {
PreparedWorkflow,
WorkflowImportOutcome,
@@ -32,6 +40,7 @@ import type {
} from '../entities/workflow/workflow-import.types';
import { WorkflowImporter } from '../entities/workflow/workflow-importer';
import { WorkflowPublisher } from '../entities/workflow/workflow-publisher';
import type { WorkflowPublishingBlockedReason } from '../entities/workflow/workflow-publishing-policy.types';
import { createBindings } from '../n8n-packages.types';
import type {
BlockingIssue,
@@ -39,6 +48,7 @@ import type {
ImportedFolderSummary,
ImportFolderProperties,
ImportWorkflowProperties,
MissingNodeTypeMode,
PackageImportBindings,
} from '../n8n-packages.types';
@@ -71,6 +81,7 @@ export interface ImportPlan {
folderPlan: FolderImportPlan;
dataTablePlan: DataTableImportPlan;
variablePlan: VariableImportPlan;
missingNodeTypes: MissingNodeTypeRequirement[];
blockingIssues: BlockingIssue[];
}
@@ -87,6 +98,7 @@ export class ImportOrchestrator {
private readonly folderImporter: FolderImporter,
private readonly workflowImporter: WorkflowImporter,
private readonly workflowPublisher: WorkflowPublisher,
private readonly nodeTypes: NodeTypes,
) {}
async import(input: ImportOrchestrationInput): Promise<ImportOrchestrationResult> {
@@ -122,6 +134,12 @@ export class ImportOrchestrator {
const folderContext = { ...context, folderConflictPolicy: options.folderConflictPolicy };
const folderPlan = await this.folderImporter.plan(folderContext, folders);
// Skipped workflows are never written, so their node types don't gate the import.
const missingNodeTypes = collectMissingNodeTypes(
workflowPlan.items.filter((item) => item.action !== 'skip'),
(nodeType) => this.nodeTypes.getSupportedVersions(nodeType),
);
const blockingIssues = this.collectBlockingIssues({
workflowPlan,
credentialPlan,
@@ -130,6 +148,8 @@ export class ImportOrchestrator {
dataTablePlan,
variableRequest,
variablePlan,
missingNodeTypes,
missingNodeTypeMode: options.missingNodeTypeMode,
});
return {
@@ -140,6 +160,7 @@ export class ImportOrchestrator {
folderPlan,
dataTablePlan,
variablePlan,
missingNodeTypes,
blockingIssues,
};
}
@@ -165,16 +186,23 @@ export class ImportOrchestrator {
);
await this.dataTableImporter.apply(context, dataTablePlan);
const publishBlockedSourceWorkflowIds = workflowsBlockedFromPublish(
const publishBlocked = new Map<string, WorkflowPublishingBlockedReason>();
for (const sourceWorkflowId of workflowsBlockedFromPublish(
credentialRequest.requirements,
new Set(credentialResult.stubbed),
);
)) {
publishBlocked.set(sourceWorkflowId, 'stub-credential');
}
// A workflow blocked for both reasons reports missing-node-type: it physically can't run.
for (const sourceWorkflowId of workflowsWithMissingNodeTypes(plan.missingNodeTypes)) {
publishBlocked.set(sourceWorkflowId, 'missing-node-type');
}
const { outcomes, bindings } = await this.workflowImporter.apply(
{
...context,
publishingPolicy: options.workflowPublishingPolicy,
publishBlockedSourceWorkflowIds,
publishBlocked,
},
workflowPlan,
createBindings({ credentials: credentialResult.bindings }),
@@ -198,6 +226,8 @@ export class ImportOrchestrator {
dataTablePlan,
variableRequest,
variablePlan,
missingNodeTypes,
missingNodeTypeMode,
}: {
workflowPlan: WorkflowImportPlan;
credentialPlan: CredentialResolution;
@@ -206,6 +236,8 @@ export class ImportOrchestrator {
dataTablePlan: DataTableImportPlan;
variableRequest: VariableImportRequest;
variablePlan: VariableImportPlan;
missingNodeTypes: MissingNodeTypeRequirement[];
missingNodeTypeMode: MissingNodeTypeMode;
}): BlockingIssue[] {
return [
...workflowPlan.conflicts.map(
@@ -229,6 +261,14 @@ export class ImportOrchestrator {
...this.variableImporter
.blockingFailures(variableRequest, variablePlan)
.map((failure): BlockingIssue => ({ type: 'variable-unresolved', ...failure })),
...missingNodeTypeBlockingFailures(missingNodeTypeMode, missingNodeTypes).map(
({ type, typeVersion, usedByWorkflows }): BlockingIssue => ({
type: 'missing-node-type',
nodeType: type,
typeVersion,
usedByWorkflows,
}),
),
];
}
}
@@ -79,7 +79,9 @@ export function assertPackageImportApiKeyScopes(
}
}
/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */
/**
* Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match.
*/
export function identifyRequirements<T extends { usedByWorkflows: string[] }>(
requirements: T[] | undefined,
workflows: PreparedWorkflow[],
@@ -75,6 +75,7 @@ export function emitPackageImportedEvent(
credentialMatchingMode: request.credentialMatchingMode,
credentialMissingMode: request.credentialMissingMode,
workflowPublishingPolicy: request.workflowPublishingPolicy,
missingNodeTypeMode: request.missingNodeTypeMode,
dataTableMatchingMode: request.dataTableMatchingMode,
dataTableMissingMode: request.dataTableMissingMode,
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
@@ -2,6 +2,7 @@ import type { WorkflowCredentialRequirement } from '../credential/credential.typ
import type { WorkflowDataTableRequirement } from '../data-table/data-table.types';
import { mergeRequirements } from '../requirements.types';
import type { WorkflowVariableRequirement } from '../variable/variable.types';
import type { WorkflowNodeTypeSource } from '../workflow/node-type-usage';
function cred(credentialId: string, workflowId: string): WorkflowCredentialRequirement {
return {
@@ -20,6 +21,10 @@ function dataTable(dataTableId: string, workflowId: string): WorkflowDataTableRe
return { workflowId, dataTableId };
}
function nodeTypeSource(workflowId: string): WorkflowNodeTypeSource {
return { workflowId, nodes: [] };
}
describe('mergeRequirements', () => {
it('concatenates credential requirements across parts, preserving order', () => {
const merged = mergeRequirements(
@@ -27,17 +32,20 @@ describe('mergeRequirements', () => {
credentials: [cred('c1', 'w1')],
dataTables: [dataTable('dt1', 'w1')],
variables: [variable('V1', 'w1')],
nodeTypes: [nodeTypeSource('w1')],
},
{
credentials: [cred('c2', 'w2'), cred('c3', 'w3')],
dataTables: [dataTable('dt2', 'w2')],
variables: [variable('V2', 'w2')],
nodeTypes: [nodeTypeSource('w2')],
},
);
expect(merged.credentials).toEqual([cred('c1', 'w1'), cred('c2', 'w2'), cred('c3', 'w3')]);
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1'), dataTable('dt2', 'w2')]);
expect(merged.variables).toEqual([variable('V1', 'w1'), variable('V2', 'w2')]);
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1'), nodeTypeSource('w2')]);
});
it('skips undefined parts so optional export results can be passed directly', () => {
@@ -47,6 +55,7 @@ describe('mergeRequirements', () => {
credentials: [cred('c1', 'w1')],
dataTables: [dataTable('dt1', 'w1')],
variables: [variable('V1', 'w1')],
nodeTypes: [nodeTypeSource('w1')],
},
undefined,
);
@@ -54,9 +63,15 @@ describe('mergeRequirements', () => {
expect(merged.credentials).toEqual([cred('c1', 'w1')]);
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1')]);
expect(merged.variables).toEqual([variable('V1', 'w1')]);
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1')]);
});
it('returns empty requirement lists when given no parts', () => {
expect(mergeRequirements()).toEqual({ credentials: [], dataTables: [], variables: [] });
expect(mergeRequirements()).toEqual({
credentials: [],
dataTables: [],
variables: [],
nodeTypes: [],
});
});
});
@@ -71,6 +71,7 @@ describe('FolderExporter', () => {
],
dataTables: [],
variables: [],
nodeTypes: [],
},
});
@@ -1,11 +1,14 @@
import type { WorkflowCredentialRequirement } from './credential/credential.types';
import type { WorkflowDataTableRequirement } from './data-table/data-table.types';
import type { WorkflowVariableRequirement } from './variable/variable.types';
import type { WorkflowNodeTypeSource } from './workflow/node-type-usage';
export interface WorkflowExportRequirements {
credentials: WorkflowCredentialRequirement[];
dataTables: WorkflowDataTableRequirement[];
variables: WorkflowVariableRequirement[];
/** Per-workflow node lists; folded into unique pairs at manifest-assembly time. */
nodeTypes: WorkflowNodeTypeSource[];
}
export const mergeRequirements = (
@@ -14,4 +17,5 @@ export const mergeRequirements = (
credentials: parts.flatMap((part) => part?.credentials ?? []),
dataTables: parts.flatMap((part) => part?.dataTables ?? []),
variables: parts.flatMap((part) => part?.variables ?? []),
nodeTypes: parts.flatMap((part) => part?.nodeTypes ?? []),
});
@@ -0,0 +1,123 @@
import type { WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import {
collectMissingNodeTypes,
missingNodeTypeBlockingFailures,
workflowsWithMissingNodeTypes,
} from '../missing-node-type-mode';
import type { PreparedWorkflow } from '../workflow-import.types';
const SUPPORTED_VERSIONS: Record<string, number[]> = {
'n8n-nodes-base.manualTrigger': [1],
'n8n-nodes-base.set': [1, 2, 3],
};
const getSupportedVersions = (nodeType: string) => SUPPORTED_VERSIONS[nodeType];
function prepared(
sourceWorkflowId: string,
nodes: Array<Pick<INode, 'type' | 'typeVersion'>>,
): PreparedWorkflow {
return {
entity: {
nodes: nodes.map((node, index) => ({
id: `n${index}`,
name: `Node ${index}`,
position: [0, 0],
parameters: {},
...node,
})),
} as WorkflowEntity,
sourceWorkflowId,
sourcePublished: false,
parentFolderId: null,
};
}
describe('collectMissingNodeTypes', () => {
it('returns nothing for an empty workflows array', () => {
expect(collectMissingNodeTypes([], getSupportedVersions)).toEqual([]);
});
it('returns nothing when every node type and version is supported', () => {
const workflows = [
prepared('wf-1', [
{ type: 'n8n-nodes-base.manualTrigger', typeVersion: 1 },
{ type: 'n8n-nodes-base.set', typeVersion: 3 },
]),
];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([]);
});
it('reports an unknown node type as missing', () => {
const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.unknown', typeVersion: 2 }])];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-1'] },
]);
});
it('reports a known node type at an unsupported version as missing', () => {
const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.set', typeVersion: 4 }])];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.set', typeVersion: 4, usedByWorkflows: ['wf-1'] },
]);
});
it('folds duplicate pairs into one requirement with merged workflow ids', () => {
const workflows = [
prepared('wf-1', [
{ type: 'n8n-nodes-base.unknown', typeVersion: 1 },
// The same pair twice within one workflow must not duplicate the id.
{ type: 'n8n-nodes-base.unknown', typeVersion: 1 },
]),
prepared('wf-2', [
{ type: 'n8n-nodes-base.unknown', typeVersion: 1 },
{ type: 'n8n-nodes-base.unknown', typeVersion: 2 },
]),
];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
{ type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-2'] },
]);
});
it('dedupes a workflow id that reappears non-adjacently in the input', () => {
const workflows = [
prepared('wf-1', [{ type: 'n8n-nodes-base.unknown', typeVersion: 1 }]),
prepared('wf-2', [{ type: 'n8n-nodes-base.unknown', typeVersion: 1 }]),
prepared('wf-1', [{ type: 'n8n-nodes-base.unknown', typeVersion: 1 }]),
];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
]);
});
});
describe('missingNodeTypeBlockingFailures', () => {
const missing = [{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1'] }];
it('fail treats every missing pair as blocking', () => {
expect(missingNodeTypeBlockingFailures('fail', missing)).toEqual(missing);
});
it('import-anyway treats nothing as blocking', () => {
expect(missingNodeTypeBlockingFailures('import-anyway', missing)).toEqual([]);
});
});
describe('workflowsWithMissingNodeTypes', () => {
it('unions the workflow ids across every missing pair', () => {
const blocked = workflowsWithMissingNodeTypes([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
{ type: 'n8n-nodes-base.other', typeVersion: 2, usedByWorkflows: ['wf-2', 'wf-3'] },
]);
expect(blocked).toEqual(new Set(['wf-1', 'wf-2', 'wf-3']));
});
});
@@ -102,6 +102,7 @@ describe('WorkflowPublisher', () => {
createItem(false),
workflow,
WorkflowPublishingPolicy.PreservePublishedState,
new Map(),
);
expect(result.workflow).toBe(workflow);
@@ -125,6 +126,7 @@ describe('WorkflowPublisher', () => {
createItem(true),
workflow,
WorkflowPublishingPolicy.PublishAll,
new Map(),
);
expect(workflowService.activateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
@@ -149,6 +151,7 @@ describe('WorkflowPublisher', () => {
createItem(true),
workflow,
WorkflowPublishingPolicy.PublishAll,
new Map(),
);
expect(result.workflow).toBe(workflow);
@@ -186,7 +189,7 @@ describe('WorkflowPublisher', () => {
updateItem,
workflow,
WorkflowPublishingPolicy.MatchSource,
new Set(['wf-stubbed']),
new Map([['wf-stubbed', 'stub-credential']]),
);
expect(workflowService.activateWorkflow).not.toHaveBeenCalled();
@@ -210,7 +213,7 @@ describe('WorkflowPublisher', () => {
createItem(true),
workflow,
WorkflowPublishingPolicy.PublishAll,
new Set(['wf-1']),
new Map([['wf-1', 'stub-credential']]),
);
expect(workflowService.activateWorkflow).not.toHaveBeenCalled();
@@ -243,7 +246,7 @@ describe('WorkflowPublisher', () => {
updateItem,
workflow,
WorkflowPublishingPolicy.PreservePublishedState,
new Set(['wf-stubbed']),
new Map([['wf-stubbed', 'stub-credential']]),
);
expect(workflowService.activateWorkflow).not.toHaveBeenCalled();
@@ -253,5 +256,40 @@ describe('WorkflowPublisher', () => {
skippedPublishReason: 'stub-credential',
});
});
it('still unpublishes a blocked workflow under unpublish-all', async () => {
const workflow = mock<WorkflowEntity>({
id: 'wf-1',
versionId: 'v2',
activeVersionId: 'v1',
isArchived: false,
});
const unpublished = mock<WorkflowEntity>({ id: 'wf-1', activeVersionId: null });
workflowService.deactivateWorkflow.mockResolvedValue(unpublished);
const updateItem: PersistedWorkflowPlanItem = {
action: 'update',
sourceWorkflowId: 'wf-broken',
sourcePublished: true,
parentFolderId: null,
entity: mock<WorkflowEntity>(),
existing: mock<WorkflowEntity>({ id: 'wf-1' }),
};
const result = await publisher.apply(
user,
updateItem,
workflow,
WorkflowPublishingPolicy.UnpublishAll,
new Map([['wf-broken', 'missing-node-type']]),
);
expect(workflowService.activateWorkflow).not.toHaveBeenCalled();
expect(workflowService.deactivateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
source: 'import',
});
expect(result.workflow).toBe(unpublished);
expect(result.publishing).toEqual({ state: 'unpublished' });
});
});
});
@@ -1,5 +1,6 @@
import type { User, WorkflowEntity } from '@n8n/db';
import { jsonParse } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -311,4 +312,30 @@ describe('WorkflowExporter', () => {
{ workflowId: 'wf-b', variableName: 'VAR_FROM_wf-b' },
]);
});
it('collects each workflow node list into requirements.nodeTypes', async () => {
const nodeA: INode = {
id: 'n1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const a = makeWorkflow({ id: 'wf-a', nodes: [nodeA] });
const b = makeWorkflow({ id: 'wf-b' });
const { exporter } = makeExporter([a, b]);
const writer = new CapturingWriter();
const { requirements } = await exporter.export({
user,
workflowIds: [a.id, b.id],
writer,
});
expect(requirements.nodeTypes).toEqual([
{ workflowId: 'wf-a', nodes: [nodeA] },
{ workflowId: 'wf-b', nodes: [] },
]);
});
});
@@ -0,0 +1,52 @@
import { collectNodeTypeUsage, type NodeTypeUsage } from './node-type-usage';
import type { PreparedWorkflow } from './workflow-import.types';
import type { MissingNodeTypeMode } from '../../n8n-packages.types';
/** A `(type, typeVersion)` pair the target instance cannot resolve. Field names align with the manifest requirements shape. */
export type MissingNodeTypeRequirement = NodeTypeUsage;
/**
* Folds the packaged workflows' nodes into unique `(type, typeVersion)` pairs
* and returns the ones the instance cannot resolve. Read-only.
*/
export function collectMissingNodeTypes(
workflows: PreparedWorkflow[],
getSupportedVersions: (nodeType: string) => number[] | undefined,
): MissingNodeTypeRequirement[] {
const usage = collectNodeTypeUsage(
workflows.map(({ entity, sourceWorkflowId }) => ({
workflowId: sourceWorkflowId,
nodes: entity.nodes,
})),
);
const supportedByType = new Map<string, number[] | undefined>();
return usage.filter(({ type, typeVersion }) => {
if (!supportedByType.has(type)) {
supportedByType.set(type, getSupportedVersions(type));
}
return !supportedByType.get(type)?.includes(typeVersion);
});
}
/* eslint-disable @typescript-eslint/naming-convention -- API missing node type mode keys */
const BLOCKING_FAILURES: Record<
MissingNodeTypeMode,
(missing: MissingNodeTypeRequirement[]) => MissingNodeTypeRequirement[]
> = {
fail: (missing) => missing,
'import-anyway': () => [],
};
/* eslint-enable @typescript-eslint/naming-convention */
export function missingNodeTypeBlockingFailures(
mode: MissingNodeTypeMode,
missing: MissingNodeTypeRequirement[],
): MissingNodeTypeRequirement[] {
return BLOCKING_FAILURES[mode](missing);
}
/** Package workflow ids that should not be published because they use missing node types. */
export function workflowsWithMissingNodeTypes(missing: MissingNodeTypeRequirement[]): Set<string> {
return new Set(missing.flatMap(({ usedByWorkflows }) => usedByWorkflows));
}
@@ -0,0 +1,46 @@
import type { INode } from 'n8n-workflow';
/** One workflow's node list, keyed by the id the usage entries should reference. */
export interface WorkflowNodeTypeSource {
workflowId: string;
nodes: INode[];
}
/** A unique `(type, typeVersion)` pair and the workflows that use it. Matches the manifest requirements shape. */
export interface NodeTypeUsage {
type: string;
typeVersion: number;
usedByWorkflows: string[];
}
/**
* Folds every node of the given workflows into unique `(type, typeVersion)`
* pairs with deduped `usedByWorkflows`. Disabled nodes still count they
* render on canvas and are part of the content. Pure no registry lookups.
*/
export function collectNodeTypeUsage(workflows: WorkflowNodeTypeSource[]): NodeTypeUsage[] {
const usage = new Map<string, { type: string; typeVersion: number; workflowIds: Set<string> }>();
for (const { workflowId, nodes } of workflows) {
for (const node of nodes) {
const key = `${node.type}@${node.typeVersion}`;
const entry = usage.get(key);
if (entry) {
entry.workflowIds.add(workflowId);
continue;
}
usage.set(key, {
type: node.type,
typeVersion: node.typeVersion,
workflowIds: new Set([workflowId]),
});
}
}
return [...usage.values()].map(({ type, typeVersion, workflowIds }) => ({
type,
typeVersion,
usedByWorkflows: [...workflowIds],
}));
}
@@ -2,6 +2,7 @@ import type { WorkflowEntity } from '@n8n/db';
import type { WorkflowIdConflict } from './workflow-import-match.service';
import type {
WorkflowPublishingBlockedReason,
WorkflowPublishingOutcome,
WorkflowPublishingPolicy,
} from './workflow-publishing-policy.types';
@@ -10,8 +11,8 @@ import type { ImportContext } from '../../n8n-packages.types';
/** Apply-time context for the workflow importer: the resolved import target plus apply-only inputs. */
export interface WorkflowImportContext extends ImportContext {
publishingPolicy: WorkflowPublishingPolicy;
/** Package workflow ids that must stay inactive because they use stubbed credentials. */
publishBlockedSourceWorkflowIds: ReadonlySet<string>;
/** Package workflow ids that must stay inactive, mapped to the reason why. */
publishBlocked: ReadonlyMap<string, WorkflowPublishingBlockedReason>;
}
export interface PreparedWorkflow {
@@ -171,7 +171,7 @@ export class WorkflowImporter {
item,
savedWorkflow,
context.publishingPolicy,
context.publishBlockedSourceWorkflowIds,
context.publishBlocked,
);
// Publish reloads the workflow without parentFolder; restore it for the import summary.
@@ -13,6 +13,7 @@ import type { PersistedWorkflowPlanItem } from './workflow-import.types';
import { decideWorkflowPublishingAction } from './workflow-publishing-policy';
import {
WorkflowPublishingPolicy,
type WorkflowPublishingBlockedReason,
type WorkflowPublishingContext,
type WorkflowPublishingOutcome,
} from './workflow-publishing-policy.types';
@@ -81,7 +82,7 @@ export class WorkflowPublisher {
item: PersistedWorkflowPlanItem,
workflow: WorkflowEntity,
policy: WorkflowPublishingPolicy,
publishBlockedSourceWorkflowIds?: ReadonlySet<string>,
publishBlocked: ReadonlyMap<string, WorkflowPublishingBlockedReason>,
): Promise<WorkflowPublishingResult> {
const action = decideWorkflowPublishingAction(policy, toPublishingContext(item, workflow));
@@ -89,7 +90,8 @@ export class WorkflowPublisher {
return { workflow, publishing: { state: 'unchanged' } };
}
if (action === 'publish' && publishBlockedSourceWorkflowIds?.has(item.sourceWorkflowId)) {
const blockedReason = publishBlocked.get(item.sourceWorkflowId);
if (action === 'publish' && blockedReason) {
// A prior published version may still be active after an update; report
// that the live publish state is unchanged rather than "blocked".
if (workflow.activeVersionId) {
@@ -97,14 +99,14 @@ export class WorkflowPublisher {
workflow,
publishing: {
state: 'unchanged',
skippedPublishReason: 'stub-credential',
skippedPublishReason: blockedReason,
},
};
}
return {
workflow,
publishing: { state: 'blocked', blockedReason: 'stub-credential' },
publishing: { state: 'blocked', blockedReason },
};
}
@@ -23,7 +23,7 @@ export type WorkflowPublishingOutcomeState =
| 'blocked'
| 'failed';
export type WorkflowPublishingBlockedReason = 'stub-credential';
export type WorkflowPublishingBlockedReason = 'stub-credential' | 'missing-node-type';
/** Result of applying a publishing policy to one imported workflow. */
export interface WorkflowPublishingOutcome {
@@ -11,6 +11,7 @@ import { CredentialRequirementsExtractor } from '../credential/credential-requir
import type { WorkflowCredentialRequirement } from '../credential/credential.types';
import { DataTableRequirementsExtractor } from '../data-table/data-table-requirements.extractor';
import type { WorkflowDataTableRequirement } from '../data-table/data-table.types';
import type { WorkflowNodeTypeSource } from './node-type-usage';
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
import type { WorkflowExportRequirements } from '../requirements.types';
import { VariableRequirementsExtractor } from '../variable/variable-requirements.extractor';
@@ -60,6 +61,7 @@ export class WorkflowExporter {
const credentials: WorkflowCredentialRequirement[] = [];
const dataTables: WorkflowDataTableRequirement[] = [];
const variables: WorkflowVariableRequirement[] = [];
const nodeTypes: WorkflowNodeTypeSource[] = [];
const fileNames = new UniqueFilenameAllocator(
request.basePrefix ? `${request.basePrefix}/workflows` : 'workflows',
'workflow',
@@ -81,9 +83,10 @@ export class WorkflowExporter {
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
dataTables.push(...this.dataTableRequirementsExtractor.extract(workflow));
variables.push(...this.variableRequirementsExtractor.extract(workflow));
nodeTypes.push({ workflowId: workflow.id, nodes: workflow.nodes ?? [] });
}
return { entries, requirements: { credentials, dataTables, variables } };
return { entries, requirements: { credentials, dataTables, variables, nodeTypes } };
}
private orderWorkflowsByRequest(
@@ -16,6 +16,7 @@ import { PackageExportBlockedError } from './entities/package-export.errors';
import { ProjectExporter } from './entities/project/project.exporter';
import { mergeRequirements } from './entities/requirements.types';
import { VariableExporter } from './entities/variable/variable.exporter';
import { collectNodeTypeUsage } from './entities/workflow/node-type-usage';
import { assertStaticSubWorkflowsIncluded } from './entities/workflow/static-sub-workflow-requirements';
import { WorkflowDependencyResolver } from './entities/workflow/workflow-dependency-resolver';
import { WorkflowRequirementExporter } from './entities/workflow/workflow-requirement.exporter';
@@ -176,6 +177,7 @@ export class N8nPackagesService {
dataTables: dataTableExportResult.requirements,
workflows: workflowRequirementExportResult.requirements,
variables: variableExportResult.requirements,
nodeTypes: collectNodeTypeUsage(requirements.nodeTypes),
});
const manifest = packageManifestSchema.parse({
@@ -244,14 +246,16 @@ export class N8nPackagesService {
dataTables: PackageRequirements['dataTables'];
workflows: PackageRequirements['workflows'];
variables: PackageRequirements['variables'];
nodeTypes: PackageRequirements['nodeTypes'];
}): PackageRequirements | undefined {
const { credentials, dataTables, workflows, variables } = input;
const { credentials, dataTables, workflows, variables, nodeTypes } = input;
const requirements: PackageRequirements = {
...(credentials?.length ? { credentials } : {}),
...(dataTables?.length ? { dataTables } : {}),
...(workflows?.length ? { workflows } : {}),
...(variables?.length ? { variables } : {}),
...(nodeTypes?.length ? { nodeTypes } : {}),
};
return Object.keys(requirements).length > 0 ? requirements : undefined;
}
@@ -45,6 +45,13 @@ export const FolderConflictPolicy = {
Fail: 'fail',
} as const;
export const MissingNodeTypeMode = {
/** Fails the import when any workflow uses a node type or version this instance does not have. */
Fail: 'fail',
/** Imports anyway; workflows containing missing node types are never published. */
ImportAnyway: 'import-anyway',
} as const;
export const MissingWorkflowDependencyPolicy = {
/** Fails the export when a workflow dependency is not included. */
Fail: 'fail',
@@ -90,6 +97,8 @@ export type WorkflowIdPolicy = (typeof WorkflowIdPolicy)[keyof typeof WorkflowId
export type FolderConflictPolicy = (typeof FolderConflictPolicy)[keyof typeof FolderConflictPolicy];
export type MissingNodeTypeMode = (typeof MissingNodeTypeMode)[keyof typeof MissingNodeTypeMode];
export type MissingWorkflowDependencyPolicy =
(typeof MissingWorkflowDependencyPolicy)[keyof typeof MissingWorkflowDependencyPolicy];
@@ -135,6 +144,7 @@ export type ImportWorkflowProperties = {
workflowConflictPolicy: WorkflowConflictPolicy;
workflowPublishingPolicy: WorkflowPublishingPolicy;
workflowIdPolicy: WorkflowIdPolicy;
missingNodeTypeMode: MissingNodeTypeMode;
};
export type ImportFolderProperties = {
@@ -260,7 +270,14 @@ export type BlockingIssue =
}
| ({ type: 'folder-conflict' } & FolderConflict)
| ({ type: 'data-table-unresolved' } & DataTableResolutionFailure)
| ({ type: 'variable-unresolved' } & VariableResolutionFailure);
| ({ type: 'variable-unresolved' } & VariableResolutionFailure)
| {
type: 'missing-node-type';
/** Node type this instance cannot resolve (at least not at `typeVersion`). */
nodeType: string;
typeVersion: number;
usedByWorkflows: string[];
};
export interface FolderConflict {
kind: 'parent-mismatch' | 'id-in-other-project' | 'fail-policy';
@@ -8,11 +8,17 @@ describe('packageRequirementsSchema', () => {
usedByWorkflows: ['wf-1'],
});
const variable = (name: string) => ({ name, usedByWorkflows: ['wf-1'] });
const nodeType = (type: string, typeVersion: number) => ({
type,
typeVersion,
usedByWorkflows: ['wf-1'],
});
it('accepts distinct entries per section', () => {
const requirements = {
credentials: [credential('cred-1'), credential('cred-2')],
variables: [variable('API_URL'), variable('REGION')],
nodeTypes: [nodeType('n8n-nodes-base.set', 3), nodeType('n8n-nodes-base.set', 3.4)],
};
expect(() => packageRequirementsSchema.parse(requirements)).not.toThrow();
@@ -33,4 +39,20 @@ describe('packageRequirementsSchema', () => {
/Duplicate variable name: API_URL/,
);
});
it('rejects duplicate node type pairs', () => {
const requirements = {
nodeTypes: [nodeType('n8n-nodes-base.set', 3), nodeType('n8n-nodes-base.set', 3)],
};
expect(() => packageRequirementsSchema.parse(requirements)).toThrow(
/Duplicate node type: n8n-nodes-base.set@3/,
);
});
it('rejects a non-finite node type version', () => {
const requirements = { nodeTypes: [nodeType('n8n-nodes-base.set', Infinity)] };
expect(() => packageRequirementsSchema.parse(requirements)).toThrow();
});
});
@@ -19,6 +19,16 @@ export const packageWorkflowRequirementSchema = z.object({
usedByWorkflows: z.array(z.string().min(1)).min(1),
});
// Node types used by the packaged workflows, folded into unique
// `(type, typeVersion)` pairs. Informational/derived only: import re-derives
// node type usage from workflow content and never trusts this section.
export const packageNodeTypeRequirementSchema = z.object({
type: z.string().min(1),
// `finite()`: JSON like `1e999` parses to Infinity (mirrors the workflow node schema).
typeVersion: z.number().finite(),
usedByWorkflows: z.array(z.string().min(1)).min(1),
});
// Variables are keyed by name, not id: a `$vars.<name>` reference resolves
// project-scope-first then global at runtime, so one requirement may be
// satisfied by different rows on different instances — no single portable id
@@ -74,10 +84,22 @@ export const packageRequirementsSchema = z.object({
.superRefine((variables, ctx) =>
assertNoDuplicateKey(variables, ({ name }) => name, 'variable name', ctx),
),
nodeTypes: z
.array(packageNodeTypeRequirementSchema)
.optional()
.superRefine((nodeTypes, ctx) =>
assertNoDuplicateKey(
nodeTypes,
({ type, typeVersion }) => `${type}@${typeVersion}`,
'node type',
ctx,
),
),
});
export type PackageCredentialRequirement = z.infer<typeof packageCredentialRequirementSchema>;
export type PackageDataTableRequirement = z.infer<typeof packageDataTableRequirementSchema>;
export type PackageWorkflowRequirement = z.infer<typeof packageWorkflowRequirementSchema>;
export type PackageVariableRequirement = z.infer<typeof packageVariableRequirementSchema>;
export type PackageNodeTypeRequirement = z.infer<typeof packageNodeTypeRequirementSchema>;
export type PackageRequirements = z.infer<typeof packageRequirementsSchema>;
@@ -0,0 +1,31 @@
import { serializedWorkflowSchema } from '../workflow.schema';
describe('serializedWorkflowSchema', () => {
const workflow = (typeVersion: number) => ({
id: 'wf-1',
name: 'Workflow',
nodes: [
{
id: 'n1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion,
position: [0, 0],
parameters: {},
},
],
connections: {},
versionId: 'v1',
parentFolderId: null,
isPublished: false,
isArchived: false,
});
it('accepts a finite node typeVersion', () => {
expect(() => serializedWorkflowSchema.parse(workflow(1.2))).not.toThrow();
});
it('rejects a non-finite node typeVersion (JSON `1e999` parses to Infinity)', () => {
expect(() => serializedWorkflowSchema.parse(workflow(Infinity))).toThrow();
});
});
@@ -13,7 +13,9 @@ const nodeSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
typeVersion: z.number(),
// `finite()`: JSON like `1e999` parses to Infinity, which would serialize to
// `null` in a missing-node-type issue and break the OpenAPI number contract.
typeVersion: z.number().finite(),
position: z.tuple([z.number(), z.number()]),
parameters: z.record(z.unknown()),
credentials: z.record(credentialReferenceSchema).optional(),
+82 -23
View File
@@ -1,20 +1,42 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import type { NeededNodeType } from '@n8n/task-runner';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type { Dirent } from 'fs';
import { readdir, readFile } from 'fs/promises';
import { RoutingNode } from 'n8n-core';
import { RoutingNode, UnrecognizedNodeTypeError } from 'n8n-core';
import type { ExecuteContext } from 'n8n-core';
import type { INodeType, INodeTypeDescription, INodeTypes, IVersionedNodeType } from 'n8n-workflow';
import { deepCopy, NodeHelpers, UnexpectedError, UserError } from 'n8n-workflow';
import { deepCopy, isHitlToolType, NodeHelpers, UnexpectedError, UserError } from 'n8n-workflow';
import { join, dirname } from 'path';
import { LoadNodesAndCredentials } from './load-nodes-and-credentials';
import { convertNodeToAiTool, convertNodeToHitlTool } from './tool-generation';
import { shouldAssignExecuteMethod, stripToolSuffix } from './utils';
import { satisfiesToolCapability, shouldAssignExecuteMethod, stripToolSuffix } from './utils';
@Service()
export class NodeTypes implements INodeTypes {
constructor(private readonly loadNodesAndCredentials: LoadNodesAndCredentials) {}
constructor(
private readonly logger: Logger,
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
) {}
/**
* Resolves a tool-variant name (`…Tool`/`…HitlTool`) to the base node type it
* is synthesized from, unless a real node with that name exists on disk.
*
* A "synthetic tool" has no implementation of its own: workflows persist
* names like `gmailTool`, and the registry fabricates that node on demand by
* converting the `gmail` base node into an agent tool.
*/
private resolveBaseName(nodeTypeName: string): { baseName: string; isSyntheticTool: boolean } {
const isSyntheticTool =
nodeTypeName.endsWith('Tool') && !this.loadNodesAndCredentials.recognizesNode(nodeTypeName);
return {
baseName: isSyntheticTool ? stripToolSuffix(nodeTypeName) : nodeTypeName,
isSyntheticTool,
};
}
/**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
@@ -65,23 +87,20 @@ export class NodeTypes implements INodeTypes {
getByNameAndVersion(nodeType: string, version?: number): INodeType {
const origType = nodeType;
const toolRequested = nodeType.endsWith('Tool');
const { baseName, isSyntheticTool } = this.resolveBaseName(nodeType);
// If an existing node name ends in `Tool`, then return that node, instead of creating a fake Tool node
if (toolRequested && this.loadNodesAndCredentials.recognizesNode(nodeType)) {
if (nodeType.endsWith('Tool') && !isSyntheticTool) {
const node = this.loadNodesAndCredentials.getNode(nodeType);
return NodeHelpers.getVersionedNodeType(node.type, version);
}
// Make sure the nodeType to actually get from disk is the un-wrapped type
if (toolRequested) {
nodeType = stripToolSuffix(nodeType);
}
const node = this.loadNodesAndCredentials.getNode(nodeType);
const node = this.loadNodesAndCredentials.getNode(baseName);
const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version);
if (toolRequested && typeof versionedNodeType.supplyData === 'function') {
throw new UnexpectedError('Node already has a `supplyData` method', { extra: { nodeType } });
if (isSyntheticTool && typeof versionedNodeType.supplyData === 'function') {
throw new UnexpectedError('Node already has a `supplyData` method', {
extra: { nodeType: baseName },
});
}
if (shouldAssignExecuteMethod(versionedNodeType)) {
@@ -92,18 +111,17 @@ export class NodeTypes implements INodeTypes {
};
}
if (!toolRequested) return versionedNodeType;
if (!isSyntheticTool) return versionedNodeType;
const { loadedNodes } = this.loadNodesAndCredentials;
if (origType in loadedNodes) {
return loadedNodes[origType].type as INodeType;
}
// Check if this is an HITL tool (ends with 'HitlTool')
const isHitlTool = origType.endsWith('HitlTool');
const isHitlTool = isHitlToolType(origType);
if (!isHitlTool && !versionedNodeType.description.usableAsTool) {
throw new UserError('Node cannot be used as a tool', { extra: { nodeType } });
if (!satisfiesToolCapability(origType, versionedNodeType)) {
throw new UserError('Node cannot be used as a tool', { extra: { nodeType: baseName } });
}
// Instead of modifying the existing type, we extend it into a new type object
@@ -128,6 +146,49 @@ export class NodeTypes implements INodeTypes {
return this.loadNodesAndCredentials.knownNodes;
}
/**
* The `typeVersion`s this instance can resolve for a node type. Returns
* `undefined` when the type is unknown or fails to load (a warning is logged
* for anything but a plain unrecognized type), and `[]` when the type exists
* but no version satisfies the request e.g. a synthetic tool name (`…Tool`)
* whose base node cannot be used as a tool (`…HitlTool` has no such
* requirement). Unlike `getByNameAndVersion`, never registers synthetic tools
* or mutates the registry, though resolving a known type may trigger its lazy
* module load.
*/
getSupportedVersions(nodeTypeName: string): number[] | undefined {
// The whole resolution runs fail-closed: name lookups use `in`, so a
// hostile name (e.g. `n8n-nodes-base.constructor`) can surface
// prototype-chain values that throw at any of the reads below. Fold any
// such failure into "unknown type" instead of failing the caller.
try {
const { baseName, isSyntheticTool } = this.resolveBaseName(nodeTypeName);
const { type } = this.loadNodesAndCredentials.getNode(baseName);
if ('nodeVersions' in type) {
return Object.entries(type.nodeVersions)
.filter(
([, versioned]) => !isSyntheticTool || satisfiesToolCapability(nodeTypeName, versioned),
)
.map(([version]) => Number(version));
}
if (isSyntheticTool && !satisfiesToolCapability(nodeTypeName, type)) return [];
const { version } = type.description;
return Array.isArray(version) ? [...version] : [version];
} catch (error) {
if (!(error instanceof UnrecognizedNodeTypeError)) {
this.logger.warn('Failed to resolve node type while listing supported versions', {
nodeType: nodeTypeName,
error: ensureError(error).message,
});
}
return undefined;
}
}
async getNodeTranslationPath({
nodeSourcePath,
longNodeType,
@@ -172,9 +233,7 @@ export class NodeTypes implements INodeTypes {
getNodeTypeDescriptions(nodeTypes: NeededNodeType[]): INodeTypeDescription[] {
return nodeTypes.map(({ name: nodeTypeName, version: nodeTypeVersion }) => {
const isSyntheticTool =
nodeTypeName.endsWith('Tool') && !this.loadNodesAndCredentials.recognizesNode(nodeTypeName);
const baseName = isSyntheticTool ? stripToolSuffix(nodeTypeName) : nodeTypeName;
const { baseName, isSyntheticTool } = this.resolveBaseName(nodeTypeName);
const nodeType = this.loadNodesAndCredentials.getNode(baseName);
const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, nodeTypeVersion);
@@ -201,7 +260,7 @@ export class NodeTypes implements INodeTypes {
nodeTypeName: string,
description: INodeTypeDescription,
): INodeTypeDescription {
if (nodeTypeName.endsWith('HitlTool')) {
if (isHitlToolType(nodeTypeName)) {
return convertNodeToHitlTool({ description: deepCopy(description) }).description;
}
@@ -193,6 +193,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
workflowConflictPolicy: payload.data.workflowConflictPolicy,
workflowPublishingPolicy: payload.data.workflowPublishingPolicy,
workflowIdPolicy: payload.data.workflowIdPolicy,
missingNodeTypeMode: payload.data.missingNodeTypeMode,
folderConflictPolicy: payload.data.folderConflictPolicy,
dataTableMatchingMode: payload.data.dataTableMatchingMode,
dataTableMissingMode: payload.data.dataTableMissingMode,
@@ -111,6 +111,20 @@ post:
existing workflow in the target project (status `updated` or
`skipped`) always keep that workflow's current id, regardless
of policy.
missingNodeTypeMode:
type: string
enum:
- fail
- import-anyway
default: fail
description: >
What to do when a workflow in the package uses a node type — or a
version of a node type — this instance does not have. `fail`
(default) rejects the import before anything is written, listing
every missing `(nodeType, typeVersion)` pair and the workflows
that use it. `import-anyway` imports the package; workflows
containing missing node types are never published by this import,
regardless of `workflowPublishingPolicy`.
workflowPublishingPolicy:
type: string
enum:
@@ -285,7 +299,8 @@ post:
description: >
`blocked` means the imported version could not be published
and no version is active (for example because the workflow
uses a stubbed credential). When a prior published version
uses a stubbed credential, or uses a node type this instance
does not have). When a prior published version
remains active, `state` is `unchanged` with
`skippedPublishReason` instead. `failed` means publish or
unpublish was attempted but did not succeed.
@@ -298,6 +313,7 @@ post:
type: string
enum:
- stub-credential
- missing-node-type
description: >
Present when `state` is `blocked`: the imported version
could not be published and no version is active.
@@ -305,6 +321,7 @@ post:
type: string
enum:
- stub-credential
- missing-node-type
description: >
Present when `state` is `unchanged` but the policy wanted
to publish the imported version: a prior published version
@@ -467,7 +484,8 @@ post:
'422':
description: >
Import blocked by non-conflict issues only (e.g. unresolved credentials or
variables).
variables, or node types this instance does not have under
`missingNodeTypeMode=fail`).
content:
application/json:
schema:
@@ -124,6 +124,32 @@ oneOf:
type: string
nullable: true
description: 'For `id-in-other-project`: the project that already owns the id.'
- type: object
description: >
A node type — or a version of a node type — used by a package workflow that
this instance does not have, under `missingNodeTypeMode=fail`. One issue is
reported per missing `(nodeType, typeVersion)` pair.
required:
- type
- nodeType
- typeVersion
- usedByWorkflows
properties:
type:
type: string
enum:
- missing-node-type
nodeType:
type: string
description: Full node type name as used by the package's workflows.
typeVersion:
type: number
description: Node type version the package's workflows use.
usedByWorkflows:
type: array
items:
type: string
description: Package workflow ids that use this node type and version.
- type: object
description: A credential reference that could not be resolved in the target project.
required:
+10 -1
View File
@@ -1,4 +1,4 @@
import { CliWorkflowOperationError, SubworkflowOperationError } from 'n8n-workflow';
import { CliWorkflowOperationError, isHitlToolType, SubworkflowOperationError } from 'n8n-workflow';
import type { INode, INodeType, Workflow } from 'n8n-workflow';
import { STARTING_NODES } from '@/constants';
@@ -20,6 +20,15 @@ export function stripToolSuffix(nodeType: string): string {
return nodeType.replace(/HitlTool$/, '').replace(/Tool$/, '');
}
/**
* Whether the given resolved node (version) can back the synthetic tool name.
* HITL tools have no capability requirement; regular tools need the base node
* version to declare `usableAsTool`.
*/
export function satisfiesToolCapability(syntheticToolName: string, nodeType: INodeType): boolean {
return isHitlToolType(syntheticToolName) || !!nodeType.description.usableAsTool;
}
function findWorkflowStart(executionMode: 'integrated' | 'cli') {
return function (nodes: INode[]) {
const executeWorkflowTriggerNode = nodes.find(
@@ -8,6 +8,7 @@ import { CredentialTypes } from '@/credential-types';
import { EventService } from '@/events/event.service';
import {
buildImportPackageBuffer,
serializedWorkflow,
serializedWorkflowWithCredential,
} from '@/modules/n8n-packages/__tests__/fixtures/package-fixtures';
import { TarPackageWriter } from '@/modules/n8n-packages/io/tar/tar-package-writer';
@@ -29,6 +30,9 @@ beforeAll(async () => {
const credentialTypesMock = mockInstance(CredentialTypes);
credentialTypesMock.recognizes.mockReturnValue(true);
// Register node types so imports pass the default fail-on-missing-node-type check.
await utils.initNodeTypes();
owner = await createOwnerWithApiKey();
Container.get(InstanceSettings).markAsLeader();
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
@@ -249,6 +253,7 @@ describe('POST /n8n-packages/import', () => {
.field('bindings', '{}')
.field('workflowConflictPolicy', 'fail')
.field('workflowIdPolicy', 'new')
.field('missingNodeTypeMode', 'fail')
.field('dataTableMatchingMode', 'by-id')
.field('dataTableMissingMode', 'must-preexist')
.field('dataTableSchemaConflictPolicy', 'fail')
@@ -342,6 +347,71 @@ describe('POST /n8n-packages/import', () => {
});
});
const unknownNodeTypePackage = async (sourceId: string) =>
await buildImportPackageBuffer(
[
serializedWorkflow({
id: 'wf-unknown-node',
name: 'Unknown Node Type',
// Published in the source, so a publish-intent policy would publish it.
isPublished: true,
nodes: [
{
id: 'unknown-node',
name: 'Unknown Node',
type: 'n8n-nodes-community.chatBot',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
}),
],
{ sourceId },
);
test('returns 422 by default when a workflow uses an unknown node type', async () => {
const tarBuffer = await unknownNodeTypePackage('http-integration-missing-node-type-fail');
const response = await authOwnerAgent
.post('/n8n-packages/import')
.field('workflowConflictPolicy', 'fail')
.attach('package', tarBuffer, 'import.n8np');
expect(response.statusCode).toBe(422);
expect(response.body).toMatchObject({
message: expect.stringContaining('Import blocked'),
issues: [
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['wf-unknown-node'],
},
],
});
});
test('honors missingNodeTypeMode=import-anyway for a package with an unknown node type', async () => {
const tarBuffer = await unknownNodeTypePackage('http-integration-missing-node-type-anyway');
const response = await authOwnerAgent
.post('/n8n-packages/import')
.field('workflowConflictPolicy', 'fail')
.field('missingNodeTypeMode', 'import-anyway')
.field('workflowPublishingPolicy', 'match-source')
.attach('package', tarBuffer, 'import.n8np');
expect(response.statusCode).toBe(200);
expect(response.body.workflows).toHaveLength(1);
// match-source wanted to publish it, but the missing node type blocks that.
expect(response.body.workflows[0].publishing).toEqual({
state: 'blocked',
blockedReason: 'missing-node-type',
});
expect(response.body.workflows[0].activeVersionId).toBeNull();
});
test('creates stub credentials by default when references are missing', async () => {
const tarBuffer = await buildImportPackageBuffer(
[
@@ -110,6 +110,20 @@ function buildDefaultNodes(): INodeTypeData {
type: mock<INodeType>({ description: new WebhookNode().description } as never) as INodeType,
sourcePath: '',
},
// Minimal mocks for node types the package-import fixtures reference at
// typeVersion 1; import validation only needs name + version resolution.
'n8n-nodes-base.httpRequest': {
type: mock<INodeType>({
description: { name: 'n8n-nodes-base.httpRequest', version: 1 },
} as never) as INodeType,
sourcePath: '',
},
'n8n-nodes-base.dataTable': {
type: mock<INodeType>({
description: { name: 'n8n-nodes-base.dataTable', version: 1 },
} as never) as INodeType,
sourcePath: '',
},
};
}