refactor(core): Start modularizing the community packages feature (#17757)

This commit is contained in:
Iván Ovejero
2025-07-31 13:55:38 +02:00
committed by GitHub
parent 1ed8239625
commit 1d31e6a0c4
35 changed files with 171 additions and 172 deletions

View File

@@ -8,5 +8,12 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true "emitDecoratorMetadata": true
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"],
"references": [
{ "path": "../../workflow/tsconfig.build.cjs.json" },
{ "path": "../config/tsconfig.build.json" },
{ "path": "../constants/tsconfig.build.json" },
{ "path": "../decorators/tsconfig.build.json" },
{ "path": "../di/tsconfig.build.json" }
]
} }

View File

@@ -20,6 +20,7 @@ export const LOG_SCOPES = [
'workflow-activation', 'workflow-activation',
'ssh-client', 'ssh-client',
'cron', 'cron',
'community-nodes',
] as const; ] as const;
export type LogScope = (typeof LOG_SCOPES)[number]; export type LogScope = (typeof LOG_SCOPES)[number];

View File

@@ -1,4 +1,4 @@
import { Config, Env, Nested } from '../decorators'; import { Config, Env } from '../decorators';
function isStringArray(input: unknown): input is string[] { function isStringArray(input: unknown): input is string[] {
return Array.isArray(input) && input.every((item) => typeof item === 'string'); return Array.isArray(input) && input.every((item) => typeof item === 'string');
@@ -20,33 +20,6 @@ class JsonStringArray extends Array<string> {
} }
} }
@Config
class CommunityPackagesConfig {
/** Whether to enable community packages */
@Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true;
/** NPM registry URL to pull community packages from */
@Env('N8N_COMMUNITY_PACKAGES_REGISTRY')
registry: string = 'https://registry.npmjs.org';
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;
/** Whether to block installation of not verified packages */
@Env('N8N_UNVERIFIED_PACKAGES_ENABLED')
unverifiedEnabled: boolean = true;
/** Whether to enable and show search suggestion of packages verified by n8n */
@Env('N8N_VERIFIED_PACKAGES_ENABLED')
verifiedEnabled: boolean = true;
/** Whether to load community packages */
@Env('N8N_COMMUNITY_PACKAGES_PREVENT_LOADING')
preventLoading: boolean = false;
}
@Config @Config
export class NodesConfig { export class NodesConfig {
/** Node types to load. Includes all if unspecified. @example '["n8n-nodes-base.hackerNews"]' */ /** Node types to load. Includes all if unspecified. @example '["n8n-nodes-base.hackerNews"]' */
@@ -64,7 +37,4 @@ export class NodesConfig {
/** Whether to enable Python execution on the Code node. */ /** Whether to enable Python execution on the Code node. */
@Env('N8N_PYTHON_ENABLED') @Env('N8N_PYTHON_ENABLED')
pythonEnabled: boolean = true; pythonEnabled: boolean = true;
@Nested
communityPackages: CommunityPackagesConfig;
} }

View File

@@ -138,14 +138,6 @@ describe('GlobalConfig', () => {
files: [], files: [],
}, },
nodes: { nodes: {
communityPackages: {
enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false,
unverifiedEnabled: true,
verifiedEnabled: true,
preventLoading: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger', errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [], include: [],
exclude: [], exclude: [],

View File

@@ -3,11 +3,12 @@ import { GlobalConfig } from '@n8n/config';
import type { User, WorkflowEntity } from '@n8n/db'; import type { User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository, DbConnection } from '@n8n/db'; import { WorkflowRepository, DbConnection } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm'; import { type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; import type { IRun } from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { DeprecationService } from '@/deprecation/deprecation.service'; import { DeprecationService } from '@/deprecation/deprecation.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
@@ -33,6 +34,7 @@ mockInstance(MessageEventBus);
const posthogClient = mockInstance(PostHogClient); const posthogClient = mockInstance(PostHogClient);
const telemetryEventRelay = mockInstance(TelemetryEventRelay); const telemetryEventRelay = mockInstance(TelemetryEventRelay);
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection); const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined); dbConnection.init.mockResolvedValue(undefined);
@@ -69,7 +71,7 @@ test('should start a task runner when task runners are enabled', async () => {
GlobalConfig, GlobalConfig,
mock<GlobalConfig>({ mock<GlobalConfig>({
taskRunners: { enabled: true }, taskRunners: { enabled: true },
nodes: { communityPackages: { enabled: false } }, nodes: {},
}), }),
); );

View File

@@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; import type { IRun } from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { DeprecationService } from '@/deprecation/deprecation.service'; import { DeprecationService } from '@/deprecation/deprecation.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
@@ -32,6 +33,7 @@ mockInstance(MessageEventBus);
const posthogClient = mockInstance(PostHogClient); const posthogClient = mockInstance(PostHogClient);
const telemetryEventRelay = mockInstance(TelemetryEventRelay); const telemetryEventRelay = mockInstance(TelemetryEventRelay);
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection); const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined); dbConnection.init.mockResolvedValue(undefined);
@@ -63,7 +65,7 @@ test('should start a task runner when task runners are enabled', async () => {
GlobalConfig, GlobalConfig,
mock<GlobalConfig>({ mock<GlobalConfig>({
taskRunners: { enabled: true }, taskRunners: { enabled: true },
nodes: { communityPackages: { enabled: false } }, nodes: {},
}), }),
); );

View File

@@ -37,6 +37,7 @@ import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog'; import { PostHogClient } from '@/posthog';
import { ShutdownService } from '@/shutdown/shutdown.service'; import { ShutdownService } from '@/shutdown/shutdown.service';
import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
export abstract class BaseCommand<F = never> { export abstract class BaseCommand<F = never> {
readonly flags: F; readonly flags: F;
@@ -132,9 +133,11 @@ export abstract class BaseCommand<F = never> {
); );
} }
const { communityPackages } = this.globalConfig.nodes; const communityPackagesConfig = Container.get(CommunityPackagesConfig);
if (communityPackages.enabled && this.needsCommunityPackages) { if (communityPackagesConfig.enabled && this.needsCommunityPackages) {
const { CommunityPackagesService } = await import('@/services/community-packages.service'); const { CommunityPackagesService } = await import(
'@/community-packages/community-packages.service'
);
await Container.get(CommunityPackagesService).init(); await Container.get(CommunityPackagesService).init();
} }

View File

@@ -5,7 +5,7 @@ import { Container } from '@n8n/di';
import { z } from 'zod'; import { z } from 'zod';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';

View File

@@ -33,6 +33,7 @@ import { ExecutionsPruningService } from '@/services/pruning/executions-pruning.
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { WaitTracker } from '@/wait-tracker'; import { WaitTracker } from '@/wait-tracker';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const open = require('open'); const open = require('open');
@@ -178,14 +179,14 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
} }
const { flags } = this; const { flags } = this;
const { communityPackages } = this.globalConfig.nodes; const communityPackagesConfig = Container.get(CommunityPackagesConfig);
// cli flag overrides the config env variable // cli flag overrides the config env variable
if (flags.reinstallMissingPackages) { if (flags.reinstallMissingPackages) {
if (communityPackages.enabled) { if (communityPackagesConfig.enabled) {
this.logger.warn( this.logger.warn(
'`--reinstallMissingPackages` is deprecated: Please use the env variable `N8N_REINSTALL_MISSING_PACKAGES` instead', '`--reinstallMissingPackages` is deprecated: Please use the env variable `N8N_REINSTALL_MISSING_PACKAGES` instead',
); );
communityPackages.reinstallMissing = true; communityPackagesConfig.reinstallMissing = true;
} else { } else {
this.logger.warn( this.logger.warn(
'`--reinstallMissingPackages` was passed, but community packages are disabled', '`--reinstallMissingPackages` was passed, but community packages are disabled',

View File

@@ -1,6 +1,6 @@
import { inProduction } from '@n8n/backend-common'; import { inProduction } from '@n8n/backend-common';
import { getCommunityNodeTypes } from '../../utils/community-node-types-utils'; import { getCommunityNodeTypes } from '../community-node-types-utils';
import { CommunityNodeTypesService } from '../community-node-types.service'; import { CommunityNodeTypesService } from '../community-node-types.service';
jest.mock('@n8n/backend-common', () => ({ jest.mock('@n8n/backend-common', () => ({
@@ -8,13 +8,13 @@ jest.mock('@n8n/backend-common', () => ({
inProduction: jest.fn().mockReturnValue(false), inProduction: jest.fn().mockReturnValue(false),
})); }));
jest.mock('../../utils/community-node-types-utils', () => ({ jest.mock('../community-node-types-utils', () => ({
getCommunityNodeTypes: jest.fn().mockResolvedValue([]), getCommunityNodeTypes: jest.fn().mockResolvedValue([]),
})); }));
describe('CommunityNodeTypesService', () => { describe('CommunityNodeTypesService', () => {
let service: CommunityNodeTypesService; let service: CommunityNodeTypesService;
let globalConfigMock: any; let configMock: any;
let communityPackagesServiceMock: any; let communityPackagesServiceMock: any;
let loggerMock: any; let loggerMock: any;
@@ -24,21 +24,13 @@ describe('CommunityNodeTypesService', () => {
delete process.env.ENVIRONMENT; delete process.env.ENVIRONMENT;
loggerMock = { error: jest.fn() }; loggerMock = { error: jest.fn() };
globalConfigMock = { configMock = {
nodes: { enabled: true,
communityPackages: { verifiedEnabled: true,
enabled: true,
verifiedEnabled: true,
},
},
}; };
communityPackagesServiceMock = {}; communityPackagesServiceMock = {};
service = new CommunityNodeTypesService( service = new CommunityNodeTypesService(loggerMock, configMock, communityPackagesServiceMock);
loggerMock,
globalConfigMock,
communityPackagesServiceMock,
);
}); });
describe('fetchNodeTypes', () => { describe('fetchNodeTypes', () => {

View File

@@ -2,13 +2,13 @@ import type { CommunityNodeType } from '@n8n/api-types';
import type { InstalledPackages } from '@n8n/db'; import type { InstalledPackages } from '@n8n/db';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { CommunityPackagesController } from '@/controllers/community-packages.controller'; import { CommunityPackagesController } from '@/community-packages/community-packages.controller';
import type { NodeRequest } from '@/requests'; import type { NodeRequest } from '@/requests';
import type { EventService } from '../../events/event.service'; import type { EventService } from '../../events/event.service';
import type { Push } from '../../push'; import type { Push } from '../../push';
import type { CommunityNodeTypesService } from '../../services/community-node-types.service'; import type { CommunityNodeTypesService } from '../community-node-types.service';
import type { CommunityPackagesService } from '../../services/community-packages.service'; import type { CommunityPackagesService } from '../community-packages.service';
describe('CommunityPackagesController', () => { describe('CommunityPackagesController', () => {
const push = mock<Push>(); const push = mock<Push>();

View File

@@ -1,6 +1,5 @@
import type { Logger } from '@n8n/backend-common'; import type { Logger } from '@n8n/backend-common';
import { randomName, mockInstance } from '@n8n/backend-test-utils'; import { randomName, mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { import {
InstalledNodes, InstalledNodes,
@@ -17,6 +16,7 @@ import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import type { PublicInstalledPackage } from 'n8n-workflow'; import type { PublicInstalledPackage } from 'n8n-workflow';
import { join } from 'node:path'; import { join } from 'node:path';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { import {
NODE_PACKAGE_PREFIX, NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS, NPM_COMMAND_TOKENS,
@@ -24,14 +24,15 @@ import {
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
} from '@/constants'; } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { CommunityPackages } from '@/interfaces';
import type { License } from '@/license'; import type { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { Publisher } from '@/scaling/pubsub/publisher.service'; import type { Publisher } from '@/scaling/pubsub/publisher.service';
import { CommunityPackagesService } from '@/services/community-packages.service';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants'; import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
import { mockPackageName, mockPackagePair } from '@test-integration/utils'; import { mockPackageName, mockPackagePair } from '@test-integration/utils';
import type { CommunityPackagesConfig } from '../community-packages.config';
import type { CommunityPackages } from '../community-packages.types';
jest.mock('fs/promises'); jest.mock('fs/promises');
jest.mock('child_process'); jest.mock('child_process');
jest.mock('axios'); jest.mock('axios');
@@ -46,14 +47,10 @@ const execMock = ((...args) => {
describe('CommunityPackagesService', () => { describe('CommunityPackagesService', () => {
const license = mock<License>(); const license = mock<License>();
const globalConfig = mock<GlobalConfig>({ const config = mock<CommunityPackagesConfig>({
nodes: { reinstallMissing: false,
communityPackages: { registry: 'some.random.host',
reinstallMissing: false, unverifiedEnabled: true,
registry: 'some.random.host',
unverifiedEnabled: true,
},
},
}); });
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>(); const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const installedNodesRepository = mockInstance(InstalledNodesRepository); const installedNodesRepository = mockInstance(InstalledNodesRepository);
@@ -72,7 +69,7 @@ describe('CommunityPackagesService', () => {
loadNodesAndCredentials, loadNodesAndCredentials,
publisher, publisher,
license, license,
globalConfig, config,
); );
beforeEach(() => { beforeEach(() => {
@@ -384,7 +381,7 @@ describe('CommunityPackagesService', () => {
const testBlockDownloadDir = instanceSettings.nodesDownloadDir; const testBlockDownloadDir = instanceSettings.nodesDownloadDir;
const testBlockPackageDir = `${testBlockDownloadDir}/node_modules/${PACKAGE_NAME}`; const testBlockPackageDir = `${testBlockDownloadDir}/node_modules/${PACKAGE_NAME}`;
const testBlockTarballName = `${PACKAGE_NAME}-latest.tgz`; const testBlockTarballName = `${PACKAGE_NAME}-latest.tgz`;
const testBlockRegistry = globalConfig.nodes.communityPackages.registry; const testBlockRegistry = config.registry;
const testBlockNpmInstallArgs = [ const testBlockNpmInstallArgs = [
'--audit=false', '--audit=false',
'--fund=false', '--fund=false',
@@ -519,8 +516,8 @@ describe('CommunityPackagesService', () => {
describe('installPackage', () => { describe('installPackage', () => {
test('should throw when installation of not vetted packages is forbidden', async () => { test('should throw when installation of not vetted packages is forbidden', async () => {
globalConfig.nodes.communityPackages.unverifiedEnabled = false; config.unverifiedEnabled = false;
globalConfig.nodes.communityPackages.registry = 'https://registry.npmjs.org'; config.registry = 'https://registry.npmjs.org';
await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow( await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow(
'Installation of unverified community packages is forbidden!', 'Installation of unverified community packages is forbidden!',
); );
@@ -601,7 +598,7 @@ describe('CommunityPackagesService', () => {
loadNodesAndCredentials.isKnownNode.mockImplementation( loadNodesAndCredentials.isKnownNode.mockImplementation(
(nodeType) => nodeType === 'node-type-2', (nodeType) => nodeType === 'node-type-2',
); );
globalConfig.nodes.communityPackages.reinstallMissing = false; config.reinstallMissing = false;
await communityPackagesService.checkForMissingPackages(); await communityPackagesService.checkForMissingPackages();
@@ -616,7 +613,7 @@ describe('CommunityPackagesService', () => {
installedPackageRepository.find.mockResolvedValue(installedPackages); installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false); loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
globalConfig.nodes.communityPackages.reinstallMissing = true; config.reinstallMissing = true;
await communityPackagesService.checkForMissingPackages(); await communityPackagesService.checkForMissingPackages();
@@ -633,7 +630,7 @@ describe('CommunityPackagesService', () => {
installedPackageRepository.find.mockResolvedValue(installedPackages); installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false); loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
globalConfig.nodes.communityPackages.reinstallMissing = true; config.reinstallMissing = true;
communityPackagesService.installPackage = jest communityPackagesService.installPackage = jest
.fn() .fn()
.mockRejectedValue(new Error('Installation failed')); .mockRejectedValue(new Error('Installation failed'));
@@ -650,7 +647,7 @@ describe('CommunityPackagesService', () => {
installedPackageRepository.find.mockResolvedValue(installedPackages); installedPackageRepository.find.mockResolvedValue(installedPackages);
loadNodesAndCredentials.isKnownNode.mockReturnValue(false); loadNodesAndCredentials.isKnownNode.mockReturnValue(false);
globalConfig.nodes.communityPackages.reinstallMissing = true; config.reinstallMissing = true;
// First installation succeeds, second fails // First installation succeeds, second fails
communityPackagesService.installPackage = jest communityPackagesService.installPackage = jest

View File

@@ -2,7 +2,7 @@ import type { CommunityNodeType } from '@n8n/api-types';
import { Get, RestController } from '@n8n/decorators'; import { Get, RestController } from '@n8n/decorators';
import { Request } from 'express'; import { Request } from 'express';
import { CommunityNodeTypesService } from '@/services/community-node-types.service'; import { CommunityNodeTypesService } from '@/community-packages/community-node-types.service';
@RestController('/community-node-types') @RestController('/community-node-types')
export class CommunityNodeTypesController { export class CommunityNodeTypesController {

View File

@@ -1,14 +1,12 @@
import type { CommunityNodeType } from '@n8n/api-types'; import type { CommunityNodeType } from '@n8n/api-types';
import { Logger, inProduction } from '@n8n/backend-common'; import { Logger, inProduction } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { ensureError } from 'n8n-workflow'; import { ensureError } from 'n8n-workflow';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
import { getCommunityNodeTypes, StrapiCommunityNodeType } from './community-node-types-utils';
import { CommunityPackagesService } from './community-packages.service'; import { CommunityPackagesService } from './community-packages.service';
import {
getCommunityNodeTypes,
StrapiCommunityNodeType,
} from '../utils/community-node-types-utils';
const UPDATE_INTERVAL = 8 * 60 * 60 * 1000; const UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
@@ -20,17 +18,14 @@ export class CommunityNodeTypesService {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private globalConfig: GlobalConfig, private config: CommunityPackagesConfig,
private communityPackagesService: CommunityPackagesService, private communityPackagesService: CommunityPackagesService,
) {} ) {}
private async fetchNodeTypes() { private async fetchNodeTypes() {
try { try {
let data: StrapiCommunityNodeType[] = []; let data: StrapiCommunityNodeType[] = [];
if ( if (this.config.enabled && this.config.verifiedEnabled) {
this.globalConfig.nodes.communityPackages.enabled &&
this.globalConfig.nodes.communityPackages.verifiedEnabled
) {
// Cloud sets ENVIRONMENT to 'production' or 'staging' depending on the environment // Cloud sets ENVIRONMENT to 'production' or 'staging' depending on the environment
const environment = this.detectEnvironment(); const environment = this.detectEnvironment();
data = await getCommunityNodeTypes(environment); data = await getCommunityNodeTypes(environment);

View File

@@ -0,0 +1,28 @@
import { Config, Env } from '@n8n/config';
@Config
export class CommunityPackagesConfig {
/** Whether to enable community packages */
@Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true;
/** NPM registry URL to pull community packages from */
@Env('N8N_COMMUNITY_PACKAGES_REGISTRY')
registry: string = 'https://registry.npmjs.org';
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;
/** Whether to block installation of not verified packages */
@Env('N8N_UNVERIFIED_PACKAGES_ENABLED')
unverifiedEnabled: boolean = true;
/** Whether to enable and show search suggestion of packages verified by n8n */
@Env('N8N_VERIFIED_PACKAGES_ENABLED')
verifiedEnabled: boolean = true;
/** Whether to load community packages */
@Env('N8N_COMMUNITY_PACKAGES_PREVENT_LOADING')
preventLoading: boolean = false;
}

View File

@@ -1,20 +1,20 @@
import type { InstalledPackages } from '@n8n/db';
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@n8n/decorators';
import { import {
RESPONSE_ERROR_MESSAGES, RESPONSE_ERROR_MESSAGES,
STARTER_TEMPLATE_NAME, STARTER_TEMPLATE_NAME,
UNKNOWN_FAILURE_REASON, UNKNOWN_FAILURE_REASON,
} from '@/constants'; } from '@/constants';
import type { InstalledPackages } from '@n8n/db';
import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@n8n/decorators';
import type { CommunityPackages } from './community-packages.types';
import { CommunityNodeTypesService } from './community-node-types.service';
import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import type { CommunityPackages } from '@/interfaces';
import { Push } from '@/push'; import { Push } from '@/push';
import { NodeRequest } from '@/requests'; import { NodeRequest } from '@/requests';
import { CommunityPackagesService } from '@/services/community-packages.service';
import { CommunityNodeTypesService } from '../services/community-node-types.service';
const { const {
PACKAGE_NOT_INSTALLED, PACKAGE_NOT_INSTALLED,

View File

@@ -1,5 +1,4 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import type { InstalledPackages } from '@n8n/db'; import type { InstalledPackages } from '@n8n/db';
import { InstalledPackagesRepository } from '@n8n/db'; import { InstalledPackagesRepository } from '@n8n/db';
@@ -22,13 +21,14 @@ import {
UNKNOWN_FAILURE_REASON, UNKNOWN_FAILURE_REASON,
} from '@/constants'; } from '@/constants';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { CommunityPackages } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Publisher } from '@/scaling/pubsub/publisher.service';
import { toError } from '@/utils'; import { toError } from '@/utils';
import { isVersionExists, verifyIntegrity } from '../utils/npm-utils'; import { CommunityPackagesConfig } from './community-packages.config';
import type { CommunityPackages } from './community-packages.types';
import { isVersionExists, verifyIntegrity } from './npm-utils';
const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
const NPM_COMMON_ARGS = ['--audit=false', '--fund=false']; const NPM_COMMON_ARGS = ['--audit=false', '--fund=false'];
@@ -82,7 +82,7 @@ export class CommunityPackagesService {
private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
private readonly publisher: Publisher, private readonly publisher: Publisher,
private readonly license: License, private readonly license: License,
private readonly globalConfig: GlobalConfig, private readonly config: CommunityPackagesConfig,
) {} ) {}
async init() { async init() {
@@ -312,7 +312,7 @@ export class CommunityPackagesService {
if (missingPackages.size === 0) return; if (missingPackages.size === 0) return;
const { reinstallMissing } = this.globalConfig.nodes.communityPackages; const { reinstallMissing } = this.config;
if (reinstallMissing) { if (reinstallMissing) {
this.logger.info('Attempting to reinstall missing packages', { missingPackages }); this.logger.info('Attempting to reinstall missing packages', { missingPackages });
try { try {
@@ -365,7 +365,7 @@ export class CommunityPackagesService {
} }
private getNpmRegistry() { private getNpmRegistry() {
const { registry } = this.globalConfig.nodes.communityPackages; const { registry } = this.config;
if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) { if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) {
throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY); throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY);
} }
@@ -379,7 +379,7 @@ export class CommunityPackagesService {
} }
private checkInstallPermissions(checksumProvided: boolean) { private checkInstallPermissions(checksumProvided: boolean) {
if (!this.globalConfig.nodes.communityPackages.unverifiedEnabled && !checksumProvided) { if (!this.config.unverifiedEnabled && !checksumProvided) {
throw new UnexpectedError('Installation of unverified community packages is forbidden!'); throw new UnexpectedError('Installation of unverified community packages is forbidden!');
} }
} }

View File

@@ -0,0 +1,22 @@
export namespace CommunityPackages {
export type ParsedPackageName = {
packageName: string;
rawString: string;
scope?: string;
version?: string;
};
export type AvailableUpdates = {
[packageName: string]: {
current: string;
wanted: string;
latest: string;
location: string;
};
};
export type PackageStatusCheck = {
status: 'OK' | 'Banned';
reason?: string;
};
}

View File

@@ -153,33 +153,6 @@ export interface IWorkflowStatisticsDataLoaded {
dataLoaded: boolean; dataLoaded: boolean;
} }
// ----------------------------------
// community nodes
// ----------------------------------
export namespace CommunityPackages {
export type ParsedPackageName = {
packageName: string;
rawString: string;
scope?: string;
version?: string;
};
export type AvailableUpdates = {
[packageName: string]: {
current: string;
wanted: string;
latest: string;
location: string;
};
};
export type PackageStatusCheck = {
status: 'OK' | 'Banned';
reason?: string;
};
}
// ---------------------------------- // ----------------------------------
// telemetry // telemetry
// ---------------------------------- // ----------------------------------

View File

@@ -31,6 +31,7 @@ import path from 'path';
import picocolors from 'picocolors'; import picocolors from 'picocolors';
import { CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_NAME, CLI_DIR, inE2ETests } from '@/constants'; import { CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_NAME, CLI_DIR, inE2ETests } from '@/constants';
import { CommunityPackagesConfig } from './community-packages/community-packages.config';
@Service() @Service()
export class LoadNodesAndCredentials { export class LoadNodesAndCredentials {
@@ -88,7 +89,7 @@ export class LoadNodesAndCredentials {
await this.loadNodesFromNodeModules(nodeModulesDir, '@n8n/n8n-nodes-langchain'); await this.loadNodesFromNodeModules(nodeModulesDir, '@n8n/n8n-nodes-langchain');
} }
if (!this.globalConfig.nodes.communityPackages.preventLoading) { if (!Container.get(CommunityPackagesConfig).preventLoading) {
// Load nodes from any other `n8n-nodes-*` packages in the download directory // Load nodes from any other `n8n-nodes-*` packages in the download directory
// This includes the community nodes // This includes the community nodes
await this.loadNodesFromNodeModules( await this.loadNodesFromNodeModules(

View File

@@ -1,7 +1,7 @@
import { inDevelopment, Logger } from '@n8n/backend-common'; import { inDevelopment, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { separate } from '@n8n/db'; import { separate } from '@n8n/db';
import { Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import axios from 'axios'; import axios from 'axios';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
@@ -16,6 +16,7 @@ import {
} from '@/security-audit/constants'; } from '@/security-audit/constants';
import type { RiskReporter, Risk, n8n } from '@/security-audit/types'; import type { RiskReporter, Risk, n8n } from '@/security-audit/types';
import { toFlaggedNode } from '@/security-audit/utils'; import { toFlaggedNode } from '@/security-audit/utils';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
@Service() @Service()
export class InstanceRiskReporter implements RiskReporter { export class InstanceRiskReporter implements RiskReporter {
@@ -88,7 +89,7 @@ export class InstanceRiskReporter implements RiskReporter {
const settings: Record<string, unknown> = {}; const settings: Record<string, unknown> = {};
settings.features = { settings.features = {
communityPackagesEnabled: this.globalConfig.nodes.communityPackages.enabled, communityPackagesEnabled: Container.get(CommunityPackagesConfig).enabled,
versionNotificationsEnabled: this.globalConfig.versionNotifications.enabled, versionNotificationsEnabled: this.globalConfig.versionNotifications.enabled,
templatesEnabled: this.globalConfig.templates.enabled, templatesEnabled: this.globalConfig.templates.enabled,
publicApiEnabled: isApiEnabled(), publicApiEnabled: isApiEnabled(),

View File

@@ -1,5 +1,4 @@
import { GlobalConfig } from '@n8n/config'; import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import glob from 'fast-glob'; import glob from 'fast-glob';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import * as path from 'path'; import * as path from 'path';
@@ -14,14 +13,14 @@ import {
} from '@/security-audit/constants'; } from '@/security-audit/constants';
import type { Risk, RiskReporter } from '@/security-audit/types'; import type { Risk, RiskReporter } from '@/security-audit/types';
import { getNodeTypes } from '@/security-audit/utils'; import { getNodeTypes } from '@/security-audit/utils';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
@Service() @Service()
export class NodesRiskReporter implements RiskReporter { export class NodesRiskReporter implements RiskReporter {
constructor( constructor(
private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
private readonly communityPackagesService: CommunityPackagesService, private readonly communityPackagesService: CommunityPackagesService,
private readonly globalConfig: GlobalConfig,
) {} ) {}
async report(workflows: IWorkflowBase[]) { async report(workflows: IWorkflowBase[]) {
@@ -87,7 +86,7 @@ export class NodesRiskReporter implements RiskReporter {
} }
private async getCommunityNodeDetails() { private async getCommunityNodeDetails() {
if (!this.globalConfig.nodes.communityPackages.enabled) return []; if (!Container.get(CommunityPackagesConfig).enabled) return [];
const installedPackages = await this.communityPackagesService.getAllInstalledPackages(); const installedPackages = await this.communityPackagesService.getAllInstalledPackages();

View File

@@ -66,6 +66,7 @@ import '@/webhooks/webhooks.controller';
import { ChatServer } from './chat/chat-server'; import { ChatServer } from './chat/chat-server';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { CommunityPackagesConfig } from './community-packages/community-packages.config';
@Service() @Service()
export class Server extends AbstractServer { export class Server extends AbstractServer {
@@ -118,9 +119,9 @@ export class Server extends AbstractServer {
await Container.get(LdapService).init(); await Container.get(LdapService).init();
} }
if (this.globalConfig.nodes.communityPackages.enabled) { if (Container.get(CommunityPackagesConfig).enabled) {
await import('@/controllers/community-packages.controller'); await import('@/community-packages/community-packages.controller');
await import('@/controllers/community-node-types.controller'); await import('@/community-packages/community-node-types.controller');
} }
if (inE2ETests) { if (inE2ETests) {

View File

@@ -1,17 +1,19 @@
import { mock } from 'jest-mock-extended';
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common'; import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import type { InstanceSettings, BinaryDataConfig } from 'n8n-core'; import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { CredentialTypes } from '@/credential-types'; import type { CredentialTypes } from '@/credential-types';
import type { CredentialsOverwrites } from '@/credentials-overwrites'; import type { CredentialsOverwrites } from '@/credentials-overwrites';
import type { License } from '@/license'; import type { License } from '@/license';
import type { UserManagementMailer } from '@/user-management/email'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { UrlService } from '@/services/url.service';
import type { PushConfig } from '@/push/push.config';
import type { MfaService } from '@/mfa/mfa.service'; import type { MfaService } from '@/mfa/mfa.service';
import type { PushConfig } from '@/push/push.config';
import { FrontendService } from '@/services/frontend.service'; import { FrontendService } from '@/services/frontend.service';
import type { UrlService } from '@/services/url.service';
import type { UserManagementMailer } from '@/user-management/email';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
import { Container } from '@n8n/di';
describe('FrontendService', () => { describe('FrontendService', () => {
let originalEnv: NodeJS.ProcessEnv; let originalEnv: NodeJS.ProcessEnv;
@@ -32,7 +34,7 @@ describe('FrontendService', () => {
endpoints: { rest: 'rest' }, endpoints: { rest: 'rest' },
diagnostics: { enabled: false }, diagnostics: { enabled: false },
templates: { enabled: false, host: '' }, templates: { enabled: false, host: '' },
nodes: { communityPackages: { enabled: false } }, nodes: {},
tags: { disabled: false }, tags: { disabled: false },
logging: { level: 'info' }, logging: { level: 'info' },
hiringBanner: { enabled: false }, hiringBanner: { enabled: false },
@@ -64,6 +66,13 @@ describe('FrontendService', () => {
}, },
}); });
Container.set(
CommunityPackagesConfig,
mock<CommunityPackagesConfig>({
enabled: false,
}),
);
const logger = mock<Logger>(); const logger = mock<Logger>();
const instanceSettings = mock<InstanceSettings>({ const instanceSettings = mock<InstanceSettings>({
isDocker: false, isDocker: false,

View File

@@ -10,6 +10,8 @@ import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { ICredentialType, INodeTypeBaseDescription } from 'n8n-workflow'; import type { ICredentialType, INodeTypeBaseDescription } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import { CommunityPackagesConfig } from '@/community-packages/community-packages.config';
import type { CommunityPackagesService } from '@/community-packages/community-packages.service';
import config from '@/config'; import config from '@/config';
import { inE2ETests, N8N_VERSION } from '@/constants'; import { inE2ETests, N8N_VERSION } from '@/constants';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
@@ -20,7 +22,6 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MfaService } from '@/mfa/mfa.service'; import { MfaService } from '@/mfa/mfa.service';
import { isApiEnabled } from '@/public-api'; import { isApiEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config'; import { PushConfig } from '@/push/push.config';
import type { CommunityPackagesService } from '@/services/community-packages.service';
import { getSamlLoginLabel } from '@/sso.ee/saml/saml-helpers'; import { getSamlLoginLabel } from '@/sso.ee/saml/saml-helpers';
import { getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; import { getCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
import { UserManagementMailer } from '@/user-management/email'; import { UserManagementMailer } from '@/user-management/email';
@@ -59,10 +60,12 @@ export class FrontendService {
this.initSettings(); this.initSettings();
if (this.globalConfig.nodes.communityPackages.enabled) { if (Container.get(CommunityPackagesConfig).enabled) {
void import('@/services/community-packages.service').then(({ CommunityPackagesService }) => { void import('@/community-packages/community-packages.service').then(
this.communityPackagesService = Container.get(CommunityPackagesService); ({ CommunityPackagesService }) => {
}); this.communityPackagesService = Container.get(CommunityPackagesService);
},
);
} }
} }
@@ -197,8 +200,8 @@ export class FrontendService {
executionMode: config.getEnv('executions.mode'), executionMode: config.getEnv('executions.mode'),
isMultiMain: this.instanceSettings.isMultiMain, isMultiMain: this.instanceSettings.isMultiMain,
pushBackend: this.pushConfig.backend, pushBackend: this.pushConfig.backend,
communityNodesEnabled: this.globalConfig.nodes.communityPackages.enabled, communityNodesEnabled: Container.get(CommunityPackagesConfig).enabled,
unverifiedCommunityNodesEnabled: this.globalConfig.nodes.communityPackages.unverifiedEnabled, unverifiedCommunityNodesEnabled: Container.get(CommunityPackagesConfig).unverifiedEnabled,
deployment: { deployment: {
type: this.globalConfig.deployment.type, type: this.globalConfig.deployment.type,
}, },

View File

@@ -16,7 +16,7 @@ import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service'; import { Publisher } from '@/scaling/pubsub/publisher.service';
import { Subscriber } from '@/scaling/pubsub/subscriber.service'; import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { ScalingService } from '@/scaling/scaling.service'; import { ScalingService } from '@/scaling/scaling.service';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server'; import { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server';
import { TaskRunnerProcess } from '@/task-runners/task-runner-process'; import { TaskRunnerProcess } from '@/task-runners/task-runner-process';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';

View File

@@ -3,7 +3,7 @@ import type { InstalledNodes, InstalledPackages } from '@n8n/db';
import path from 'path'; import path from 'path';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { COMMUNITY_PACKAGE_VERSION } from './shared/constants'; import { COMMUNITY_PACKAGE_VERSION } from './shared/constants';
import { createOwner } from './shared/db/users'; import { createOwner } from './shared/db/users';

View File

@@ -9,7 +9,7 @@ import { NodeTypes } from '@/node-types';
import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/security-audit/constants'; import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/security-audit/constants';
import { SecurityAuditService } from '@/security-audit/security-audit.service'; import { SecurityAuditService } from '@/security-audit/security-audit.service';
import { toReportTitle } from '@/security-audit/utils'; import { toReportTitle } from '@/security-audit/utils';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/community-packages/community-packages.service';
import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils'; import { getRiskSection, MOCK_PACKAGE, saveManualTriggerWorkflow } from './utils';

View File

@@ -228,7 +228,7 @@ export const setupTestServer = ({
break; break;
case 'community-packages': case 'community-packages':
await import('@/controllers/community-packages.controller'); await import('@/community-packages/community-packages.controller');
break; break;
case 'me': case 'me':

View File

@@ -49,7 +49,7 @@ Modules are managed via env vars:
- To enable a module (activate it on instance startup), use the env var `N8N_ENABLED_MODULES`. - To enable a module (activate it on instance startup), use the env var `N8N_ENABLED_MODULES`.
- To disable a module (skip it on instance startup), use the env var `N8N_DISABLED_MODULES`. - To disable a module (skip it on instance startup), use the env var `N8N_DISABLED_MODULES`.
- Some modules are **default modules** so they are always enabled unless specifically disabled. - Some modules are **default modules** so they are always enabled unless specifically disabled. To enable a module by default, add it [here](https://github.com/n8n-io/n8n/blob/c0360e52afe9db37d4dd6e00955fa42b0c851904/packages/%40n8n/backend-common/src/modules/module-registry.ts#L26).
Modules that are under a license flag are automatically skipped on startup if the instance is not licensed to use the feature. Modules that are under a license flag are automatically skipped on startup if the instance is not licensed to use the feature.
@@ -225,7 +225,7 @@ Service-level decorators to be aware of:
- `@Service()` to make a service usable by the dependency injection container - `@Service()` to make a service usable by the dependency injection container
- `@OnLifecycleEvent()` to register a class method to be called on an execution lifecycle event, e.g. `nodeExecuteBefore`, `nodeExecuteAfter`, `workflowExecuteBefore`, and `workflowExecuteAfter` - `@OnLifecycleEvent()` to register a class method to be called on an execution lifecycle event, e.g. `nodeExecuteBefore`, `nodeExecuteAfter`, `workflowExecuteBefore`, and `workflowExecuteAfter`
- `@OnPubSubEvent()` to register a class method to be called on receiving a message via Redis pubsub - `@OnPubSubEvent()` to register a class method to be called on receiving a message via Redis pubsub
- `@OnLeaderTakeover()` and `@OnLeaderStopdown` to register a class method to be called on leadership transition in a multi-main setup - `@OnLeaderTakeover()` and `@OnLeaderStepdown` to register a class method to be called on leadership transition in a multi-main setup
## Repositories ## Repositories
@@ -333,7 +333,7 @@ Currently, testing utilities live partly at `cli` and partly at `@n8n/backend-te
4. Existing features that are not modules (e.g. LDAP) should be turned into modules over time. 4. Existing features that are not modules (e.g. LDAP) should be turned into modules over time.
### FAQs ## FAQs
- **What is a good example of a backend module?** Our first backend module is the `insights` module at `packages/@n8n/modules/insights`. - **What is a good example of a backend module?** Our first backend module is the `insights` module at `packages/@n8n/modules/insights`.
- **My feature is already a separate _package_ at `packages/@n8n/{feature}`. How does this work with modules?** If your feature is already fully decoupled from `cli`, or if you know in advance that your feature will have zero dependencies on `cli`, then you already stand to gain most of the benefits of modularity. In this case, you can add a thin module to `cli` containing an entrypoint to your feature imported from your package, so that your feature is loaded only when needed. - **My feature is already a separate _package_ at `packages/@n8n/{feature}`. How does this work with modules?** If your feature is already fully decoupled from `cli`, or if you know in advance that your feature will have zero dependencies on `cli`, then you already stand to gain most of the benefits of modularity. In this case, you can add a thin module to `cli` containing an entrypoint to your feature imported from your package, so that your feature is loaded only when needed.