feat(core): Support minor levels in n8nNodesApiVersion (no-changelog) (#39308)

Co-authored-by: Benny Neugebauer <[email protected]>
This commit is contained in:
Garrit Franke
2026-10-09 10:35:40 +00:00
committed by GitHub
co-authored by Benny Neugebauer
parent a3391ba93c
commit 8f9f673b86
28 changed files with 294 additions and 125 deletions
+3 -12
View File
@@ -1,16 +1,7 @@
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
/**
* Node-authoring API level supported by the runtime that bundles this copy.
*
* Compatibility rule for community packages:
* `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`.
*
* The level mirrors the n8n major for the v3 transition: `1` on master
* (which ships as 2.x during the v3 window) and `3` on the `3.x` branch,
* where it also serves as the v3 feature flag for node-authoring APIs.
*
* Lives here, not in `n8n-workflow`, so tooling that cannot depend on the
* runtime packages (e.g. `@n8n/node-cli`) can read the same value.
* Node-authoring API level this runtime supports, and so the highest
* `n8nNodesApiVersion` a node may declare to be installed.
*/
export const N8N_NODES_API_VERSION = 1;
export const N8N_NODES_API_VERSION = '1.0';
@@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user.
This rule enforces the structural contract:
- `package.json` must contain an `n8n` object.
- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live
inside `n8n`, not at the root.
- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a
`"<major>"` or `"<major>.<minor>"` string such as `"3"` or `"3.1"`. It must
live inside `n8n`, not at the root.
- `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`.
- `n8n.credentials`, if present, must be an array of strings, each starting
with `dist/`.
@@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`.
{
"name": "n8n-nodes-example",
"n8n": {
"n8nNodesApiVersion": "1",
"n8nNodesApiVersion": "v1",
"nodes": ["nodes/Foo/Foo.node.js"]
}
}
@@ -28,6 +28,7 @@
"watch": "tsc --watch --project tsconfig.build.json"
},
"dependencies": {
"@n8n/utils": "workspace:*",
"@typescript-eslint/typescript-estree": "catalog:",
"@typescript-eslint/utils": "catalog:",
"fastest-levenshtein": "catalog:"
@@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }',
},
{
name: 'major-only string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }',
},
{
name: 'major.minor string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }',
},
{
name: 'two-digit minor string api version is allowed',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
@@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, {
errors: [{ messageId: 'missingNodesApiVersion' }],
},
{
name: 'n8nNodesApiVersion is a string',
name: 'n8nNodesApiVersion is a non-numeric string',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }],
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }],
},
{
name: 'n8nNodesApiVersion is a string with a patch part',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }],
},
{
name: 'n8nNodesApiVersion is a zero-major string',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }',
errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }],
},
{
name: 'n8nNodesApiVersion is zero',
@@ -1,3 +1,4 @@
import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
@@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({
wrongLocationApiVersion:
'"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.',
missingNodesApiVersion:
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).',
'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "<major>" or "<major>.<minor>" string).',
invalidNodesApiVersion:
'"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.',
'"n8n.n8nNodesApiVersion" must be a positive integer, or a "<major>" or "<major>.<minor>" string, got {{ value }}.',
missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.',
n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.',
emptyN8nNodes: '"n8n.nodes" must contain at least one path.',
@@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi
}
const valueNode = apiVersionProp.value;
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) {
context.report({
node: apiVersionProp,
messageId: 'invalidNodesApiVersion',
@@ -194,7 +195,3 @@ function validatePathArray(
}
}
}
function isPositiveInteger(value: unknown): boolean {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
+3 -1
View File
@@ -41,13 +41,15 @@
},
"dependencies": {
"@n8n/constants": "workspace:*",
"nanoid": "catalog:"
"nanoid": "catalog:",
"semver": "catalog:"
},
"devDependencies": {
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@types/semver": "catalog:",
"fast-check": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
@@ -0,0 +1,58 @@
import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level';
describe('parseNodesApiLevel', () => {
it.each([
['3.1', { major: 3, minor: 1 }],
['3.0', { major: 3, minor: 0 }],
['3', { major: 3, minor: 0 }],
['3.10', { major: 3, minor: 10 }],
])('parses the string %p as %p', (value, expected) => {
expect(parseNodesApiLevel(value)).toMatchObject(expected);
});
it.each([
[1, { major: 1, minor: 0 }],
[3, { major: 3, minor: 0 }],
])('reads the integer %p as %p', (value, expected) => {
expect(parseNodesApiLevel(value)).toMatchObject(expected);
});
it.each([
0,
-1,
2.5,
3.1,
'0.0',
'0.1',
'01.1',
'03',
'3.01',
'3.',
'.1',
' 3.2 ',
'3.1.2',
'v3',
'',
null,
NaN,
Infinity,
true,
{},
])('rejects %p', (value) => {
expect(parseNodesApiLevel(value)).toBeNull();
});
it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])(
'rejects the level %p above the safe integer range',
(value) => {
expect(parseNodesApiLevel(value)).toBeNull();
},
);
});
describe('formatNodesApiLevel', () => {
it('writes major and minor', () => {
expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0');
expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10');
});
});
@@ -0,0 +1,19 @@
import { coerce, type SemVer } from 'semver';
// A float is rejected, since an unquoted `3.10` in package.json is read as
// the number `3.1`. The input must print back from its coerced version as
// `<major>` or `<major>.<minor>`.
export function parseNodesApiLevel(value: unknown): SemVer | null {
const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value;
if (typeof text !== 'string') return null;
const version = coerce(text);
if (version === null || version.major < 1) return null;
if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null;
return version;
}
export function formatNodesApiLevel({ major, minor }: SemVer): string {
return `${major}.${minor}`;
}
@@ -9,9 +9,9 @@ export class IncompatibleNodesApiVersionError extends ResponseError {
message: string,
readonly meta: {
/** API version the package requires, or `null` if the declared value is malformed. */
requiredNodesApiVersion: number | null;
requiredNodesApiVersion: string | null;
/** Node API version this runtime supports. */
supportedNodesApiVersion: number;
supportedNodesApiVersion: string;
},
cause?: unknown,
) {
@@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'path';
@@ -305,9 +305,10 @@ describe('node API compatibility at startup', () => {
});
test('boots with an incompatible package on disk and registers no loader for it', async () => {
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
writePackage('n8n-nodes-future', {
nodes: ['dist/nodes/Future.node.js'],
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
n8nNodesApiVersion: `${major}.${minor + 1}`,
});
writePackage('n8n-nodes-good');
@@ -310,7 +310,7 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.installPackage.mockRejectedValue(
new IncompatibleNodesApiVersionError(
'This community node requires n8n node API version 3, but this instance supports up to 1.',
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
),
);
@@ -318,7 +318,7 @@ describe('CommunityPackagesLifecycleService', () => {
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
await expect(promise).rejects.toMatchObject({
httpStatusCode: 400,
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
message:
'This community node requires n8n node API version 3, but this instance supports up to 1.',
});
@@ -666,7 +666,7 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.updatePackage.mockRejectedValue(
new IncompatibleNodesApiVersionError(
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
{ requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
{ requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
),
);
@@ -678,7 +678,7 @@ describe('CommunityPackagesLifecycleService', () => {
await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError);
await expect(promise).rejects.toMatchObject({
httpStatusCode: 400,
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
});
});
@@ -691,8 +691,8 @@ describe('CommunityPackagesLifecycleService', () => {
communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0'));
communityPackagesService.updatePackage.mockRejectedValue(
new IncompatibleNodesApiVersionError('Not compatible', {
requiredNodesApiVersion: 3,
supportedNodesApiVersion: 1,
requiredNodesApiVersion: '3',
supportedNodesApiVersion: '1',
}),
);
@@ -3,6 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockInstance } from '@n8n/backend-test-utils';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { N8N_NODES_API_VERSION } from '@n8n/constants';
import { parseNodesApiLevel } from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
@@ -34,6 +35,9 @@ vi.mock('../npm-utils', async () => ({
const PACKAGE_NAME = 'n8n-nodes-test';
const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`;
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
/**
* Exercises the install rollback against a real filesystem rather than mocked `fs`
* calls. The bug being guarded here is a directory that is deleted with no backup
@@ -264,7 +268,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
};
test('rejects an update and leaves directory, ledger, and database unchanged', async () => {
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
"isn't compatible with your version of n8n",
);
@@ -284,7 +288,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
});
test('rejects an update with a malformed declared version', async () => {
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
expect(existsSync(markerPath())).toBe(true);
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
@@ -314,7 +318,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
downloadedPackageJson = {
name: PACKAGE_NAME,
version: '2.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
};
await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow(
@@ -334,7 +338,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
downloadedPackageJson = {
name: PACKAGE_NAME,
version: '2.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
};
// The follower resolves the version to install from the leader's database record.
installedPackageRepository.findOne.mockResolvedValue(
@@ -359,7 +363,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => {
packageName: PACKAGE_NAME,
reason: expect.stringContaining("isn't compatible with your version of n8n"),
// The operator log names both versions, unlike the user-facing message.
requiredNodesApiVersion: N8N_NODES_API_VERSION + 1,
requiredNodesApiVersion: oneMinorAboveSupported,
supportedNodesApiVersion: N8N_NODES_API_VERSION,
}),
);
@@ -3,7 +3,11 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { mockInstance, randomName } from '@n8n/backend-test-utils';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { N8N_NODES_API_VERSION, type PublicInstalledPackage } from 'n8n-workflow';
import {
N8N_NODES_API_VERSION,
parseNodesApiLevel,
type PublicInstalledPackage,
} from 'n8n-workflow';
import { execFile } from 'node:child_process';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path, { join } from 'node:path';
@@ -52,6 +56,9 @@ const execMock: typeof execFile = ((...args) => {
vi.mocked(execFile).mockImplementation(execMock);
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`;
describe('CommunityPackagesService', () => {
const license = mock<License>();
const configDefaults = {
@@ -675,7 +682,7 @@ describe('CommunityPackagesService', () => {
test('should reject the update when the package requires a newer node API version', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow(
await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow(
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
);
@@ -687,7 +694,7 @@ describe('CommunityPackagesService', () => {
test('should reject the update when the declared node API version is malformed', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version');
await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version');
expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled();
expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled();
@@ -701,6 +708,16 @@ describe('CommunityPackagesService', () => {
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled();
});
test('should update to a package that declares the supported level with an explicit minor', async () => {
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe(
packageAfterUpdate,
);
expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME);
});
});
test('should remove the package.json dependency when a fresh install fails', async () => {
@@ -1418,7 +1435,7 @@ describe('CommunityPackagesService', () => {
JSON.stringify({
name: 'package-1',
version: '1.0.0',
n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 },
n8n: { n8nNodesApiVersion: oneMinorAboveSupported },
}),
);
@@ -1438,7 +1455,7 @@ describe('CommunityPackagesService', () => {
loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
config.reinstallMissing = true;
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: '3' } }),
JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }),
);
await communityPackagesService.checkForMissingPackages();
@@ -60,7 +60,7 @@ export async function getCommunityNodeTypes(
environment: 'staging' | 'production',
qs: { filters?: StrapiFilters; fields?: string[] } = {},
maxAiNodeSdk: number,
maxN8nNodesApiVersion: number,
maxN8nNodesApiVersion: string,
): Promise<StrapiCommunityNodeType[]> {
const url = getUrl(environment);
const params = {
@@ -78,7 +78,7 @@ export async function getCommunityNodeTypes(
export async function getCommunityNodesMetadata(
environment: 'staging' | 'production',
maxAiNodeSdk: number,
maxN8nNodesApiVersion: number,
maxN8nNodesApiVersion: string,
): Promise<CommunityNodesMetadata[]> {
const url = getUrl(environment);
const params = {
@@ -40,7 +40,7 @@ export class CommunityPackagesConfig {
readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION;
/** Highest community node API version this instance supports, sent to Strapi API */
readonly nodesApiVersion: number = N8N_NODES_API_VERSION;
readonly nodesApiVersion: string = N8N_NODES_API_VERSION;
@Env('N8N_DISABLED_MODULES')
private disabledModules: CommaSeparatedStringArray<string> = [];
@@ -304,14 +304,17 @@ export class CommunityPackagesService {
const check = checkNodesApiVersion(packageJson);
if (check.compatible) return;
const isMalformed = check.reason === 'malformed';
if (check.reason === 'malformed') {
throw new IncompatibleNodesApiVersionError(
`This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`,
{ requiredNodesApiVersion: null, supportedNodesApiVersion: N8N_NODES_API_VERSION },
);
}
throw new IncompatibleNodesApiVersionError(
isMalformed
? `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`
: "This community node isn't compatible with your version of n8n. Update n8n to use it.",
"This community node isn't compatible with your version of n8n. Update n8n to use it.",
{
requiredNodesApiVersion: isMalformed ? null : Number(check.declared),
requiredNodesApiVersion: check.required,
supportedNodesApiVersion: N8N_NODES_API_VERSION,
},
);
@@ -370,7 +373,7 @@ export class CommunityPackagesService {
const requirement =
apiVersionCheck.reason === 'malformed'
? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})`
: `node API version ${String(apiVersionCheck.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
: `node API version ${apiVersionCheck.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
this.logger.warn(
`Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
);
+1 -1
View File
@@ -48,7 +48,7 @@ type PaginationRequestParams = {
pageSize: number;
};
maxAiNodeSdk?: number;
maxN8nNodesApiVersion?: number;
maxN8nNodesApiVersion?: string;
version?: number;
};
@@ -1,5 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -26,6 +26,9 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
rmSync(nodeModulesDir, { recursive: true, force: true });
});
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const unsupportedLevel = `${major}.${minor + 1}`;
const writePackage = (name: string, n8n?: object) => {
const dir = path.join(nodeModulesDir, name);
mkdirSync(dir);
@@ -93,7 +96,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
it('registers no loader for a package requiring an unsupported node API version', async () => {
writePackage('n8n-nodes-future', {
nodes: ['dist/nodes/Future.node.js'],
n8nNodesApiVersion: N8N_NODES_API_VERSION + 1,
n8nNodesApiVersion: unsupportedLevel,
});
writePackage('n8n-nodes-good');
@@ -102,7 +105,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => {
expect(loaders.map((loader) => loader.packageName)).toEqual(['n8n-nodes-good']);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('n8n-nodes-future'));
const [message] = vi.mocked(logger.warn).mock.calls[0];
expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`);
expect(message).toContain(`node API version ${unsupportedLevel}`);
expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`);
expect(message).toContain('Upgrade n8n');
});
@@ -1,7 +1,7 @@
// eslint-disable-next-line import-x/order
import { mock } from 'vitest-mock-extended';
import { Logger } from '@n8n/backend-common';
import { N8N_NODES_API_VERSION } from 'n8n-workflow';
import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow';
import * as fs from 'node:fs';
import type * as fsPromises from 'node:fs/promises';
@@ -36,6 +36,9 @@ describe('scanDirectoryForPackages', () => {
const firecrawlPackageJson = (n8n?: object) =>
JSON.stringify({ name: '@mendable/n8n-nodes-firecrawl', version: '2.1.2', ...{ n8n } });
const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
const unsupportedLevel = `${major}.${minor + 1}`;
const enoent = (file: string): NodeJS.ErrnoException => {
const error: NodeJS.ErrnoException = new Error(
`ENOENT: no such file or directory, open '${file}'`,
@@ -116,7 +119,7 @@ describe('scanDirectoryForPackages', () => {
});
it('skips a package requiring an unsupported node API version and keeps compatible ones', async () => {
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }));
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel }));
const loaders = await scanDirectoryForPackages(nodeModulesDir);
@@ -127,7 +130,7 @@ describe('scanDirectoryForPackages', () => {
});
it('returns a loader for a package declaring a supported node API version', async () => {
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 1 }));
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '1.0' }));
const loaders = await scanDirectoryForPackages(nodeModulesDir);
@@ -144,7 +147,7 @@ describe('scanDirectoryForPackages', () => {
});
it('logs name, declared version, supported version, and remediation for an unsupported package', async () => {
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }));
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel }));
await scanDirectoryForPackages(nodeModulesDir);
@@ -152,13 +155,13 @@ describe('scanDirectoryForPackages', () => {
expect.stringContaining('@mendable/n8n-nodes-firecrawl'),
);
const [message] = vi.mocked(logger.warn).mock.calls[0];
expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`);
expect(message).toContain(`node API version ${unsupportedLevel}`);
expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`);
expect(message).toContain('Upgrade n8n');
});
it('skips a package with a malformed node API version', async () => {
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '3' }));
mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 3.1 }));
const loaders = await scanDirectoryForPackages(nodeModulesDir);
@@ -49,7 +49,7 @@ export async function scanDirectoryForPackages(
const requirement =
check.reason === 'malformed'
? `an invalid n8nNodesApiVersion (${JSON.stringify(check.declared)})`
: `node API version ${String(check.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
: `node API version ${check.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`;
logger.warn(
`Skipping package "${loader.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`,
);
+2 -1
View File
@@ -5,7 +5,8 @@ export namespace n8n {
n8n?: {
credentials?: string[];
nodes?: string[];
n8nNodesApiVersion?: number;
/** A positive integer or `"<major>.<minor>"`, as declared by the package. */
n8nNodesApiVersion?: number | string;
};
author?: {
name?: string;
@@ -52,13 +52,13 @@ describe('isCommunityPackageUpdateAvailable', () => {
});
describe('isNodesApiVersionError', () => {
const incompatibleNodesApiVersionError = (requiredNodesApiVersion: number | null): unknown => ({
const incompatibleNodesApiVersionError = (requiredNodesApiVersion: string | null): unknown => ({
httpStatusCode: 400,
meta: { requiredNodesApiVersion, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion, supportedNodesApiVersion: '1' },
});
it('should match the error metadata, not the message', () => {
expect(isNodesApiVersionError(incompatibleNodesApiVersionError(3))).toBe(true);
expect(isNodesApiVersionError(incompatibleNodesApiVersionError('3.1'))).toBe(true);
// Malformed declared values carry `null` and must be recognized too.
expect(isNodesApiVersionError(incompatibleNodesApiVersionError(null))).toBe(true);
// The same copy without the metadata is a generic error.
@@ -43,9 +43,9 @@ export function isCommunityPackageUpdateAvailable({
interface IncompatibleNodesApiVersionErrorResponse {
httpStatusCode: number;
meta: {
/** API version the package requires, or `null` if the declared value is malformed. */
requiredNodesApiVersion: number | null;
supportedNodesApiVersion: number;
/** `null` if the declared value is malformed. */
requiredNodesApiVersion: string | null;
supportedNodesApiVersion: string;
};
}
@@ -400,7 +400,7 @@ describe('useInstallNode', () => {
new Error('This community node requires n8n node API version 3.'),
{
httpStatusCode: 400,
meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 },
meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' },
},
);
vi.mocked(communityNodesStore.installPackage).mockRejectedValue(error);
@@ -81,9 +81,9 @@ test.describe(
expect(response.status()).toBe(400);
const rejection = await api.communityPackages.readRejection(response);
expect(rejection.message).toContain("isn't compatible with your version of n8n");
expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3');
expect(String(rejection.meta.supportedNodesApiVersion)).toBe(
String(supportedNodesApiVersion),
expect(rejection.meta.requiredNodesApiVersion).toBe('3.0');
expect(String(rejection.meta.supportedNodesApiVersion)).toMatch(
new RegExp(`^${supportedNodesApiVersion}(\\.\\d+)?$`),
);
await expectAbsent(api, packageDisk, v3);
});
@@ -109,7 +109,7 @@ test.describe(
const response = await api.communityPackages.update(legacyV3.name, legacyV3.version);
expect(response.status()).toBe(400);
const rejection = await api.communityPackages.readRejection(response);
expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3');
expect(rejection.meta.requiredNodesApiVersion).toBe('3.0');
await expectInstalled(api, packageDisk, legacy);
const state = await packageDisk.stateOf(legacyV3.name);
+28 -29
View File
@@ -1,54 +1,53 @@
import { N8N_NODES_API_VERSION } from '@n8n/constants';
import { formatNodesApiLevel, parseNodesApiLevel } from '@n8n/utils/nodes-api-level';
export { N8N_NODES_API_VERSION };
export { N8N_NODES_API_VERSION, formatNodesApiLevel, parseNodesApiLevel };
/** Minimal package.json shape needed to check node API compatibility. */
export interface NodesApiVersionPackageJson {
n8n?: {
/**
* Node-authoring API level the package requires. Absent in legacy
* packages, which are treated as requiring level 1.
*/
n8nNodesApiVersion?: unknown;
};
}
export type NodesApiVersionCheck =
| { compatible: true }
| {
compatible: true;
/** Effective required API level; 1 for packages without metadata. */
version: number;
compatible: false;
reason: 'malformed';
declared: unknown;
}
| {
compatible: false;
reason: 'malformed' | 'unsupported';
/** The declared value, as read from package.json. */
reason: 'unsupported';
declared: unknown;
/** As `<major>.<minor>`, for messages and metadata. */
required: string;
};
/**
* Read a community package's declared node-authoring API level and check it
* against the level this runtime supports (`required <= N8N_NODES_API_VERSION`).
*
* Missing `n8n.n8nNodesApiVersion` means a legacy package and resolves to
* level 1. Malformed values (non-integer, non-positive, non-number) are
* reported as incompatible — the runtime cannot distinguish an old package
* from a corrupt or hostile one if both fall back to legacy.
*/
function supportedLevel() {
const level = parseNodesApiLevel(N8N_NODES_API_VERSION);
if (level === null)
throw new Error(`N8N_NODES_API_VERSION is not a level: ${N8N_NODES_API_VERSION}`);
return level;
}
// A missing declaration is a legacy package at level 1. A malformed one is
// incompatible: the runtime cannot tell an old package from a corrupt one.
export function checkNodesApiVersion(pkgJson: NodesApiVersionPackageJson): NodesApiVersionCheck {
const declared = pkgJson?.n8n?.n8nNodesApiVersion;
if (declared === undefined) {
return { compatible: true, version: 1 };
}
if (declared === undefined) return { compatible: true };
if (typeof declared !== 'number' || !Number.isInteger(declared) || declared < 1) {
return { compatible: false, reason: 'malformed', declared };
}
const required = parseNodesApiLevel(declared);
if (required === null) return { compatible: false, reason: 'malformed', declared };
if (declared <= N8N_NODES_API_VERSION) {
return { compatible: true, version: declared };
}
if (required.compare(supportedLevel()) <= 0) return { compatible: true };
return { compatible: false, reason: 'unsupported', declared };
return {
compatible: false,
reason: 'unsupported',
declared,
required: formatNodesApiLevel(required),
};
}
@@ -1,49 +1,82 @@
import { N8N_NODES_API_VERSION, checkNodesApiVersion } from '../src/nodes-api-version';
import {
N8N_NODES_API_VERSION,
checkNodesApiVersion,
formatNodesApiLevel,
parseNodesApiLevel,
} from '../src/nodes-api-version';
const pkg = (n8nNodesApiVersion?: unknown) => ({
n8n: n8nNodesApiVersion === undefined ? {} : { n8nNodesApiVersion },
});
const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!;
describe('N8N_NODES_API_VERSION', () => {
it('is a level written as major.minor', () => {
expect(N8N_NODES_API_VERSION).toBe(
formatNodesApiLevel(parseNodesApiLevel(N8N_NODES_API_VERSION)!),
);
});
});
describe('checkNodesApiVersion', () => {
it('treats a missing n8n section as legacy level 1', () => {
expect(checkNodesApiVersion({})).toEqual({ compatible: true, version: 1 });
expect(checkNodesApiVersion({})).toEqual({ compatible: true });
});
it('treats a missing n8nNodesApiVersion as legacy level 1', () => {
expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true, version: 1 });
expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true });
});
// The rule under test is `required <= supported`. The expectations below
// state that rule at its exact boundaries instead of pinning the
// constant's value, so they survive a deliberate bump without edits.
// They also fail if the constant degenerates (below 1 or non-integer).
it('accepts the floor level 1', () => {
expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true, version: 1 });
it('accepts the integer 1', () => {
expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true });
});
it('accepts a package that requires exactly the supported level', () => {
expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({
it('accepts the supported level', () => {
expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({ compatible: true });
});
it('accepts a minor below the supported one', () => {
if (supportedMinor === 0) return;
expect(checkNodesApiVersion(pkg(`${supportedMajor}.${supportedMinor - 1}`))).toEqual({
compatible: true,
version: N8N_NODES_API_VERSION,
});
});
it('rejects a package that requires one level above the supported level', () => {
const above = N8N_NODES_API_VERSION + 1;
it('rejects one minor above the supported level', () => {
const above = `${supportedMajor}.${supportedMinor + 1}`;
expect(checkNodesApiVersion(pkg(above))).toEqual({
compatible: false,
reason: 'unsupported',
declared: above,
required: above,
});
});
for (const declared of ['3', 0, -1, 2.5, null, NaN, Infinity, true, {}]) {
it(`rejects malformed value ${String(declared)}`, () => {
it('rejects the next major at minor 0', () => {
const above = `${supportedMajor + 1}.0`;
expect(checkNodesApiVersion(pkg(above))).toEqual({
compatible: false,
reason: 'unsupported',
declared: above,
required: above,
});
});
it('reports an integer level as major.minor', () => {
expect(checkNodesApiVersion(pkg(supportedMajor + 1))).toMatchObject({
required: `${supportedMajor + 1}.0`,
});
});
it.each(['3.1.0', 'three', 0, -1, 2.5, 3.1, null, NaN, true, {}, '9007199254740992'])(
'rejects the malformed value %p',
(declared) => {
expect(checkNodesApiVersion(pkg(declared))).toEqual({
compatible: false,
reason: 'malformed',
declared,
});
});
}
},
);
});
+9
View File
@@ -2886,6 +2886,9 @@ importers:
packages/@n8n/eslint-plugin-community-nodes:
dependencies:
'@n8n/utils':
specifier: workspace:*
version: link:../utils
'@typescript-eslint/typescript-estree':
specifier: 'catalog:'
version: 8.70.0(@typescript/[email protected])([email protected])
@@ -4589,6 +4592,9 @@ importers:
nanoid:
specifier: 'catalog:'
version: 3.3.18
semver:
specifier: 7.7.3
version: 7.7.3
devDependencies:
'@n8n/oxlint-config':
specifier: workspace:*
@@ -4602,6 +4608,9 @@ importers:
'@testing-library/jest-dom':
specifier: catalog:frontend
version: 6.9.1
'@types/semver':
specifier: 'catalog:'
version: 7.7.0
fast-check:
specifier: 'catalog:'
version: 3.23.2