first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,61 @@
import type { ESLint, Linter } from 'eslint';
import pkg from '../package.json' with { type: 'json' };
import { rules } from './rules/index.js';
const plugin = {
meta: {
name: pkg.name,
version: pkg.version,
namespace: '@n8n/community-nodes',
},
// @ts-expect-error Rules type does not match for typescript-eslint and eslint
rules: rules as ESLint.Plugin['rules'],
} satisfies ESLint.Plugin;
const configs = {
recommended: {
ignores: ['eslint.config.{js,mjs,ts,mts}'],
plugins: {
'@n8n/community-nodes': plugin,
},
rules: {
'@n8n/community-nodes/ai-node-package-json': 'error',
'@n8n/community-nodes/no-restricted-globals': 'error',
'@n8n/community-nodes/no-restricted-imports': 'error',
'@n8n/community-nodes/credential-password-field': 'error',
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
'@n8n/community-nodes/no-credential-reuse': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/credential-documentation-url': 'error',
},
},
recommendedWithoutN8nCloudSupport: {
ignores: ['eslint.config.{js,mjs,ts,mts}'],
plugins: {
'@n8n/community-nodes': plugin,
},
rules: {
'@n8n/community-nodes/ai-node-package-json': 'error',
'@n8n/community-nodes/credential-password-field': 'error',
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
'@n8n/community-nodes/no-credential-reuse': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
},
},
} satisfies Record<string, Linter.Config>;
const pluginWithConfigs = { ...plugin, configs } satisfies ESLint.Plugin;
const n8nCommunityNodesPlugin = pluginWithConfigs;
export default pluginWithConfigs;
export { rules, configs, n8nCommunityNodesPlugin };
@@ -0,0 +1,111 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
const ruleTester = new RuleTester();
ruleTester.run('ai-node-package-json', AiNodePackageJsonRule, {
valid: [
{
name: 'both n8n.aiNodeSdkVersion and ai-node-sdk peer dependency present',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
},
{
name: 'neither n8n.aiNodeSdkVersion nor ai-node-sdk present (non-AI package)',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
},
{
name: 'n8n section without aiNodeSdkVersion and no ai-node-sdk peer dep',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] } }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
code: '{ "n8n": { "aiNodeSdkVersion": 1 } }',
},
{
name: 'peerDependencies without ai-node-sdk and no aiNodeSdkVersion',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*" } }',
},
{
name: 'aiNodeSdkVersion as a larger positive integer with multiple peer deps',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 42 }, "peerDependencies": { "n8n-workflow": "^1.0.0", "ai-node-sdk": "^1.0.0" } }',
},
],
invalid: [
{
name: 'n8n.aiNodeSdkVersion present but ai-node-sdk missing from peerDependencies',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 } }',
errors: [{ messageId: 'missingPeerDep' }],
},
{
name: 'n8n.aiNodeSdkVersion present but peerDependencies has other deps only',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*" } }',
errors: [{ messageId: 'missingPeerDep' }],
},
{
name: 'ai-node-sdk in peerDependencies but n8n.aiNodeSdkVersion missing',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
errors: [{ messageId: 'missingSdkVersion' }],
},
{
name: 'ai-node-sdk in peerDependencies but no n8n section at all',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
errors: [{ messageId: 'missingSdkVersion' }],
},
{
name: 'n8n.aiNodeSdkVersion is a string instead of integer',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "1" }, "peerDependencies": { "ai-node-sdk": "*" } }',
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1' } }],
},
{
name: 'n8n.aiNodeSdkVersion is zero',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 0 }, "peerDependencies": { "ai-node-sdk": "*" } }',
errors: [{ messageId: 'invalidSdkVersion', data: { value: '0' } }],
},
{
name: 'n8n.aiNodeSdkVersion is negative (parsed as UnaryExpression, not Literal)',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": -1 }, "peerDependencies": { "ai-node-sdk": "*" } }',
errors: [{ messageId: 'invalidSdkVersion', data: { value: 'non-literal' } }],
},
{
name: 'n8n.aiNodeSdkVersion is a float',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1.5 }, "peerDependencies": { "ai-node-sdk": "*" } }',
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1.5' } }],
},
{
name: 'n8n.aiNodeSdkVersion is invalid and ai-node-sdk peer dep is missing (two errors)',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "bad" } }',
errors: [
{ messageId: 'invalidSdkVersion', data: { value: 'bad' } },
{ messageId: 'missingPeerDep' },
],
},
{
name: 'aiNodeSdkVersion at root level instead of inside n8n section',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1, "peerDependencies": { "ai-node-sdk": "*" } }',
errors: [{ messageId: 'wrongLocation' }, { messageId: 'missingSdkVersion' }],
},
{
name: 'aiNodeSdkVersion at root level without peer dep',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1 }',
errors: [{ messageId: 'wrongLocation' }],
},
],
});
@@ -0,0 +1,100 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { createRule, findJsonProperty } from '../utils/index.js';
export const AiNodePackageJsonRule = createRule({
name: 'ai-node-package-json',
meta: {
type: 'problem',
docs: {
description:
'Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages',
},
messages: {
missingPeerDep:
'Package declares "n8n.aiNodeSdkVersion" but is missing "ai-node-sdk" in peerDependencies. Add "ai-node-sdk": "*" to peerDependencies.',
missingSdkVersion:
'Package has "ai-node-sdk" in peerDependencies but is missing "aiNodeSdkVersion" in the "n8n" section of package.json.',
invalidSdkVersion: '"n8n.aiNodeSdkVersion" must be a positive integer, got {{ value }}.',
wrongLocation:
'"aiNodeSdkVersion" must be inside the "n8n" section, not at the root level of package.json.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!context.filename.endsWith('package.json')) {
return {};
}
return {
ObjectExpression(node: TSESTree.ObjectExpression) {
// Only process the root object, not nested ones
if (node.parent?.type === AST_NODE_TYPES.Property) {
return;
}
const n8nProp = findJsonProperty(node, 'n8n');
const n8nObject =
n8nProp?.value.type === AST_NODE_TYPES.ObjectExpression ? n8nProp.value : null;
const aiNodeSdkVersionProp = n8nObject
? findJsonProperty(n8nObject, 'aiNodeSdkVersion')
: null;
const rootAiNodeSdkVersionProp = findJsonProperty(node, 'aiNodeSdkVersion');
const peerDependenciesProp = findJsonProperty(node, 'peerDependencies');
const hasAiNodeSdkVersion = aiNodeSdkVersionProp !== null;
const hasAiNodeSdkPeerDep =
peerDependenciesProp?.value.type === AST_NODE_TYPES.ObjectExpression &&
findJsonProperty(peerDependenciesProp.value, 'ai-node-sdk') !== null;
// Catch aiNodeSdkVersion placed at root level instead of inside n8n
if (rootAiNodeSdkVersionProp) {
context.report({
node: rootAiNodeSdkVersionProp,
messageId: 'wrongLocation',
});
}
// Validate aiNodeSdkVersion is a positive integer when present
if (hasAiNodeSdkVersion) {
const valueNode = aiNodeSdkVersionProp.value;
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
context.report({
node: aiNodeSdkVersionProp,
messageId: 'invalidSdkVersion',
data: {
value: String(
valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal',
),
},
});
}
}
// If aiNodeSdkVersion is declared, ai-node-sdk must be in peerDependencies
if (hasAiNodeSdkVersion && !hasAiNodeSdkPeerDep) {
context.report({
node: aiNodeSdkVersionProp,
messageId: 'missingPeerDep',
});
}
// If ai-node-sdk is in peerDependencies, aiNodeSdkVersion must be declared
if (hasAiNodeSdkPeerDep && !hasAiNodeSdkVersion) {
context.report({
node: peerDependenciesProp,
messageId: 'missingSdkVersion',
});
}
},
};
},
});
function isPositiveInteger(value: unknown): boolean {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
@@ -0,0 +1,306 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
const ruleTester = new RuleTester();
function createCredentialCode(documentationUrl: string): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestCredential implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
documentationUrl = '${documentationUrl}';
properties: INodeProperties[] = [];
}`;
}
function createCredentialWithoutDocUrl(): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestCredential implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [];
}`;
}
function createRegularClass(): string {
return `
export class RegularClass {
documentationUrl = 'invalid-url';
}`;
}
ruleTester.run('credential-documentation-url', CredentialDocumentationUrlRule, {
valid: [
{
name: 'valid URL with default options (URLs only)',
code: createCredentialCode('https://example.com/docs'),
},
{
name: 'valid URL with explicit options',
code: createCredentialCode('https://example.com/docs'),
options: [{ allowUrls: true, allowSlugs: false }],
},
{
name: 'valid lowercase slug when slugs are allowed',
code: createCredentialCode('myservice'),
options: [{ allowUrls: false, allowSlugs: true }],
},
{
name: 'valid lowercase slug with slashes when slugs are allowed',
code: createCredentialCode('myservice/advanced/config'),
options: [{ allowUrls: false, allowSlugs: true }],
},
{
name: 'valid URL when both URLs and slugs are allowed',
code: createCredentialCode('https://example.com/docs'),
options: [{ allowUrls: true, allowSlugs: true }],
},
{
name: 'valid lowercase slug when both URLs and slugs are allowed',
code: createCredentialCode('myservice/config'),
options: [{ allowUrls: true, allowSlugs: true }],
},
{
name: 'credential without documentationUrl should not trigger',
code: createCredentialWithoutDocUrl(),
},
{
name: 'class not implementing ICredentialType should be ignored',
code: createRegularClass(),
},
{
name: 'valid lowercase slug with multiple segments',
code: createCredentialCode('myservice/somefeature/advancedconfig'),
options: [{ allowUrls: false, allowSlugs: true }],
},
{
name: 'valid lowercase alphanumeric slug',
code: createCredentialCode('myservice123'),
options: [{ allowUrls: false, allowSlugs: true }],
},
{
name: 'valid lowercase alphanumeric slug with slashes',
code: createCredentialCode('myservice123/config456'),
options: [{ allowUrls: false, allowSlugs: true }],
},
],
invalid: [
{
name: 'invalid URL with default options',
code: createCredentialCode('invalid-url'),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'invalid-url',
expectedFormats: 'a valid URL',
},
},
],
},
{
name: 'slug not allowed with default options',
code: createCredentialCode('myservice'),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'myservice',
expectedFormats: 'a valid URL',
},
},
],
},
{
name: 'slug with special characters should not be autofixable',
code: createCredentialCode('My-Service'),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'My-Service',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'uppercase slug should be autofixable',
code: createCredentialCode('MyService'),
options: [{ allowUrls: false, allowSlugs: true }],
output: createCredentialCode('myservice'),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'MyService',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'invalid URL when only URLs are allowed',
code: createCredentialCode('not-a-valid-url'),
options: [{ allowUrls: true, allowSlugs: false }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'not-a-valid-url',
expectedFormats: 'a valid URL',
},
},
],
},
{
name: 'invalid when neither URLs nor slugs are allowed',
code: createCredentialCode('https://example.com'),
options: [{ allowUrls: false, allowSlugs: false }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'https://example.com',
expectedFormats: 'a valid format (none configured)',
},
},
],
},
{
name: 'slug with invalid characters (special chars) should not be autofixable',
code: createCredentialCode('my@service/config'),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'my@service/config',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'slug with uppercase segment should be autofixable',
code: createCredentialCode('myService/Config'),
options: [{ allowUrls: false, allowSlugs: true }],
output: createCredentialCode('myservice/config'),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'myService/Config',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'slug with hyphens should not be autofixable',
code: createCredentialCode('myservice/advanced-config'),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'myservice/advanced-config',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'slug with underscores should not be autofixable',
code: createCredentialCode('my_service/config_advanced'),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'my_service/config_advanced',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'invalid value when both formats are allowed - shows both in error message',
code: createCredentialCode('Invalid-Value!'),
options: [{ allowUrls: true, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'Invalid-Value!',
expectedFormats: 'a valid URL or a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'empty string should be invalid with default options',
code: createCredentialCode(''),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: '',
expectedFormats: 'a valid URL',
},
},
],
},
{
name: 'empty string should be invalid when slugs are allowed',
code: createCredentialCode(''),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: '',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'mixed case slug with numbers should be autofixable',
code: createCredentialCode('MyService123/Config456'),
options: [{ allowUrls: false, allowSlugs: true }],
output: createCredentialCode('myservice123/config456'),
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: 'MyService123/Config456',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
{
name: 'slug starting with number should be invalid and not autofixable',
code: createCredentialCode('123service/config'),
options: [{ allowUrls: false, allowSlugs: true }],
errors: [
{
messageId: 'invalidDocumentationUrl',
data: {
value: '123service/config',
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
},
},
],
},
],
});
@@ -0,0 +1,129 @@
import {
isCredentialTypeClass,
findClassProperty,
getStringLiteralValue,
createRule,
} from '../utils/index.js';
type RuleOptions = {
allowUrls?: boolean;
allowSlugs?: boolean;
};
const DEFAULT_OPTIONS: RuleOptions = {
allowUrls: true,
allowSlugs: false,
};
function isValidUrl(value: string): boolean {
try {
new URL(value);
return true;
} catch {
return false;
}
}
function isValidSlug(value: string): boolean {
// TODO: Remove this special case once these slugs are updated
if (
['google/service-account', 'google/oauth-single-service', 'google/oauth-generic'].includes(
value,
)
)
return true;
return value.split('/').every((segment) => /^[a-z][a-z0-9]*$/.test(segment));
}
function hasOnlyCaseIssues(value: string): boolean {
return value.split('/').every((segment) => /^[a-zA-Z][a-zA-Z0-9]*$/.test(segment));
}
function validateDocumentationUrl(value: string, options: RuleOptions): boolean {
return (!!options.allowUrls && isValidUrl(value)) || (!!options.allowSlugs && isValidSlug(value));
}
function getExpectedFormatsMessage(options: RuleOptions): string {
const formats = [
...(options.allowUrls ? ['a valid URL'] : []),
...(options.allowSlugs ? ['a lowercase alphanumeric slug (can contain slashes)'] : []),
];
if (formats.length === 0) return 'a valid format (none configured)';
if (formats.length === 1) return formats[0]!;
return formats.slice(0, -1).join(', ') + ' or ' + formats[formats.length - 1];
}
export const CredentialDocumentationUrlRule = createRule({
name: 'credential-documentation-url',
meta: {
type: 'problem',
docs: {
description:
'Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug)',
},
messages: {
invalidDocumentationUrl: "documentationUrl '{{ value }}' must be {{ expectedFormats }}",
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
allowUrls: {
type: 'boolean',
description: 'Whether to allow valid URLs',
},
allowSlugs: {
type: 'boolean',
description: 'Whether to allow lowercase alphanumeric slugs with slashes',
},
},
additionalProperties: false,
},
],
},
defaultOptions: [DEFAULT_OPTIONS],
create(context, [options = {}]) {
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const documentationUrlProperty = findClassProperty(node, 'documentationUrl');
if (!documentationUrlProperty?.value) {
return;
}
const documentationUrl = getStringLiteralValue(documentationUrlProperty.value);
if (documentationUrl === null) {
return;
}
if (!validateDocumentationUrl(documentationUrl, mergedOptions)) {
const canAutofix = !!mergedOptions.allowSlugs && hasOnlyCaseIssues(documentationUrl);
context.report({
node: documentationUrlProperty.value,
messageId: 'invalidDocumentationUrl',
data: {
value: documentationUrl,
expectedFormats: getExpectedFormatsMessage(mergedOptions),
},
fix: canAutofix
? (fixer) =>
fixer.replaceText(
documentationUrlProperty.value!,
`'${documentationUrl.toLowerCase()}'`,
)
: undefined,
});
}
},
};
},
});
@@ -0,0 +1,232 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
const ruleTester = new RuleTester();
function createCredentialCode(properties: string[]): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestCredential implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [
${properties.map((prop) => `\t\t${prop}`).join(',\n')},
];
}`;
}
function createProperty(
displayName: string,
name: string,
options: { password?: boolean; emptyTypeOptions?: boolean } = {},
): string {
let typeOptionsStr = '';
if (options.emptyTypeOptions) {
typeOptionsStr = '\n\t\t\ttypeOptions: {},';
} else if (options.password !== undefined) {
typeOptionsStr = `\n\t\t\ttypeOptions: { password: ${options.password} },`;
}
return `{
displayName: '${displayName}',
name: '${name}',
type: 'string',
default: '',${typeOptionsStr}
}`;
}
function createOAuth2CredentialCode(hasPasswordProtection: boolean = true): string {
const passwordOptions = hasPasswordProtection ? '\n\t\t\ttypeOptions: { password: true },' : '';
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class GithubOAuth2Api implements ICredentialType {
name = 'githubOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'GitHub OAuth2 API';
properties: INodeProperties[] = [
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://github.com/login/oauth/access_token',
},
{
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string',
default: '',${passwordOptions}
},
];
}`;
}
// Helper function to create a regular (non-credential) class
function createRegularClass(): string {
return `
export class RegularClass {
properties = [
{
name: 'password',
type: 'string',
},
];
}`;
}
ruleTester.run('credential-password-field', CredentialPasswordFieldRule, {
valid: [
{
name: 'correct usage with password field having typeOptions.password = true',
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
},
{
name: 'field name is not sensitive',
code: createCredentialCode([createProperty('Base URL', 'baseUrl')]),
},
{
name: 'multiple sensitive fields with correct typeOptions',
code: createCredentialCode([
createProperty('Password', 'password', { password: true }),
createProperty('Secret Token', 'secretToken', { password: true }),
]),
},
{
name: 'class does not implement ICredentialType',
code: createRegularClass(),
},
{
name: 'OAuth2 credential with URL fields and proper client secret protection',
code: createOAuth2CredentialCode(true),
},
{
name: 'public key fields should not be flagged as sensitive',
code: createCredentialCode([
createProperty('Public Key', 'publicKey'),
createProperty('Client ID', 'clientId'),
]),
},
{
name: 'certificates should be flagged as sensitive (when properly configured)',
code: createCredentialCode([
createProperty('Certificate', 'certificate', { password: true }),
createProperty('CA Certificate', 'caCert', { password: true }),
createProperty('Client Certificate', 'clientCert', { password: true }),
]),
},
{
name: 'URL fields containing sensitive keywords should not be flagged',
code: createCredentialCode([
createProperty('Token URL', 'tokenUrl'),
createProperty('Authorization URL', 'authorizationUrl'),
createProperty('Access Token URL', 'accessTokenUrl'),
]),
},
{
name: 'ID fields containing sensitive keywords should not be flagged',
code: createCredentialCode([
createProperty('Access Key ID', 'accessKeyId'),
createProperty('Key ID', 'keyId'),
createProperty('User ID', 'userId'),
]),
},
{
name: 'file path and name fields should not be flagged',
code: createCredentialCode([
createProperty('Key File Path', 'keyPath'),
createProperty('Key File Name', 'keyFile'),
createProperty('Certificate Path', 'keyName'),
]),
},
{
name: 'should flag actual sensitive fields while ignoring false positives',
code: createCredentialCode([
createProperty('Private Key', 'privateKey', { password: true }),
createProperty('Public Key', 'publicKey'), // Should not be flagged
createProperty('Secret Token', 'secretToken', { password: true }),
createProperty('Client ID', 'clientId'), // Should not be flagged
]),
},
],
invalid: [
{
name: 'password field missing typeOptions.password = true',
code: createCredentialCode([createProperty('Password', 'password')]),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } }],
output: createCredentialCode([createProperty('Password', 'password', { password: true })]),
},
{
name: 'API key field missing typeOptions.password = true',
code: createCredentialCode([createProperty('API Key', 'apiKey')]),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
},
{
name: 'secret field missing typeOptions.password = true',
code: createCredentialCode([createProperty('Client Secret', 'clientSecret')]),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
output: createCredentialCode([
createProperty('Client Secret', 'clientSecret', { password: true }),
]),
},
{
name: 'multiple invalid fields',
code: createCredentialCode([
createProperty('Password', 'password'),
createProperty('Username', 'username'),
createProperty('Access Token', 'accessToken'),
]),
errors: [
{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } },
{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } },
],
output: createCredentialCode([
createProperty('Password', 'password', { password: true }),
createProperty('Username', 'username'),
createProperty('Access Token', 'accessToken', { password: true }),
]),
},
{
name: 'field has typeOptions but password is false',
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: false })]),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
},
{
name: 'OAuth2 credential with missing password protection for clientSecret',
code: createOAuth2CredentialCode(false),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
output: createOAuth2CredentialCode(true),
},
{
name: 'field has empty typeOptions object',
code: createCredentialCode([
createProperty('Access Token', 'accessToken', { emptyTypeOptions: true }),
]),
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } }],
output: createCredentialCode([
createProperty('Access Token', 'accessToken', { password: true }),
]),
},
{
name: 'certificate fields should require password protection',
code: createCredentialCode([
createProperty('Certificate', 'certificate'),
createProperty('Client Certificate', 'clientCert'),
]),
errors: [
{ messageId: 'missingPasswordOption', data: { fieldName: 'certificate' } },
{ messageId: 'missingPasswordOption', data: { fieldName: 'clientCert' } },
],
output: createCredentialCode([
createProperty('Certificate', 'certificate', { password: true }),
createProperty('Client Certificate', 'clientCert', { password: true }),
]),
},
],
});
@@ -0,0 +1,141 @@
import { TSESTree } from '@typescript-eslint/types';
import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint';
import {
isCredentialTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
getBooleanLiteralValue,
createRule,
} from '../utils/index.js';
const SENSITIVE_PATTERNS = [
'password',
'secret',
'token',
'cert',
'passphrase',
'apikey',
'secretkey',
'privatekey',
'authkey',
];
const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
function isSensitiveFieldName(name: string): boolean {
const lowerName = name.toLowerCase();
if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
return false;
}
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
}
function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
return false;
}
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
return passwordValue === true;
}
function createPasswordFix(
element: TSESTree.ObjectExpression,
typeOptionsProperty: TSESTree.Property | null,
): ReportFixFunction {
return (fixer) => {
if (typeOptionsProperty?.value.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
if (passwordProperty) {
return fixer.replaceText(passwordProperty.value, 'true');
}
const objectValue = typeOptionsProperty.value;
if (objectValue.properties.length > 0) {
const lastProperty = objectValue.properties[objectValue.properties.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, ', password: true');
}
} else {
const range = objectValue.range;
if (range) {
const openBrace = range[0] + 1;
return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true ');
}
}
}
const lastProperty = element.properties[element.properties.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }');
}
return null;
};
}
export const CredentialPasswordFieldRule = createRule({
name: 'credential-password-field',
meta: {
type: 'problem',
docs: {
description: 'Ensure credential fields with sensitive names have typeOptions.password = true',
},
messages: {
missingPasswordOption:
"Field '{{ fieldName }}' appears to be a sensitive field but is missing 'typeOptions: { password: true }'",
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const propertiesProperty = findClassProperty(node, 'properties');
if (
!propertiesProperty?.value ||
propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
) {
return;
}
for (const element of propertiesProperty.value.elements) {
if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
continue;
}
const nameProperty = findObjectProperty(element, 'name');
const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
if (!fieldName || !isSensitiveFieldName(fieldName)) {
continue;
}
if (!hasPasswordTypeOption(element)) {
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
context.report({
node: element,
messageId: 'missingPasswordOption',
data: { fieldName },
fix: createPasswordFix(element, typeOptionsProperty),
});
}
}
},
};
},
});
@@ -0,0 +1,174 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredentialTestRequiredRule } from './credential-test-required.js';
const ruleTester = new RuleTester();
// Helper function to create credential class code
function createCredentialCode(options: {
name?: string;
displayName?: string;
hasTest?: boolean;
extends?: string[];
extraProperties?: string;
}): string {
const {
name = 'myApi',
displayName = 'My API',
hasTest = false,
extends: extendsArray,
extraProperties = '',
} = options;
const imports = hasTest
? "import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';"
: "import type { ICredentialType, INodeProperties } from 'n8n-workflow';";
const extendsStr = extendsArray ? `\n\textends = ${JSON.stringify(extendsArray)};` : '';
const testProperty = hasTest
? "\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};"
: '';
return `
${imports}
export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType {
name = '${name}';${extendsStr}
displayName = '${displayName}';
properties: INodeProperties[] = [];${testProperty}${extraProperties}
}`;
}
function createNonCredentialClass(className: string = 'SomeOtherClass'): string {
return `
export class ${className} {
name = 'notACredential';
}`;
}
ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
valid: [
{
name: 'credential class with test property',
filename: 'MyApi.credentials.ts',
code: createCredentialCode({ hasTest: true }),
},
{
name: 'credential class extending oAuth2Api (exempt)',
filename: 'MyOAuth2Api.credentials.ts',
code: createCredentialCode({
name: 'myOAuth2Api',
displayName: 'My OAuth2 API',
extends: ['oAuth2Api'],
}),
},
{
name: 'non-credential class ignored',
filename: 'MyApi.credentials.ts',
code: createNonCredentialClass(),
},
{
name: 'non-credential file ignored',
filename: 'regular-file.ts',
code: createCredentialCode({}),
},
],
invalid: [
{
name: 'credential class missing test property and no testedBy in package',
filename: 'MyApi.credentials.ts',
code: createCredentialCode({}),
errors: [
{
messageId: 'missingCredentialTest',
data: { className: 'MyApi' },
suggestions: [
{
messageId: 'addTemplate',
output: `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class MyApi implements ICredentialType {
name = 'myApi';
displayName = 'My API';
properties: INodeProperties[] = [];
test: ICredentialTestRequest = {
request: {
method: 'GET',
url: '={{$credentials.server}}/test', // Replace with actual endpoint
},
};
}`,
},
],
},
],
},
{
name: 'credential class with extends but not oAuth2Api and no testedBy in package',
filename: 'MyApi.credentials.ts',
code: createCredentialCode({ extends: ['someOtherApi'] }),
errors: [
{
messageId: 'missingCredentialTest',
data: { className: 'MyApi' },
suggestions: [
{
messageId: 'addTemplate',
output: `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class MyApi implements ICredentialType {
name = 'myApi';
extends = ["someOtherApi"];
displayName = 'My API';
properties: INodeProperties[] = [];
test: ICredentialTestRequest = {
request: {
method: 'GET',
url: '={{$credentials.server}}/test', // Replace with actual endpoint
},
};
}`,
},
],
},
],
},
{
name: 'credential class with empty extends array and no testedBy in package',
filename: 'MyApi.credentials.ts',
code: createCredentialCode({ extends: [] }),
errors: [
{
messageId: 'missingCredentialTest',
data: { className: 'MyApi' },
suggestions: [
{
messageId: 'addTemplate',
output: `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class MyApi implements ICredentialType {
name = 'myApi';
extends = [];
displayName = 'My API';
properties: INodeProperties[] = [];
test: ICredentialTestRequest = {
request: {
method: 'GET',
url: '={{$credentials.server}}/test', // Replace with actual endpoint
},
};
}`,
},
],
},
],
},
],
});
@@ -0,0 +1,145 @@
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
import { dirname } from 'node:path';
import {
isCredentialTypeClass,
findClassProperty,
hasArrayLiteralValue,
isFileType,
getStringLiteralValue,
findPackageJson,
areAllCredentialUsagesTestedByNodes,
createRule,
} from '../utils/index.js';
export const CredentialTestRequiredRule = createRule({
name: 'credential-test-required',
meta: {
type: 'problem',
docs: {
description: 'Ensure credentials have a credential test',
},
messages: {
addTemplate: 'Add basic credential test template',
missingCredentialTest:
'Credential class "{{ className }}" must have a test property or be tested by a node via testedBy',
},
schema: [],
hasSuggestions: true,
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.credentials.ts')) {
return {};
}
let packageDir: string | null = null;
const getPackageDir = (): string | null => {
if (packageDir !== null) {
return packageDir;
}
const packageJsonPath = findPackageJson(context.filename);
if (!packageJsonPath) {
packageDir = '';
return packageDir;
}
packageDir = dirname(packageJsonPath);
return packageDir;
};
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const extendsProperty = findClassProperty(node, 'extends');
if (extendsProperty && hasArrayLiteralValue(extendsProperty, 'oAuth2Api')) {
return;
}
const testProperty = findClassProperty(node, 'test');
if (testProperty) {
return;
}
const nameProperty = findClassProperty(node, 'name');
if (!nameProperty) {
return;
}
const credentialName = getStringLiteralValue(nameProperty.value);
if (!credentialName) {
return;
}
const pkgDir = getPackageDir();
if (!pkgDir) {
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
const testProperty = createCredentialTestTemplate();
suggestions.push({
messageId: 'addTemplate',
fix(fixer) {
const classBody = node.body.body;
const lastProperty = classBody[classBody.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
}
return null;
},
});
context.report({
node,
messageId: 'missingCredentialTest',
data: {
className: node.id?.name ?? 'Unknown',
},
suggest: suggestions,
});
return;
}
const allUsagesTestedByNodes = areAllCredentialUsagesTestedByNodes(credentialName, pkgDir);
if (!allUsagesTestedByNodes) {
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
const testProperty = createCredentialTestTemplate();
suggestions.push({
messageId: 'addTemplate',
fix(fixer) {
const classBody = node.body.body;
const lastProperty = classBody[classBody.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
}
return null;
},
});
context.report({
node,
messageId: 'missingCredentialTest',
data: {
className: node.id?.name ?? 'Unknown',
},
suggest: suggestions,
});
}
},
};
},
});
function createCredentialTestTemplate(): string {
return `\ttest: ICredentialTestRequest = {
\t\trequest: {
\t\t\tmethod: 'GET',
\t\t\turl: '={{$credentials.server}}/test', // Replace with actual endpoint
\t\t},
\t};`;
}
@@ -0,0 +1,279 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import * as fs from 'node:fs';
import { vi } from 'vitest';
import { IconValidationRule } from './icon-validation.js';
const ruleTester = new RuleTester();
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readdirSync: vi.fn(),
}));
const mockExistsSync = vi.mocked(fs.existsSync);
const mockReaddirSync = vi.mocked(fs.readdirSync);
const mockSvgFiles = [
'TestNode.svg',
'ValidIcon.svg',
'ValidIcon.dark.svg',
'SameIcon.svg',
'github.svg',
];
function setupMockFileSystem() {
mockExistsSync.mockImplementation((path: fs.PathLike) => {
const pathStr = path.toString();
if (mockSvgFiles.some((file) => pathStr.includes(file)) || pathStr.includes('NotSvg.png')) {
return true;
}
if (pathStr.endsWith('/tmp/icons') || pathStr.endsWith('/tmp') || pathStr.endsWith('icons')) {
return true;
}
return false;
});
// @ts-expect-error Typescript does not select the correct overload
mockReaddirSync.mockImplementation((path: fs.PathLike): string[] => {
const pathStr = path.toString();
if (pathStr.includes('icons')) {
return [...mockSvgFiles, 'NotSvg.png'];
}
return [];
});
}
setupMockFileSystem();
const nodeFilePath = '/tmp/TestNode.node.ts';
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
function createNodeCode(
icon?: string | { light: string; dark: string },
includeTypeImport: boolean = false,
): string {
const typeImport = includeTypeImport
? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';"
: "import type { INodeType } from 'n8n-workflow';";
const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : '';
let iconProperty = '';
if (icon) {
if (typeof icon === 'string') {
iconProperty = `icon: '${icon}',`;
} else {
iconProperty = `icon: {
light: '${icon.light}',
dark: '${icon.dark}'
},`;
}
}
return `
${typeImport}
export class TestNode implements INodeType {
description${typeAnnotation} = {
displayName: 'Test Node',
name: 'testNode',
${iconProperty}
group: ['input'],
version: 1,
description: 'A test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}`;
}
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
let iconProperty = '';
if (icon) {
if (typeof icon === 'string') {
iconProperty = `icon = '${icon}';`;
} else {
iconProperty = `icon = {
light: '${icon.light}',
dark: '${icon.dark}'
};`;
}
}
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestCredential implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
${iconProperty}
properties: INodeProperties[] = [];
}`;
}
// Helper function to create non-node class
function createNonNodeClass(icon: string): string {
return `
export class NotANode {
icon = '${icon}';
}`;
}
ruleTester.run('icon-validation', IconValidationRule, {
valid: [
{
name: 'non-node class ignored',
filename: nodeFilePath,
code: createNonNodeClass('file:nonexistent.png'),
},
{
name: 'non-node file ignored',
filename: '/tmp/regular-file.ts',
code: createNodeCode('file:nonexistent.svg'),
},
{
name: 'node with valid string icon in description',
filename: nodeFilePath,
code: createNodeCode('file:icons/TestNode.svg', true),
},
{
name: 'node with valid light/dark icons in description',
filename: nodeFilePath,
code: createNodeCode(
{
light: 'file:icons/ValidIcon.svg',
dark: 'file:icons/ValidIcon.dark.svg',
},
true,
),
},
{
name: 'credential with valid string icon',
filename: credentialFilePath,
code: createCredentialCode('file:icons/TestNode.svg'),
},
{
name: 'credential with valid light/dark icons',
filename: credentialFilePath,
code: createCredentialCode({
light: 'file:icons/ValidIcon.svg',
dark: 'file:icons/ValidIcon.dark.svg',
}),
},
],
invalid: [
{
name: 'node missing icon property in description',
filename: nodeFilePath,
code: createNodeCode(undefined, true),
errors: [
{
messageId: 'missingIcon',
suggestions: [
{
messageId: 'addPlaceholder',
output:
"\nimport type { INodeType, INodeTypeDescription } from 'n8n-workflow';\n\nexport class TestNode implements INodeType {\n\tdescription: INodeTypeDescription = {\n\t\tdisplayName: 'Test Node',\n\t\tname: 'testNode',\n\t\t\n\t\tgroup: ['input'],\n\t\tversion: 1,\n\t\tdescription: 'A test node',\n\t\tdefaults: {\n\t\t\tname: 'Test Node',\n\t\t},\n\t\tinputs: ['main'],\n\t\toutputs: ['main'],\n\t\tproperties: [],\n\t\ticon: \"file:./icon.svg\",\n\t};\n}",
},
],
},
],
},
{
name: 'icon file does not exist in description',
filename: nodeFilePath,
code: createNodeCode('file:icons/NonExistent.svg', true),
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
},
{
name: 'light and dark icons are the same file in description',
filename: nodeFilePath,
code: createNodeCode(
{
light: 'file:icons/SameIcon.svg',
dark: 'file:icons/SameIcon.svg',
},
true,
),
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
},
{
name: 'credential missing icon property',
filename: credentialFilePath,
code: createCredentialCode(),
errors: [
{
messageId: 'missingIcon',
suggestions: [
{
messageId: 'addPlaceholder',
output:
"\nimport type { ICredentialType, INodeProperties } from 'n8n-workflow';\n\nexport class TestCredential implements ICredentialType {\n\tname = 'testApi';\n\tdisplayName = 'Test API';\n\t\n\tproperties: INodeProperties[] = [];\n\n\ticon = \"file:./icon.svg\";\n}",
},
],
},
],
},
{
name: 'credential icon file does not exist',
filename: credentialFilePath,
code: createCredentialCode('file:icons/NonExistent.svg'),
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
},
{
name: 'credential light and dark icons are the same file',
filename: credentialFilePath,
code: createCredentialCode({
light: 'file:icons/SameIcon.svg',
dark: 'file:icons/SameIcon.svg',
}),
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
},
{
name: 'node icon file does not exist but similar file exists - should suggest similar file',
filename: nodeFilePath,
code: createNodeCode('file:icons/github2.svg'),
errors: [
{
messageId: 'iconFileNotFound',
data: { iconPath: 'icons/github2.svg' },
suggestions: [
{
messageId: 'similarIcon',
data: { suggestedName: 'icons/github.svg' },
output: `
import type { INodeType } from 'n8n-workflow';
export class TestNode implements INodeType {
description = {
displayName: 'Test Node',
name: 'testNode',
icon: "file:icons/github.svg",
group: ['input'],
version: 1,
description: 'A test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}`,
},
],
},
],
},
],
});
@@ -0,0 +1,239 @@
import { TSESTree } from '@typescript-eslint/utils';
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
import { dirname } from 'node:path';
import {
isNodeTypeClass,
isCredentialTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
validateIconPath,
findSimilarSvgFiles,
isFileType,
createRule,
} from '../utils/index.js';
const messages = {
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
missingIcon: 'Node/Credential class must have an icon property defined',
addPlaceholder: 'Add icon property with placeholder',
addFileProtocol: "Add 'file:' protocol to icon path",
changeExtension: "Change icon extension to '.svg'",
similarIcon: "Use existing icon '{{ suggestedName }}'",
} as const;
export const IconValidationRule = createRule({
name: 'icon-validation',
meta: {
type: 'problem',
docs: {
description:
'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
},
messages,
schema: [],
hasSuggestions: true,
},
defaultOptions: [],
create(context) {
if (
!isFileType(context.filename, '.node.ts') &&
!isFileType(context.filename, '.credentials.ts')
) {
return {};
}
const validateIcon = (iconPath: string | null, node: TSESTree.Node): boolean => {
if (!iconPath) {
context.report({
node,
messageId: 'invalidIconPath',
data: { iconPath: iconPath ?? '' },
});
return false;
}
const currentDir = dirname(context.filename);
const validation = validateIconPath(iconPath, currentDir);
if (!validation.isFile) {
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
if (!iconPath.startsWith('file:')) {
suggestions.push({
messageId: 'addFileProtocol',
fix(fixer) {
return fixer.replaceText(node, `"file:${iconPath}"`);
},
});
}
context.report({
node,
messageId: 'invalidIconPath',
data: { iconPath },
suggest: suggestions,
});
return false;
}
if (!validation.isSvg) {
const relativePath = iconPath.replace(/^file:/, '');
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
const svgPath = `${pathWithoutExt}.svg`;
suggestions.push({
messageId: 'changeExtension',
fix(fixer) {
return fixer.replaceText(node, `"file:${svgPath}"`);
},
});
context.report({
node,
messageId: 'iconNotSvg',
data: { iconPath: relativePath },
suggest: suggestions,
});
return false;
}
if (!validation.exists) {
const relativePath = iconPath.replace(/^file:/, '');
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
// Find similar SVG files in the same directory
const similarFiles = findSimilarSvgFiles(relativePath, currentDir);
for (const similarFile of similarFiles) {
suggestions.push({
messageId: 'similarIcon',
data: { suggestedName: similarFile },
fix(fixer) {
return fixer.replaceText(node, `"file:${similarFile}"`);
},
});
}
context.report({
node,
messageId: 'iconFileNotFound',
data: { iconPath: relativePath },
suggest: suggestions,
});
return false;
}
return true;
};
const validateIconValue = (iconValue: TSESTree.Node) => {
if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) {
const iconPath = getStringLiteralValue(iconValue);
validateIcon(iconPath, iconValue);
} else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
const lightProperty = findObjectProperty(iconValue, 'light');
const darkProperty = findObjectProperty(iconValue, 'dark');
const lightPath = lightProperty ? getStringLiteralValue(lightProperty.value) : null;
const darkPath = darkProperty ? getStringLiteralValue(darkProperty.value) : null;
if (lightProperty) {
validateIcon(lightPath, lightProperty.value);
}
if (darkProperty) {
validateIcon(darkPath, darkProperty.value);
}
if (lightPath && darkPath && lightPath === darkPath && lightProperty) {
context.report({
node: lightProperty.value,
messageId: 'lightDarkSame',
data: { iconPath: lightPath.replace(/^file:/, '') },
});
}
}
};
return {
ClassDeclaration(node) {
const isNodeClass = isNodeTypeClass(node);
const isCredentialClass = isCredentialTypeClass(node);
if (!isNodeClass && !isCredentialClass) {
return;
}
if (isNodeClass) {
const descriptionProperty = findClassProperty(node, 'description');
if (
!descriptionProperty?.value ||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
) {
context.report({
node,
messageId: 'missingIcon',
});
return;
}
const descriptionValue = descriptionProperty.value;
const iconProperty = findObjectProperty(descriptionValue, 'icon');
if (!iconProperty) {
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
suggestions.push({
messageId: 'addPlaceholder',
fix(fixer) {
const lastProperty =
descriptionValue.properties[descriptionValue.properties.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, ',\n\t\ticon: "file:./icon.svg"');
}
return null;
},
});
context.report({
node,
messageId: 'missingIcon',
suggest: suggestions,
});
return;
}
validateIconValue(iconProperty.value);
} else if (isCredentialClass) {
const iconProperty = findClassProperty(node, 'icon');
if (!iconProperty?.value) {
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
suggestions.push({
messageId: 'addPlaceholder',
fix(fixer) {
const classBody = node.body.body;
const lastProperty = classBody[classBody.length - 1];
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, '\n\n\ticon = "file:./icon.svg";');
}
return null;
},
});
context.report({
node,
messageId: 'missingIcon',
suggest: suggestions,
});
return;
}
validateIconValue(iconProperty.value);
}
},
};
},
});
@@ -0,0 +1,29 @@
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
import { CredentialTestRequiredRule } from './credential-test-required.js';
import { IconValidationRule } from './icon-validation.js';
import { NoCredentialReuseRule } from './no-credential-reuse.js';
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
import { PackageNameConventionRule } from './package-name-convention.js';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
export const rules = {
'ai-node-package-json': AiNodePackageJsonRule,
'no-restricted-globals': NoRestrictedGlobalsRule,
'no-restricted-imports': NoRestrictedImportsRule,
'credential-password-field': CredentialPasswordFieldRule,
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
'node-usable-as-tool': NodeUsableAsToolRule,
'package-name-convention': PackageNameConventionRule,
'credential-test-required': CredentialTestRequiredRule,
'no-credential-reuse': NoCredentialReuseRule,
'icon-validation': IconValidationRule,
'resource-operation-pattern': ResourceOperationPatternRule,
'credential-documentation-url': CredentialDocumentationUrlRule,
} satisfies Record<string, AnyRuleModule>;
@@ -0,0 +1,474 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { vi } from 'vitest';
import { NoCredentialReuseRule } from './no-credential-reuse.js';
import * as fileUtils from '../utils/file-utils.js';
vi.mock('../utils/file-utils.js', async () => {
const actual = await vi.importActual('../utils/file-utils.js');
return {
...actual,
readPackageJsonCredentials: vi.fn(),
findPackageJson: vi.fn(),
};
});
const mockReadPackageJsonCredentials = vi.mocked(fileUtils.readPackageJsonCredentials);
const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson);
const ruleTester = new RuleTester();
const nodeFilePath = '/tmp/TestNode.node.ts';
function createNodeCode(
credentials: Array<string | { name: string; required?: boolean }> = [],
): string {
const credentialsArray =
credentials.length > 0
? credentials
.map((cred) => {
if (typeof cred === 'string') {
return `'${cred}'`;
} else {
const required =
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
return `{\n\t\t\t\tname: '${cred.name}'${required},\n\t\t\t}`;
}
})
.join(',\n\t\t\t')
: '';
const credentialsProperty =
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
${credentialsProperty}
properties: [],
};
}`;
}
// Helper function to create expected outputs with double quotes (matching rule fix behavior)
function createExpectedNodeCode(
credentials: Array<string | { name: string; required?: boolean }> = [],
): string {
const credentialsArray =
credentials.length > 0
? credentials
.map((cred) => {
if (typeof cred === 'string') {
return `"${cred}"`;
} else {
const required =
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
return `{\n\t\t\t\tname: "${cred.name}"${required},\n\t\t\t}`;
}
})
.join(',\n\t\t\t')
: '';
const credentialsProperty =
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
${credentialsProperty}
properties: [],
};
}`;
}
// Helper function to create non-node class
function createNonNodeClass(): string {
return `
export class RegularClass {
credentials = [
{ name: 'ExternalApi', required: true }
];
}`;
}
// Helper function to create non-INodeType class
function createNonINodeTypeClass(): string {
return `
export class NotANode {
description = {
displayName: 'Not A Node',
credentials: [
{ name: 'ExternalApi', required: true }
]
};
}`;
}
function setupMockFileSystem() {
mockFindPackageJson.mockReturnValue('/tmp/package.json');
mockReadPackageJsonCredentials.mockReturnValue(
new Set(['myApiCredential', 'anotherApiCredential']),
);
}
setupMockFileSystem();
ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
valid: [
{
name: 'node using allowed credential (object form) from same package',
filename: nodeFilePath,
code: createNodeCode([{ name: 'myApiCredential', required: true }]),
},
{
name: 'node using allowed credential (string form) from same package',
filename: nodeFilePath,
code: createNodeCode(['myApiCredential']),
},
{
name: 'node using multiple allowed credentials (mixed forms)',
filename: nodeFilePath,
code: createNodeCode(['myApiCredential', { name: 'anotherApiCredential', required: false }]),
},
{
name: 'node without credentials array',
filename: nodeFilePath,
code: createNodeCode(),
},
{
name: 'non-node file ignored',
filename: '/tmp/regular-file.ts',
code: createNonNodeClass(),
},
{
name: 'non-INodeType class ignored',
filename: nodeFilePath,
code: createNonINodeTypeClass(),
},
],
invalid: [
{
name: 'SECURITY: node using credential not in package (object form)',
filename: nodeFilePath,
code: createNodeCode([{ name: 'ExternalApi', required: true }]),
errors: [
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'ExternalApi' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: createExpectedNodeCode([{ name: 'myApiCredential', required: true }]),
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: createExpectedNodeCode([{ name: 'anotherApiCredential', required: true }]),
},
],
},
],
},
{
name: 'SECURITY: node using credential not in package (string form)',
filename: nodeFilePath,
code: createNodeCode(['ExternalApi']),
errors: [
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'ExternalApi' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: createExpectedNodeCode(['myApiCredential']),
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: createExpectedNodeCode(['anotherApiCredential']),
},
],
},
],
},
{
name: 'SECURITY: node using mix of allowed and disallowed credentials (mixed forms)',
filename: nodeFilePath,
code: createNodeCode([
'myApiCredential',
{ name: 'ExternalApi', required: true },
'AnotherExternalApi',
]),
errors: [
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'ExternalApi' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
'myApiCredential',
{
name: "myApiCredential",
required: true,
},
'AnotherExternalApi'
],
properties: [],
};
}`,
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
'myApiCredential',
{
name: "anotherApiCredential",
required: true,
},
'AnotherExternalApi'
],
properties: [],
};
}`,
},
],
},
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'AnotherExternalApi' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
'myApiCredential',
{
name: 'ExternalApi',
required: true,
},
"myApiCredential"
],
properties: [],
};
}`,
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
'myApiCredential',
{
name: 'ExternalApi',
required: true,
},
"anotherApiCredential"
],
properties: [],
};
}`,
},
],
},
],
},
{
name: 'node using multiple disallowed credentials',
filename: nodeFilePath,
code: createNodeCode([
{ name: 'ExternalApi1', required: true },
{ name: 'ExternalApi2', required: false },
]),
errors: [
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'ExternalApi1' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: "myApiCredential",
required: true,
},
{
name: 'ExternalApi2',
required: false,
}
],
properties: [],
};
}`,
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: "anotherApiCredential",
required: true,
},
{
name: 'ExternalApi2',
required: false,
}
],
properties: [],
};
}`,
},
],
},
{
messageId: 'credentialNotInPackage',
data: { credentialName: 'ExternalApi2' },
suggestions: [
{
messageId: 'useAvailable',
data: { suggestedName: 'myApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'ExternalApi1',
required: true,
},
{
name: "myApiCredential",
required: false,
}
],
properties: [],
};
}`,
},
{
messageId: 'useAvailable',
data: { suggestedName: 'anotherApiCredential' },
output: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'ExternalApi1',
required: true,
},
{
name: "anotherApiCredential",
required: false,
}
],
properties: [],
};
}`,
},
],
},
],
},
],
});
@@ -0,0 +1,121 @@
import { TSESTree } from '@typescript-eslint/types';
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
import {
isNodeTypeClass,
findClassProperty,
findArrayLiteralProperty,
extractCredentialNameFromArray,
findPackageJson,
readPackageJsonCredentials,
isFileType,
findSimilarStrings,
createRule,
} from '../utils/index.js';
export const NoCredentialReuseRule = createRule({
name: 'no-credential-reuse',
meta: {
type: 'problem',
docs: {
description:
'Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package',
},
messages: {
didYouMean: "Did you mean '{{ suggestedName }}'?",
useAvailable: "Use available credential '{{ suggestedName }}'",
credentialNotInPackage:
'SECURITY: Node references credential "{{ credentialName }}" which is not defined in this package. This creates a security risk as it attempts to reuse credentials from other packages. Nodes can only use credentials from the same package as listed in package.json n8n.credentials field.',
},
schema: [],
hasSuggestions: true,
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
let packageCredentials: Set<string> | null = null;
const loadPackageCredentials = (): Set<string> => {
if (packageCredentials !== null) {
return packageCredentials;
}
const packageJsonPath = findPackageJson(context.filename);
if (!packageJsonPath) {
packageCredentials = new Set();
return packageCredentials;
}
packageCredentials = readPackageJsonCredentials(packageJsonPath);
return packageCredentials;
};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (
!descriptionProperty?.value ||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
) {
return;
}
const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
if (!credentialsArray) {
return;
}
const allowedCredentials = loadPackageCredentials();
credentialsArray.elements.forEach((element) => {
const credentialInfo = extractCredentialNameFromArray(element);
if (credentialInfo && !allowedCredentials.has(credentialInfo.name)) {
const similarCredentials = findSimilarStrings(credentialInfo.name, allowedCredentials);
const suggestions: ReportSuggestionArray<
'didYouMean' | 'useAvailable' | 'credentialNotInPackage'
> = [];
for (const similarName of similarCredentials) {
suggestions.push({
messageId: 'didYouMean',
data: { suggestedName: similarName },
fix(fixer) {
return fixer.replaceText(credentialInfo.node, `"${similarName}"`);
},
});
}
if (suggestions.length === 0 && allowedCredentials.size > 0) {
const availableCredentials = Array.from(allowedCredentials).slice(0, 3);
for (const availableName of availableCredentials) {
suggestions.push({
messageId: 'useAvailable',
data: { suggestedName: availableName },
fix(fixer) {
return fixer.replaceText(credentialInfo.node, `"${availableName}"`);
},
});
}
}
context.report({
node: credentialInfo.node,
messageId: 'credentialNotInPackage',
data: {
credentialName: credentialInfo.name,
},
suggest: suggestions,
});
}
});
},
};
},
});
@@ -0,0 +1,187 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
const ruleTester = new RuleTester();
ruleTester.run('no-deprecated-workflow-functions', NoDeprecatedWorkflowFunctionsRule, {
valid: [
{
name: 'using recommended functions and types',
code: `
import { IHttpRequestOptions } from 'n8n-workflow';
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: 'https://example.com',
};
const response1 = await this.helpers.httpRequest(requestOptions);
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oAuth2Api', {
method: 'POST',
url: 'https://api.example.com/data',
});`,
},
{
name: 'functions with similar names should not trigger',
code: `
import { request } from 'axios';
const result = await this.helpers.requestSomething();
const response = await request('https://api.example.com');
const config = { request: 'some value' };
// Other objects with helpers property should not trigger
const otherObject = {
helpers: {
request: () => 'not n8n',
requestWithAuthentication: () => 'not n8n'
}
};
const result2 = otherObject.helpers.request();`,
},
{
name: 'types with same name from other modules should not trigger',
code: `
import { IRequestOptions } from 'some-other-package';
function test(options: IRequestOptions) {
return options.url;
}`,
},
],
invalid: [
{
name: 'deprecated request functions',
code: `
const response1 = await this.helpers.request('https://example.com/1');
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
errors: [
{
messageId: 'deprecatedRequestFunction',
data: { functionName: 'request', replacement: 'httpRequest' },
suggestions: [
{
messageId: 'suggestReplaceFunction',
data: { functionName: 'request', replacement: 'httpRequest' },
output: `
const response1 = await this.helpers.httpRequest('https://example.com/1');
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
},
],
},
{
messageId: 'deprecatedRequestFunction',
data: {
functionName: 'requestWithAuthentication',
replacement: 'httpRequestWithAuthentication',
},
suggestions: [
{
messageId: 'suggestReplaceFunction',
data: {
functionName: 'requestWithAuthentication',
replacement: 'httpRequestWithAuthentication',
},
output: `
const response1 = await this.helpers.request('https://example.com/1');
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oauth', options);
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
},
],
},
{
messageId: 'deprecatedRequestFunction',
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
suggestions: [
{
messageId: 'suggestReplaceFunction',
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
output: `
const response1 = await this.helpers.request('https://example.com/1');
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
const response3 = await this.helpers.httpRequestWithAuthentication.call(this, 'google', options);`,
},
],
},
],
},
{
name: 'deprecated types',
code: `
import { IRequestOptions } from 'n8n-workflow';
function makeRequest(options: IRequestOptions): Promise<any> {
return this.helpers.request(options);
}`,
errors: [
{
messageId: 'deprecatedType',
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
suggestions: [
{
messageId: 'suggestReplaceType',
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
output: `
import { IHttpRequestOptions } from 'n8n-workflow';
function makeRequest(options: IRequestOptions): Promise<any> {
return this.helpers.request(options);
}`,
},
],
},
{
messageId: 'deprecatedType',
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
suggestions: [
{
messageId: 'suggestReplaceType',
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
output: `
import { IRequestOptions } from 'n8n-workflow';
function makeRequest(options: IHttpRequestOptions): Promise<any> {
return this.helpers.request(options);
}`,
},
],
},
{
messageId: 'deprecatedRequestFunction',
data: { functionName: 'request', replacement: 'httpRequest' },
suggestions: [
{
messageId: 'suggestReplaceFunction',
data: { functionName: 'request', replacement: 'httpRequest' },
output: `
import { IRequestOptions } from 'n8n-workflow';
function makeRequest(options: IRequestOptions): Promise<any> {
return this.helpers.httpRequest(options);
}`,
},
],
},
],
},
{
name: 'functions without replacement',
code: `
const result = await this.helpers.copyBinaryFile();
return this.helpers.prepareOutputData([{ json: response }]);`,
errors: [
{
messageId: 'deprecatedWithoutReplacement',
data: { functionName: 'copyBinaryFile' },
},
{
messageId: 'deprecatedWithoutReplacement',
data: { functionName: 'prepareOutputData' },
},
],
},
],
});
@@ -0,0 +1,200 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { createRule } from '../utils/index.js';
const DEPRECATED_FUNCTIONS = {
request: 'httpRequest',
requestWithAuthentication: 'httpRequestWithAuthentication',
requestOAuth1: 'httpRequestWithAuthentication',
requestOAuth2: 'httpRequestWithAuthentication',
copyBinaryFile: null,
prepareOutputData: null,
} as const;
const DEPRECATED_TYPES = {
IRequestOptions: 'IHttpRequestOptions',
} as const;
function isDeprecatedFunctionName(name: string): name is keyof typeof DEPRECATED_FUNCTIONS {
return name in DEPRECATED_FUNCTIONS;
}
function isDeprecatedTypeName(name: string): name is keyof typeof DEPRECATED_TYPES {
return name in DEPRECATED_TYPES;
}
export const NoDeprecatedWorkflowFunctionsRule = createRule({
name: 'no-deprecated-workflow-functions',
meta: {
type: 'problem',
docs: {
description: 'Disallow usage of deprecated functions and types from n8n-workflow package',
},
messages: {
deprecatedRequestFunction:
"'{{ functionName }}' is deprecated. Use '{{ replacement }}' instead for better authentication support and consistency.",
deprecatedFunction: "'{{ functionName }}' is deprecated and should be avoided. {{ message }}",
deprecatedType: "'{{ typeName }}' is deprecated. Use '{{ replacement }}' instead.",
deprecatedWithoutReplacement:
"'{{ functionName }}' is deprecated and should be removed or replaced with alternative implementation.",
suggestReplaceFunction: "Replace '{{ functionName }}' with '{{ replacement }}'",
suggestReplaceType: "Replace '{{ typeName }}' with '{{ replacement }}'",
},
schema: [],
hasSuggestions: true,
},
defaultOptions: [],
create(context) {
const n8nWorkflowTypes = new Set<string>();
return {
ImportDeclaration(node) {
if (node.source.value === 'n8n-workflow') {
node.specifiers.forEach((specifier) => {
if (
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
specifier.imported.type === AST_NODE_TYPES.Identifier
) {
n8nWorkflowTypes.add(specifier.local.name);
}
});
}
},
MemberExpression(node) {
if (
node.property.type === AST_NODE_TYPES.Identifier &&
isDeprecatedFunctionName(node.property.name)
) {
if (!isThisHelpersAccess(node)) {
return;
}
const functionName = node.property.name;
const replacement = DEPRECATED_FUNCTIONS[functionName];
if (replacement) {
const messageId = functionName.includes('request')
? 'deprecatedRequestFunction'
: 'deprecatedFunction';
context.report({
node: node.property,
messageId,
data: {
functionName,
replacement,
message: getDeprecationMessage(functionName),
},
suggest: [
{
messageId: 'suggestReplaceFunction',
data: { functionName, replacement },
fix: (fixer) => fixer.replaceText(node.property, replacement),
},
],
});
} else {
context.report({
node: node.property,
messageId: 'deprecatedWithoutReplacement',
data: {
functionName,
},
});
}
}
},
TSTypeReference(node) {
if (
node.typeName.type === AST_NODE_TYPES.Identifier &&
isDeprecatedTypeName(node.typeName.name) &&
n8nWorkflowTypes.has(node.typeName.name)
) {
const typeName = node.typeName.name;
const replacement = DEPRECATED_TYPES[typeName];
context.report({
node: node.typeName,
messageId: 'deprecatedType',
data: {
typeName,
replacement,
},
suggest: [
{
messageId: 'suggestReplaceType',
data: { typeName, replacement },
fix: (fixer) => fixer.replaceText(node.typeName, replacement),
},
],
});
}
},
ImportSpecifier(node) {
// Check if this import is from n8n-workflow by looking at the parent ImportDeclaration
const importDeclaration = node.parent;
if (
importDeclaration?.type === AST_NODE_TYPES.ImportDeclaration &&
importDeclaration.source.value === 'n8n-workflow' &&
node.imported.type === AST_NODE_TYPES.Identifier &&
isDeprecatedTypeName(node.imported.name)
) {
const typeName = node.imported.name;
const replacement = DEPRECATED_TYPES[typeName];
context.report({
node: node.imported,
messageId: 'deprecatedType',
data: {
typeName,
replacement,
},
suggest: [
{
messageId: 'suggestReplaceType',
data: { typeName, replacement },
fix: (fixer) => fixer.replaceText(node.imported, replacement),
},
],
});
}
},
};
},
});
/**
* Check if the MemberExpression follows the this.helpers.* pattern
*/
function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean {
if (node.object?.type === AST_NODE_TYPES.MemberExpression) {
const outerObject = node.object;
return (
outerObject.object?.type === AST_NODE_TYPES.ThisExpression &&
outerObject.property?.type === AST_NODE_TYPES.Identifier &&
outerObject.property.name === 'helpers'
);
}
return false;
}
function getDeprecationMessage(functionName: string): string {
switch (functionName) {
case 'request':
return 'Use httpRequest for better type safety and consistency.';
case 'requestWithAuthentication':
case 'requestOAuth1':
case 'requestOAuth2':
return 'Use httpRequestWithAuthentication which provides unified authentication handling.';
case 'copyBinaryFile':
return 'This function has been removed. Handle binary data directly.';
case 'prepareOutputData':
return 'This function is deprecated. Return data directly from execute method.';
default:
return 'This function is deprecated and should be avoided.';
}
}
@@ -0,0 +1,136 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
const ruleTester = new RuleTester();
ruleTester.run('no-restricted-globals', NoRestrictedGlobalsRule, {
valid: [
{
code: 'const result = someFunction();',
},
{
code: 'window.setTimeout(() => {}, 1000);',
},
{
code: 'const obj = { global: "allowed" };',
},
{
code: 'function process() { return "allowed"; }',
},
{
code: 'console.clearInterval;',
},
{
code: 'const obj = { __dirname: "allowed", Buffer: "allowed", require: "allowed" };',
},
{
code: 'function globalThis() { return "allowed"; }',
},
{
code: 'const helper = require("./helper");',
},
{
name: 'variable declarations should be allowed',
code: 'const process = "my-process"; let global = "my-global";',
},
{
name: 'function parameters should be allowed',
code: 'function test(process, global, setTimeout) { return process; }',
},
{
name: 'arrow function parameters should be allowed',
code: 'const fn = (process, global) => process + global;',
},
{
name: 'destructuring should be allowed',
code: 'const { process, global } = someObject; const [setTimeout] = someArray;',
},
{
name: 'class methods should be allowed',
code: 'class MyClass { process() {} global = "value"; }',
},
{
name: 'import should be allowed',
code: 'import { process } from "./utils";',
},
{
name: 'function expressions should be allowed',
code: 'const fn = function process() {}; const fn2 = function global() {};',
},
{
name: 'locally declared variables should not trigger false positives',
code: `
const process = require('process');
const global = {};
const setTimeout = () => {};
function clearInterval() {}
let setInterval;
var __dirname = '/path';
const __filename = 'file.js';
`,
},
],
invalid: [
{
code: 'const pid = process.pid;',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'process' } }],
},
{
code: 'global.myVar = "test";',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'global' } }],
},
{
code: 'setTimeout(() => {}, 1000);',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setTimeout' } }],
},
{
code: 'clearInterval(timer);',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearInterval' } }],
},
{
code: 'clearTimeout(timer);',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearTimeout' } }],
},
{
code: 'setInterval(() => {}, 1000);',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setInterval' } }],
},
{
code: `
const fn = () => {
process.exit(0);
global.something = true;
};`,
errors: [
{ messageId: 'restrictedGlobal', data: { name: 'process' } },
{ messageId: 'restrictedGlobal', data: { name: 'global' } },
],
},
{
name: 'SECURITY: __dirname usage',
code: 'const currentDir = __dirname;',
errors: [{ messageId: 'restrictedGlobal', data: { name: '__dirname' } }],
},
{
name: 'SECURITY: __filename usage',
code: 'console.log(__filename);',
errors: [{ messageId: 'restrictedGlobal', data: { name: '__filename' } }],
},
{
name: 'SECURITY: globalThis usage',
code: 'globalThis.myVar = "test";',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'globalThis' } }],
},
{
name: 'SECURITY: setImmediate usage',
code: 'setImmediate(() => {});',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setImmediate' } }],
},
{
name: 'SECURITY: clearImmediate usage',
code: 'clearImmediate(immediate);',
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearImmediate' } }],
},
],
});
@@ -0,0 +1,74 @@
import { TSESTree } from '@typescript-eslint/types';
import type { TSESLint } from '@typescript-eslint/utils';
import { createRule } from '../utils/index.js';
const restrictedGlobals = [
'clearInterval',
'clearTimeout',
'global',
'globalThis',
'process',
'setInterval',
'setTimeout',
'setImmediate',
'clearImmediate',
'__dirname',
'__filename',
];
export const NoRestrictedGlobalsRule = createRule({
name: 'no-restricted-globals',
meta: {
type: 'problem',
docs: {
description: 'Disallow usage of restricted global variables in community nodes.',
},
messages: {
restrictedGlobal: "Use of restricted global '{{ name }}' is not allowed",
},
schema: [],
},
defaultOptions: [],
create(context) {
function checkReference(ref: TSESLint.Scope.Reference, name: string) {
const { parent } = ref.identifier;
// Skip property access (like console.process - we want process.exit but not obj.process)
if (
parent?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
parent.property === ref.identifier &&
!parent.computed
) {
return;
}
context.report({
node: ref.identifier,
messageId: 'restrictedGlobal',
data: { name },
});
}
return {
Program() {
const globalScope = context.sourceCode.getScope(context.sourceCode.ast);
const allReferences = [
...globalScope.variables
.filter(
(variable) => restrictedGlobals.includes(variable.name) && variable.defs.length === 0, // No definitions means it's a global
)
.flatMap((variable) =>
variable.references.map((ref) => ({ ref, name: variable.name })),
),
...globalScope.through
.filter((ref) => restrictedGlobals.includes(ref.identifier.name))
.map((ref) => ({ ref, name: ref.identifier.name })),
];
allReferences.forEach(({ ref, name }) => checkReference(ref, name));
},
};
},
});
@@ -0,0 +1,183 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
const ruleTester = new RuleTester();
ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
valid: [
{
code: 'import { WorkflowExecuteMode } from "n8n-workflow";',
},
{
code: 'import _ from "lodash";',
},
{
code: 'import moment from "moment";',
},
{
code: 'import pLimit from "p-limit";',
},
{
code: 'import { DateTime } from "luxon";',
},
{
code: 'import { z } from "zod";',
},
{
code: 'import crypto from "crypto";',
},
{
code: 'import crypto from "node:crypto";',
},
{
code: 'import { helper } from "./helper";',
},
{
code: 'import { utils } from "../utils";',
},
{
code: 'const helper = require("./helper");',
},
{
code: 'const utils = require("../utils");',
},
{
code: 'const _ = require("lodash");',
},
{
code: 'require.resolve("lodash");',
},
{
code: 'require.resolve("./helper");',
},
{
code: 'require.resolve("../utils");',
},
{
code: 'const workflow = await import("n8n-workflow");',
},
{
code: 'import("lodash").then((_) => {});',
},
{
code: 'const helper = await import("./helper");',
},
{
code: 'import("../utils").then((utils) => {});',
},
{
code: 'import(`lodash`).then((_) => {});',
},
{
code: 'require(`./helper`);',
},
{
code: 'require.resolve(`n8n-workflow`);',
},
{
code: 'const workflow = await import(`n8n-workflow`);',
},
],
invalid: [
{
code: 'import fs from "fs";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'fs' } }],
},
{
code: 'import path from "path";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'path' } }],
},
{
code: 'import express from "express";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'express' } }],
},
{
code: 'import axios from "axios";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
},
{
code: 'import { Client } from "@elastic/elasticsearch";',
errors: [{ messageId: 'restrictedImport', data: { modulePath: '@elastic/elasticsearch' } }],
},
{
code: 'const fs = require("fs");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
},
{
code: 'const path = require("path");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
},
{
code: 'const express = require("express");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
},
{
code: 'require.resolve("fs");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
},
{
code: 'require.resolve("express");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
},
{
code: 'const resolved = require.resolve("axios");',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'axios' } }],
},
{
code: `
import fs from "fs";
import path from "path";
import { WorkflowExecuteMode } from "n8n-workflow";
import { supplyModel } from "@n8n/ai-node-sdk";`,
errors: [
{ messageId: 'restrictedImport', data: { modulePath: 'fs' } },
{ messageId: 'restrictedImport', data: { modulePath: 'path' } },
],
},
{
code: `
const fs = require("fs");
const express = require("express");
const lodash = require("lodash");`,
errors: [
{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } },
{ messageId: 'restrictedRequire', data: { modulePath: 'express' } },
],
},
{
code: 'const fs = await import("fs");',
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } }],
},
{
code: 'import("path").then((path) => {});',
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'path' } }],
},
{
code: 'const express = await import("express");',
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'express' } }],
},
{
code: 'const path = require(`path`);',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
},
{
code: 'require.resolve(`express`);',
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
},
{
code: 'const axios = await import(`axios`);',
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } }],
},
{
code: `
const fs = await import("fs");
import("axios").then((axios) => {});
const workflow = await import("n8n-workflow");`,
errors: [
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } },
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } },
],
},
],
});
@@ -0,0 +1,93 @@
import {
getModulePath,
isDirectRequireCall,
isRequireMemberCall,
createRule,
} from '../utils/index.js';
const allowedModules = [
'n8n-workflow',
'ai-node-sdk',
'lodash',
'moment',
'p-limit',
'luxon',
'zod',
'crypto',
'node:crypto',
'@n8n/ai-node-sdk',
];
const isModuleAllowed = (modulePath: string): boolean => {
if (modulePath.startsWith('./') || modulePath.startsWith('../')) return true;
const moduleName = modulePath.startsWith('@')
? modulePath.split('/').slice(0, 2).join('/')
: modulePath.split('/')[0];
if (!moduleName) return true;
return allowedModules.includes(moduleName);
};
export const NoRestrictedImportsRule = createRule({
name: 'no-restricted-imports',
meta: {
type: 'problem',
docs: {
description: 'Disallow usage of restricted imports in community nodes.',
},
messages: {
restrictedImport:
"Import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
restrictedRequire:
"Require of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
restrictedDynamicImport:
"Dynamic import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
ImportDeclaration(node) {
const modulePath = getModulePath(node.source);
if (modulePath && !isModuleAllowed(modulePath)) {
context.report({
node,
messageId: 'restrictedImport',
data: {
modulePath,
},
});
}
},
ImportExpression(node) {
const modulePath = getModulePath(node.source);
if (modulePath && !isModuleAllowed(modulePath)) {
context.report({
node,
messageId: 'restrictedDynamicImport',
data: {
modulePath,
},
});
}
},
CallExpression(node) {
if (isDirectRequireCall(node) || isRequireMemberCall(node)) {
const modulePath = getModulePath(node.arguments[0] ?? null);
if (modulePath && !isModuleAllowed(modulePath)) {
context.report({
node,
messageId: 'restrictedRequire',
data: {
modulePath,
},
});
}
}
},
};
},
});
@@ -0,0 +1,147 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
const ruleTester = new RuleTester();
function createNodeCode(
usableAsTool?: boolean | 'missing',
hasDescription: boolean = true,
): string {
let usableAsToolProperty = '';
if (usableAsTool === true) {
usableAsToolProperty = ',\n\t\tusableAsTool: true';
} else if (usableAsTool === false) {
usableAsToolProperty = ',\n\t\tusableAsTool: false';
}
if (!hasDescription) {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
displayName = 'Test Node';
}`;
}
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: []${usableAsToolProperty},
};
}`;
}
function createNonNodeClass(): string {
return `
export class RegularClass {
someProperty = 'value';
}`;
}
function createNodeCodeWithOutputsInputs(
outputs: string,
inputs: string,
includeUsableAsTool = false,
): string {
const usableAsToolLine = includeUsableAsTool ? '\n\t\tusableAsTool: true,' : '';
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: {
name: 'Test Node',
},
inputs: ${inputs},
outputs: ${outputs},
properties: [],${usableAsToolLine}
};
}`;
}
ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
valid: [
{
name: 'node with usableAsTool set to true',
code: createNodeCode(true),
},
{
name: 'class that does not implement INodeType',
code: createNonNodeClass(),
},
{
name: 'node with usableAsTool set to false',
code: createNodeCode(false),
},
{
name: 'node without description property',
code: createNodeCode(undefined, false),
},
{
name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs skips usableAsTool check',
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]'),
},
{
name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs skips usableAsTool check',
code: createNodeCodeWithOutputsInputs(
'[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]',
'[]',
),
},
{
name: 'AI-only node: non-main string literal output and empty inputs skips usableAsTool check',
code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]'),
},
],
invalid: [
{
name: 'node missing usableAsTool property',
code: createNodeCode('missing'),
errors: [{ messageId: 'missingUsableAsTool' }],
output: createNodeCode(true),
},
{
name: 'NodeConnectionTypes.Main output with empty inputs does not skip check',
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]'),
errors: [{ messageId: 'missingUsableAsTool' }],
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]', true),
},
{
name: 'main string literal output with empty inputs does not skip check',
code: createNodeCodeWithOutputsInputs("['main']", '[]'),
errors: [{ messageId: 'missingUsableAsTool' }],
output: createNodeCodeWithOutputsInputs("['main']", '[]', true),
},
{
name: 'non-Main output with non-empty inputs does not skip check',
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']"),
errors: [{ messageId: 'missingUsableAsTool' }],
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']", true),
},
{
name: 'non-main string literal output with non-empty inputs does not skip check',
code: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']"),
errors: [{ messageId: 'missingUsableAsTool' }],
output: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']", true),
},
],
});
@@ -0,0 +1,94 @@
import { TSESTree } from '@typescript-eslint/types';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
createRule,
} from '../utils/index.js';
export const NodeUsableAsToolRule = createRule({
name: 'node-usable-as-tool',
meta: {
type: 'problem',
docs: {
description: 'Ensure node classes have usableAsTool property',
},
messages: {
missingUsableAsTool:
'Node class should have usableAsTool property. When in doubt, set it to true.',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty) {
return;
}
const descriptionValue = descriptionProperty.value;
if (descriptionValue?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
return;
}
const usableAsToolProperty = findObjectProperty(descriptionValue, 'usableAsTool');
const outputsProperty = findObjectProperty(descriptionValue, 'outputs');
const inputsProperty = findObjectProperty(descriptionValue, 'inputs');
if (
outputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression &&
inputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression
) {
const isAiOutput = outputsProperty?.value?.elements?.some((element) => {
const isAiOutputEnum =
element?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
element?.object?.type === TSESTree.AST_NODE_TYPES.Identifier &&
element?.object?.name === 'NodeConnectionTypes' &&
element?.property?.type === TSESTree.AST_NODE_TYPES.Identifier &&
element?.property?.name !== 'Main';
const isAiOutputLiteral =
element?.type === TSESTree.AST_NODE_TYPES.Literal && element?.value !== 'main';
return isAiOutputEnum || isAiOutputLiteral;
});
const isEmptyInputs = inputsProperty?.value?.elements?.length === 0;
if (isAiOutput && isEmptyInputs) {
return;
}
}
if (!usableAsToolProperty) {
context.report({
node,
messageId: 'missingUsableAsTool',
fix(fixer) {
if (descriptionValue?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
const properties = descriptionValue.properties;
if (properties.length === 0) {
const openBrace = descriptionValue.range[0] + 1;
return fixer.insertTextAfterRange(
[openBrace, openBrace],
'\n\t\tusableAsTool: true,',
);
} else {
const lastProperty = properties.at(-1);
if (lastProperty) {
return fixer.insertTextAfter(lastProperty, ',\n\t\tusableAsTool: true');
}
}
}
return null;
},
});
}
},
};
},
});
@@ -0,0 +1,189 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { PackageNameConventionRule } from './package-name-convention.js';
const ruleTester = new RuleTester();
ruleTester.run('package-name-convention', PackageNameConventionRule, {
valid: [
{
name: 'valid unscoped package name',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
},
{
name: 'valid unscoped package name with dashes',
filename: 'package.json',
code: '{ "name": "n8n-nodes-my-service", "version": "1.0.0" }',
},
{
name: 'valid scoped package name',
filename: 'package.json',
code: '{ "name": "@mycompany/n8n-nodes-example", "version": "1.0.0" }',
},
{
name: 'valid scoped package name with dashes',
filename: 'package.json',
code: '{ "name": "@author/n8n-nodes-service", "version": "1.0.0" }',
},
{
name: 'object without name property',
filename: 'package.json',
code: '{ "version": "1.0.0", "description": "test" }',
},
{
name: 'non-package.json file ignored',
filename: 'some-config.json',
code: '{ "name": "my-config", "type": "config" }',
},
{
name: 'nested name fields should be ignored - only top-level name matters',
filename: 'package.json',
code: `{
"name": "n8n-nodes-example",
"version": "1.0.0",
"dependencies": {
"name": "invalid-nested-name"
},
"scripts": {
"name": "another-invalid-name"
},
"author": {
"name": "John Doe"
}
}`,
},
{
name: 'deeply nested name fields should be ignored',
filename: 'package.json',
code: `{
"name": "@author/n8n-nodes-service",
"version": "1.0.0",
"config": {
"nested": {
"deeply": {
"name": "very-invalid-name"
}
}
},
"repository": {
"type": "git",
"url": "https://github.com/user/repo",
"directory": {
"name": "bad-name"
}
}
}`,
},
],
invalid: [
{
name: 'invalid package name - generic',
filename: 'package.json',
code: '{ "name": "my-package", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: 'my-package' },
suggestions: [
{
messageId: 'renameTo',
data: { suggestedName: 'n8n-nodes-my-package' },
output: '{ "name": "n8n-nodes-my-package", "version": "1.0.0" }',
},
],
},
],
},
{
name: 'invalid package name - missing nodes',
filename: 'package.json',
code: '{ "name": "n8n-example", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: 'n8n-example' },
suggestions: [
{
messageId: 'renameTo',
data: { suggestedName: 'n8n-nodes-example' },
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
},
],
},
],
},
{
name: 'invalid scoped package name',
filename: 'package.json',
code: '{ "name": "@company/example-nodes", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: '@company/example-nodes' },
suggestions: [
{
messageId: 'renameTo',
data: { suggestedName: '@company/n8n-nodes-example' },
output: '{ "name": "@company/n8n-nodes-example", "version": "1.0.0" }',
},
],
},
],
},
{
name: 'invalid package name - wrong order',
filename: 'package.json',
code: '{ "name": "nodes-n8n-example", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: 'nodes-n8n-example' },
suggestions: [
{
messageId: 'renameTo',
data: { suggestedName: 'n8n-nodes-example' },
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
},
],
},
],
},
{
name: 'empty package name',
filename: 'package.json',
code: '{ "name": "", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: '' },
suggestions: [],
},
],
},
{
name: 'incomplete package name with missing suffix',
filename: 'package.json',
code: '{ "name": "n8n-nodes-", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: 'n8n-nodes-' },
suggestions: [],
},
],
},
{
name: 'incomplete scoped package name with missing suffix',
filename: 'package.json',
code: '{ "name": "@company/n8n-nodes-", "version": "1.0.0" }',
errors: [
{
messageId: 'invalidPackageName',
data: { packageName: '@company/n8n-nodes-' },
suggestions: [],
},
],
},
],
});
@@ -0,0 +1,102 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
import { createRule, findJsonProperty } from '../utils/index.js';
export const PackageNameConventionRule = createRule({
name: 'package-name-convention',
meta: {
type: 'problem',
docs: {
description: 'Enforce correct package naming convention for n8n community nodes',
},
messages: {
renameTo: "Rename to '{{suggestedName}}'",
invalidPackageName:
'Package name "{{ packageName }}" must follow the convention "n8n-nodes-[PACKAGE-NAME]" or "@[AUTHOR]/n8n-nodes-[PACKAGE-NAME]"',
},
schema: [],
hasSuggestions: true,
},
defaultOptions: [],
create(context) {
if (!context.filename.endsWith('package.json')) {
return {};
}
return {
ObjectExpression(node: TSESTree.ObjectExpression) {
if (node.parent?.type === AST_NODE_TYPES.Property) {
return;
}
const nameProperty = findJsonProperty(node, 'name');
if (!nameProperty) {
return;
}
if (nameProperty.value.type !== AST_NODE_TYPES.Literal) {
return;
}
const packageName = nameProperty.value.value;
const packageNameStr = typeof packageName === 'string' ? packageName : null;
if (!packageNameStr || !isValidPackageName(packageNameStr)) {
const suggestions: ReportSuggestionArray<'invalidPackageName' | 'renameTo'> = [];
// Generate package name suggestions if we have a valid string
if (packageNameStr) {
const suggestedNames = generatePackageNameSuggestions(packageNameStr);
for (const suggestedName of suggestedNames) {
suggestions.push({
messageId: 'renameTo',
data: { suggestedName },
fix(fixer) {
return fixer.replaceText(nameProperty.value, `"${suggestedName}"`);
},
});
}
}
context.report({
node: nameProperty,
messageId: 'invalidPackageName',
data: {
packageName: packageNameStr ?? 'undefined',
},
suggest: suggestions,
});
}
},
};
},
});
function isValidPackageName(name: string): boolean {
const unscoped = /^n8n-nodes-.+$/;
const scoped = /^@.+\/n8n-nodes-.+$/;
return unscoped.test(name) || scoped.test(name);
}
function generatePackageNameSuggestions(invalidName: string): string[] {
const cleanName = (name: string) => {
return name
.replace(/^nodes?-?n8n-?/, '')
.replace(/^n8n-/, '')
.replace(/^nodes?-?/, '')
.replace(/^node-/, '')
.replace(/-nodes$/, '');
};
if (invalidName.startsWith('@')) {
const [scope, packagePart] = invalidName.split('/');
const clean = cleanName(packagePart ?? '');
return clean ? [`${scope}/n8n-nodes-${clean}`] : [];
}
const clean = cleanName(invalidName);
return clean ? [`n8n-nodes-${clean}`] : [];
}
@@ -0,0 +1,217 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
const ruleTester = new RuleTester();
ruleTester.run('resource-operation-pattern', ResourceOperationPatternRule, {
valid: [
{
name: 'node with resources and operations (good pattern)',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Project', value: 'project' }
],
default: 'user'
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get', value: 'get' },
{ name: 'Create', value: 'create' },
{ name: 'Update', value: 'update' },
{ name: 'Delete', value: 'delete' }
],
default: 'get'
}
]
};
}
`,
},
{
name: 'node without operations property',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
default: ''
}
]
};
}
`,
},
{
name: 'non-node class ignored',
filename: '/tmp/TestNode.node.ts',
code: `
export class NotANode {
description = {
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get', value: 'get' },
{ name: 'Create', value: 'create' }
],
default: 'get'
}
]
};
}
`,
},
{
name: 'node with exactly 5 operations without resources (allowed)',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get', value: 'get' },
{ name: 'Create', value: 'create' },
{ name: 'Update', value: 'update' },
{ name: 'Delete', value: 'delete' },
{ name: 'List', value: 'list' }
],
default: 'get'
}
]
};
}
`,
},
],
invalid: [
{
name: 'node with exactly 6 operations without resources (error)',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get', value: 'get' },
{ name: 'Create', value: 'create' },
{ name: 'Update', value: 'update' },
{ name: 'Delete', value: 'delete' },
{ name: 'List', value: 'list' },
{ name: 'Search', value: 'search' }
],
default: 'get'
}
]
};
}
`,
errors: [
{
messageId: 'tooManyOperationsWithoutResources',
data: { operationCount: '6' },
},
],
},
{
name: 'node with many operations without resources (error)',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['output'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Get User', value: 'getUser' },
{ name: 'Create User', value: 'createUser' },
{ name: 'Update User', value: 'updateUser' },
{ name: 'Delete User', value: 'deleteUser' },
{ name: 'List Users', value: 'listUsers' },
{ name: 'Get Project', value: 'getProject' },
{ name: 'Create Project', value: 'createProject' },
{ name: 'Update Project', value: 'updateProject' }
],
default: 'getUser'
}
]
};
}
`,
errors: [
{
messageId: 'tooManyOperationsWithoutResources',
data: { operationCount: '8' },
},
],
},
],
});
@@ -0,0 +1,104 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
export const ResourceOperationPatternRule = createRule({
name: 'resource-operation-pattern',
meta: {
type: 'problem',
docs: {
description: 'Enforce proper resource/operation pattern for better UX in n8n nodes',
},
messages: {
tooManyOperationsWithoutResources:
'Node has {{ operationCount }} operations without resources. Use resources to organize operations when there are more than 5 operations.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
if (
!propertiesProperty?.value ||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
) {
return;
}
const propertiesArray = propertiesProperty.value;
let hasResources = false;
let operationCount = 0;
let operationNode: TSESTree.Node | null = null;
for (const property of propertiesArray.elements) {
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
continue;
}
const nameProperty = findObjectProperty(property, 'name');
const typeProperty = findObjectProperty(property, 'type');
const name = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
const type = typeProperty ? getStringLiteralValue(typeProperty.value) : null;
if (!name || !type) {
continue;
}
if (name === 'resource' && type === 'options') {
hasResources = true;
}
if (name === 'operation' && type === 'options') {
operationNode = property;
const optionsProperty = findObjectProperty(property, 'options');
if (optionsProperty?.value?.type === AST_NODE_TYPES.ArrayExpression) {
operationCount = optionsProperty.value.elements.length;
}
}
}
if (operationCount > 5 && !hasResources && operationNode) {
context.report({
node: operationNode,
messageId: 'tooManyOperationsWithoutResources',
data: {
operationCount: operationCount.toString(),
},
});
}
};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty) {
return;
}
analyzeNodeDescription(descriptionProperty.value);
},
};
},
});
@@ -0,0 +1,220 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { distance } from 'fastest-levenshtein';
function implementsInterface(node: TSESTree.ClassDeclaration, interfaceName: string): boolean {
return (
node.implements?.some(
(impl) =>
impl.type === AST_NODE_TYPES.TSClassImplements &&
impl.expression.type === AST_NODE_TYPES.Identifier &&
impl.expression.name === interfaceName,
) ?? false
);
}
export function isNodeTypeClass(node: TSESTree.ClassDeclaration): boolean {
if (implementsInterface(node, 'INodeType')) {
return true;
}
if (node.superClass?.type === AST_NODE_TYPES.Identifier && node.superClass.name === 'Node') {
return true;
}
return false;
}
export function isCredentialTypeClass(node: TSESTree.ClassDeclaration): boolean {
return implementsInterface(node, 'ICredentialType');
}
export function findClassProperty(
node: TSESTree.ClassDeclaration,
propertyName: string,
): TSESTree.PropertyDefinition | null {
const property = node.body.body.find(
(member) =>
member.type === AST_NODE_TYPES.PropertyDefinition &&
member.key?.type === AST_NODE_TYPES.Identifier &&
member.key.name === propertyName,
);
return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
}
export function findObjectProperty(
obj: TSESTree.ObjectExpression,
propertyName: string,
): TSESTree.Property | null {
const property = obj.properties.find(
(prop) =>
prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Identifier &&
prop.key.name === propertyName,
);
return property?.type === AST_NODE_TYPES.Property ? property : null;
}
export function getLiteralValue(node: TSESTree.Node | null): string | boolean | number | null {
if (node?.type === AST_NODE_TYPES.Literal) {
return node.value as string | boolean | number | null;
}
return null;
}
export function getStringLiteralValue(node: TSESTree.Node | null): string | null {
const value = getLiteralValue(node);
return typeof value === 'string' ? value : null;
}
export function getModulePath(node: TSESTree.Node | null): string | null {
const stringValue = getStringLiteralValue(node);
if (stringValue) {
return stringValue;
}
if (
node?.type === AST_NODE_TYPES.TemplateLiteral &&
node.expressions.length === 0 &&
node.quasis.length === 1
) {
return node.quasis[0]?.value.cooked ?? null;
}
return null;
}
export function getBooleanLiteralValue(node: TSESTree.Node | null): boolean | null {
const value = getLiteralValue(node);
return typeof value === 'boolean' ? value : null;
}
export function findJsonProperty(
obj: TSESTree.ObjectExpression,
propertyName: string,
): TSESTree.Property | null {
const property = obj.properties.find(
(prop) =>
prop.type === AST_NODE_TYPES.Property &&
prop.key.type === AST_NODE_TYPES.Literal &&
prop.key.value === propertyName,
);
return property?.type === AST_NODE_TYPES.Property ? property : null;
}
export function findArrayLiteralProperty(
obj: TSESTree.ObjectExpression,
propertyName: string,
): TSESTree.ArrayExpression | null {
const property = findObjectProperty(obj, propertyName);
if (property?.value.type === AST_NODE_TYPES.ArrayExpression) {
return property.value;
}
return null;
}
export function hasArrayLiteralValue(
node: TSESTree.PropertyDefinition,
searchValue: string,
): boolean {
if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return false;
return node.value.elements.some(
(element) =>
element?.type === AST_NODE_TYPES.Literal &&
typeof element.value === 'string' &&
element.value === searchValue,
);
}
export function getTopLevelObjectInJson(
node: TSESTree.ObjectExpression,
): TSESTree.ObjectExpression | null {
if (node.parent?.type === AST_NODE_TYPES.Property) {
return null;
}
return node;
}
export function isFileType(filename: string, extension: string): boolean {
return filename.endsWith(extension);
}
export function isDirectRequireCall(node: TSESTree.CallExpression): boolean {
return (
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === 'require' &&
node.arguments.length > 0
);
}
export function isRequireMemberCall(node: TSESTree.CallExpression): boolean {
return (
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
node.callee.object.name === 'require' &&
node.arguments.length > 0
);
}
export function extractCredentialInfoFromArray(
element: TSESTree.ArrayExpression['elements'][number],
): { name: string; testedBy?: string; node: TSESTree.Node } | null {
if (!element) return null;
const stringValue = getStringLiteralValue(element);
if (stringValue) {
return { name: stringValue, node: element };
}
if (element.type === AST_NODE_TYPES.ObjectExpression) {
const nameProperty = findObjectProperty(element, 'name');
const testedByProperty = findObjectProperty(element, 'testedBy');
if (nameProperty) {
const nameValue = getStringLiteralValue(nameProperty.value);
const testedByValue = testedByProperty
? getStringLiteralValue(testedByProperty.value)
: undefined;
if (nameValue) {
return {
name: nameValue,
testedBy: testedByValue ?? undefined,
node: nameProperty.value,
};
}
}
}
return null;
}
export function extractCredentialNameFromArray(
element: TSESTree.ArrayExpression['elements'][number],
): { name: string; node: TSESTree.Node } | null {
const info = extractCredentialInfoFromArray(element);
return info ? { name: info.name, node: info.node } : null;
}
export function findSimilarStrings(
target: string,
candidates: Set<string>,
maxDistance: number = 3,
maxResults: number = 3,
): string[] {
const matches: Array<{ name: string; distance: number }> = [];
for (const candidate of candidates) {
const levenshteinDistance = distance(target.toLowerCase(), candidate.toLowerCase());
if (levenshteinDistance <= maxDistance) {
matches.push({ name: candidate, distance: levenshteinDistance });
}
}
return matches
.sort((a, b) => a.distance - b.distance)
.slice(0, maxResults)
.map((match) => match.name);
}
@@ -0,0 +1,294 @@
import type { TSESTree } from '@typescript-eslint/typescript-estree';
import { parse, simpleTraverse, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import * as path from 'node:path';
import { dirname, parse as parsePath } from 'node:path';
import {
isCredentialTypeClass,
isNodeTypeClass,
findClassProperty,
getStringLiteralValue,
findArrayLiteralProperty,
extractCredentialInfoFromArray,
findSimilarStrings,
} from './ast-utils.js';
/**
* Checks if the given childPath is contained within the parentPath. Resolves
* the paths before comparing them, so that relative paths are also supported.
*/
export function isContainedWithin(parentPath: string, childPath: string): boolean {
parentPath = path.resolve(parentPath);
childPath = path.resolve(childPath);
if (parentPath === childPath) {
return true;
}
return childPath.startsWith(parentPath + path.sep);
}
/**
* Joins the given paths to the parentPath, ensuring that the resulting path
* is still contained within the parentPath. If not, it throws an error to
* prevent path traversal vulnerabilities.
*
* @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
*/
export function safeJoinPath(parentPath: string, ...paths: string[]): string {
const candidate = path.join(parentPath, ...paths);
if (!isContainedWithin(parentPath, candidate)) {
throw new Error(
`Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`,
);
}
return candidate;
}
export function findPackageJson(startPath: string): string | null {
let currentDir = path.dirname(startPath);
while (parsePath(currentDir).dir !== parsePath(currentDir).root) {
const testPath = safeJoinPath(currentDir, 'package.json');
if (fileExistsWithCaseSync(testPath)) {
return testPath;
}
currentDir = dirname(currentDir);
}
return null;
}
interface PackageJsonN8n {
credentials?: string[];
nodes?: string[];
[key: string]: unknown;
}
function isValidPackageJson(obj: unknown): obj is { n8n?: PackageJsonN8n } {
return typeof obj === 'object' && obj !== null;
}
function readPackageJsonN8n(packageJsonPath: string): PackageJsonN8n {
try {
const content = readFileSync(packageJsonPath, 'utf8');
const parsed: unknown = JSON.parse(content);
if (isValidPackageJson(parsed)) {
return parsed.n8n ?? {};
}
return {};
} catch {
return {};
}
}
function resolveN8nFilePaths(packageJsonPath: string, filePaths: string[]): string[] {
const packageDir = dirname(packageJsonPath);
const resolvedFiles: string[] = [];
for (const filePath of filePaths) {
const sourcePath = filePath.replace(/^dist\//, '').replace(/\.js$/, '.ts');
const fullSourcePath = safeJoinPath(packageDir, sourcePath);
if (existsSync(fullSourcePath)) {
resolvedFiles.push(fullSourcePath);
}
}
return resolvedFiles;
}
export function readPackageJsonCredentials(packageJsonPath: string): Set<string> {
const n8nConfig = readPackageJsonN8n(packageJsonPath);
const credentialPaths = n8nConfig.credentials ?? [];
const credentialFiles = resolveN8nFilePaths(packageJsonPath, credentialPaths);
const credentialNames: string[] = [];
for (const credentialFile of credentialFiles) {
try {
const credentialName = extractCredentialNameFromFile(credentialFile);
if (credentialName) {
credentialNames.push(credentialName);
}
} catch {
// Silently continue if file can't be parsed
}
}
return new Set(credentialNames);
}
export function extractCredentialNameFromFile(credentialFilePath: string): string | null {
try {
const sourceCode = readFileSync(credentialFilePath, 'utf8');
const ast = parse(sourceCode, {
jsx: false,
range: true,
});
let credentialName: string | null = null;
simpleTraverse(ast, {
enter(node: TSESTree.Node) {
if (node.type === AST_NODE_TYPES.ClassDeclaration && isCredentialTypeClass(node)) {
const nameProperty = findClassProperty(node, 'name');
if (nameProperty) {
const nameValue = getStringLiteralValue(nameProperty.value);
if (nameValue) {
credentialName = nameValue;
}
}
}
},
});
return credentialName;
} catch {
return null;
}
}
export function validateIconPath(
iconPath: string,
baseDir: string,
): {
isValid: boolean;
isFile: boolean;
isSvg: boolean;
exists: boolean;
} {
const isFile = iconPath.startsWith('file:');
const relativePath = iconPath.replace(/^file:/, '');
const isSvg = relativePath.endsWith('.svg');
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const fullPath = path.join(baseDir, relativePath);
const exists = fileExistsWithCaseSync(fullPath);
return {
isValid: isFile && isSvg && exists,
isFile,
isSvg,
exists,
};
}
export function readPackageJsonNodes(packageJsonPath: string): string[] {
const n8nConfig = readPackageJsonN8n(packageJsonPath);
const nodePaths = n8nConfig.nodes ?? [];
return resolveN8nFilePaths(packageJsonPath, nodePaths);
}
export function areAllCredentialUsagesTestedByNodes(
credentialName: string,
packageDir: string,
): boolean {
const packageJsonPath = safeJoinPath(packageDir, 'package.json');
if (!existsSync(packageJsonPath)) {
return false;
}
const nodeFiles = readPackageJsonNodes(packageJsonPath);
let hasAnyCredentialUsage = false;
for (const nodeFile of nodeFiles) {
const result = checkCredentialUsageInFile(nodeFile, credentialName);
if (result.hasUsage) {
hasAnyCredentialUsage = true;
if (!result.allTestedBy) {
return false; // Found usage without testedBy
}
}
}
return hasAnyCredentialUsage;
}
function checkCredentialUsageInFile(
nodeFile: string,
credentialName: string,
): { hasUsage: boolean; allTestedBy: boolean } {
try {
const sourceCode = readFileSync(nodeFile, 'utf8');
const ast = parse(sourceCode, { jsx: false, range: true });
let hasUsage = false;
let allTestedBy = true;
simpleTraverse(ast, {
enter(node: TSESTree.Node) {
if (node.type === AST_NODE_TYPES.ClassDeclaration && isNodeTypeClass(node)) {
const descriptionProperty = findClassProperty(node, 'description');
if (
!descriptionProperty?.value ||
descriptionProperty.value.type !== AST_NODE_TYPES.ObjectExpression
) {
return;
}
const credentialsArray = findArrayLiteralProperty(
descriptionProperty.value,
'credentials',
);
if (!credentialsArray) {
return;
}
for (const element of credentialsArray.elements) {
const credentialInfo = extractCredentialInfoFromArray(element);
if (credentialInfo?.name === credentialName) {
hasUsage = true;
if (!credentialInfo.testedBy) {
allTestedBy = false;
}
}
}
}
},
});
return { hasUsage, allTestedBy };
} catch {
return { hasUsage: false, allTestedBy: true };
}
}
function fileExistsWithCaseSync(filePath: string): boolean {
try {
const dir = path.dirname(filePath);
const file = path.basename(filePath);
const files = new Set(readdirSync(dir));
return files.has(file);
} catch {
return false;
}
}
export function findSimilarSvgFiles(targetPath: string, baseDir: string): string[] {
try {
const targetFileName = path.basename(targetPath, path.extname(targetPath));
const targetDir = path.dirname(targetPath);
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const searchDir = path.join(baseDir, targetDir);
if (!existsSync(searchDir)) {
return [];
}
const files = readdirSync(searchDir);
const svgFileNames = files
.filter((file) => file.endsWith('.svg'))
.map((file) => path.basename(file, '.svg'));
const candidateNames = new Set(svgFileNames);
const similarNames = findSimilarStrings(targetFileName, candidateNames);
return similarNames.map((name) => path.join(targetDir, `${name}.svg`));
} catch {
return [];
}
}
@@ -0,0 +1,3 @@
export * from './ast-utils.js';
export * from './file-utils.js';
export * from './rule-creator.js';
@@ -0,0 +1,6 @@
import { ESLintUtils } from '@typescript-eslint/utils';
const REPO_URL = 'https://github.com/n8n-io/n8n';
const DOCS_PATH = 'blob/master/packages/@n8n/eslint-plugin-community-nodes/docs/rules';
export const createRule = ESLintUtils.RuleCreator((name) => `${REPO_URL}/${DOCS_PATH}/${name}.md`);