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
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
nodeConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
},
},
{
files: ['**/*.config.ts'],
rules: {
'n8n-local-rules/no-untyped-config-class-field': 'error',
},
},
);
+2
View File
@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@n8n/config",
"version": "2.10.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write src test",
"format:check": "biome ci src test",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"@n8n/di": "workspace:*",
"reflect-metadata": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
}
}
@@ -0,0 +1,17 @@
import { Container } from '@n8n/di';
import { AiConfig } from '../ai.config';
describe('AiConfig', () => {
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
it('should not poison openAiDefaultHeaders object globally when modified', () => {
const { openAiDefaultHeaders } = Container.get(AiConfig);
openAiDefaultHeaders.test = 'ok';
expect(openAiDefaultHeaders.test).toBe('ok');
expect(Container.get(AiConfig).openAiDefaultHeaders.test).toBeFalsy();
});
});
@@ -0,0 +1,67 @@
import { Container } from '@n8n/di';
import { UserManagementConfig } from '../user-management.config';
describe('UserManagementConfig', () => {
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
test('with refresh timout > session, sets refresh timout to `0`', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '1',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '2',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.',
);
consoleWarnSpy.mockRestore();
});
test('with refresh timout == session, sets refresh timout to `0`', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '1',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '1',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.',
);
consoleWarnSpy.mockRestore();
});
test('with refresh timout < session, keeps refresh timout intact', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
process.env = {
N8N_USER_MANAGEMENT_JWT_DURATION_HOURS: '10',
N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS: '5',
};
const config = Container.get(UserManagementConfig);
expect(config.jwtRefreshTimeoutHours).toBe(5);
expect(consoleWarnSpy).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
@@ -0,0 +1,11 @@
import { Config, Env } from '../decorators';
@Config
export class AiAssistantConfig {
/**
* Base URL of the AI assistant service.
* When set, requests are sent to this URL instead of the default provider endpoint.
*/
@Env('N8N_AI_ASSISTANT_BASE_URL')
baseUrl: string = '';
}
@@ -0,0 +1,11 @@
import { Config, Env } from '../decorators';
@Config
export class AiBuilderConfig {
/**
* API key for the Anthropic (Claude) provider used by the AI workflow builder.
* When set, enables AI-powered workflow and node building.
*/
@Env('N8N_AI_ANTHROPIC_KEY')
apiKey: string = '';
}
@@ -0,0 +1,32 @@
import { Config, Env } from '../decorators';
@Config
export class AiConfig {
/** Whether AI features (such as AI nodes and AI assistant) are enabled globally. */
@Env('N8N_AI_ENABLED')
enabled: boolean = false;
/**
* Maximum time in milliseconds to wait for an HTTP response from an AI service.
* Matches the maximum workflow execution timeout, EXECUTIONS_TIMEOUT_MAX (1 hour) so AI calls do not outlive executions.
* Default: 3600000 (1 hour).
*/
@Env('N8N_AI_TIMEOUT_MAX')
timeout: number = 3600000;
/**
* Whether workflow and node parameter values may be sent to AI providers.
* When false, only structure or placeholders are sent.
*/
@Env('N8N_AI_ALLOW_SENDING_PARAMETER_VALUES')
allowSendingParameterValues: boolean = true;
/** Whether to persist AI workflow builder sessions to the database. */
@Env('N8N_AI_PERSIST_BUILDER_SESSIONS')
persistBuilderSessions: boolean = false;
get openAiDefaultHeaders(): Record<string, string> {
// eslint-disable-next-line @typescript-eslint/naming-convention
return { 'openai-platform': 'org-qkmJQuJ2WnvoIKMr2UJwIJkZ' };
}
}
@@ -0,0 +1,24 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
const samesiteSchema = z.enum(['strict', 'lax', 'none']);
type Samesite = z.infer<typeof samesiteSchema>;
@Config
class CookieConfig {
/** Whether to set the `Secure` flag on the n8n authentication cookie (recommended for HTTPS). */
@Env('N8N_SECURE_COOKIE')
secure: boolean = true;
/** Value for the `SameSite` attribute on the n8n authentication cookie (`strict`, `lax`, or `none`). */
@Env('N8N_SAMESITE_COOKIE', samesiteSchema)
samesite: Samesite = 'lax';
}
@Config
export class AuthConfig {
@Nested
cookie: CookieConfig;
}
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
const cacheBackendSchema = z.enum(['memory', 'redis', 'auto']);
type CacheBackend = z.infer<typeof cacheBackendSchema>;
@Config
class MemoryConfig {
/** Maximum size of the in-memory cache in bytes. Default: 3 MiB. */
@Env('N8N_CACHE_MEMORY_MAX_SIZE')
maxSize: number = 3 * 1024 * 1024; // 3 MiB
/** Time to live in milliseconds for entries in the memory cache. Default: 1 hour. */
@Env('N8N_CACHE_MEMORY_TTL')
ttl: number = 3600 * 1000; // 1 hour
}
@Config
class RedisConfig {
/** Key prefix for cache entries stored in Redis. */
@Env('N8N_CACHE_REDIS_KEY_PREFIX')
prefix: string = 'cache';
/** Time to live in milliseconds for Redis cache entries. Set to 0 to disable expiry. Default: 1 hour. */
@Env('N8N_CACHE_REDIS_TTL')
ttl: number = 3600 * 1000; // 1 hour
}
@Config
export class CacheConfig {
/** Cache backend: `memory`, `redis`, or `auto` (choose based on deployment). */
@Env('N8N_CACHE_BACKEND', cacheBackendSchema)
backend: CacheBackend = 'auto';
@Nested
memory: MemoryConfig;
@Nested
redis: RedisConfig;
}
@@ -0,0 +1,23 @@
import { Config, Env } from '../decorators';
@Config
export class ChatHubConfig {
/**
* Time to live in seconds for execution context in Chat Hub.
* Maximum duration for a single non-streaming Workflow Agent execution, including wait time.
* After this TTL, responses from those executions are no longer captured or sent to the client.
*/
@Env('N8N_CHAT_HUB_EXECUTION_CONTEXT_TTL')
executionContextTtl: number = 3600;
/**
* Time to live in seconds for stream state in Chat Hub.
* Inactive streams are cleaned up after this duration.
*/
@Env('N8N_CHAT_HUB_STREAM_STATE_TTL')
streamStateTtl: number = 300;
/** Maximum number of response chunks to buffer per stream for reconnection in Chat Hub. */
@Env('N8N_CHAT_HUB_MAX_BUFFERED_CHUNKS')
maxBufferedChunks: number = 1000;
}
@@ -0,0 +1,33 @@
import { Config, Env, Nested } from '../decorators';
@Config
class CredentialsOverwrite {
/**
* JSON object of prefilled credential data (overwrites). End users cannot view or edit these values.
* Format: `{ "CREDENTIAL_NAME": { "PARAMETER": "VALUE" } }`.
*/
@Env('CREDENTIALS_OVERWRITE_DATA')
data: string = '{}';
/** Endpoint of an internal API that returns overwritten credential definitions. When set, overwrites are loaded from this endpoint. */
@Env('CREDENTIALS_OVERWRITE_ENDPOINT')
endpoint: string = '';
/** Token used to authenticate requests to the credentials overwrite endpoint. */
@Env('CREDENTIALS_OVERWRITE_ENDPOINT_AUTH_TOKEN')
endpointAuthToken: string = '';
/** Whether to persist credential overwrites so they survive restarts. */
@Env('CREDENTIALS_OVERWRITE_PERSISTENCE')
persistence: boolean = false;
}
@Config
export class CredentialsConfig {
/** Default name suggested when creating new credentials. */
@Env('CREDENTIALS_DEFAULT_NAME')
defaultName: string = 'My credentials';
@Nested
overwrite: CredentialsOverwrite;
}
@@ -0,0 +1,53 @@
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Config, Env } from '../decorators';
@Config
export class DataTableConfig {
/** Maximum total size in bytes allowed for data tables. Default: 50 MiB. */
@Env('N8N_DATA_TABLES_MAX_SIZE_BYTES')
maxSize: number = 50 * 1024 * 1024;
/**
* Size in bytes at which to warn that a data table is nearing capacity.
* If unset, defaults to 80% of maxSize.
*/
@Env('N8N_DATA_TABLES_WARNING_THRESHOLD_BYTES')
warningThreshold?: number;
/**
* Duration in milliseconds to cache data table size checks.
* Reduces database load when validating size repeatedly.
*/
@Env('N8N_DATA_TABLES_SIZE_CHECK_CACHE_DURATION_MS')
sizeCheckCacheDuration: number = 5 * 1000;
/**
* Maximum file size in bytes for CSV uploads to data tables.
* If unset, the limit is the remaining available storage.
*/
@Env('N8N_DATA_TABLES_UPLOAD_MAX_FILE_SIZE_BYTES')
uploadMaxFileSize?: number;
/** Interval in milliseconds between cleanup runs for orphaned upload files. Default: 60000 milliseconds. */
@Env('N8N_DATA_TABLES_CLEANUP_INTERVAL_MS')
cleanupIntervalMs: number = 60 * 1000;
/**
* Age in milliseconds after which an uploaded file is treated as orphaned and deleted during cleanup.
* Default: 2 minutes.
*/
@Env('N8N_DATA_TABLES_FILE_MAX_AGE_MS')
fileMaxAgeMs: number = 2 * 60 * 1000;
/**
* Directory for temporary CSV uploads before import. Files in this directory are pruned by cleanup (see fileMaxAgeMs).
* Resolved as `<system-tmp-dir>/n8nDataTableUploads` (for example, `/tmp/n8nDataTableUploads`).
*/
readonly uploadDir: string;
constructor() {
this.uploadDir = path.join(tmpdir(), 'n8nDataTableUploads');
}
}
@@ -0,0 +1,143 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
const dbLoggingOptionsSchema = z.enum(['query', 'error', 'schema', 'warn', 'info', 'log', 'all']);
type DbLoggingOptions = z.infer<typeof dbLoggingOptionsSchema>;
@Config
class LoggingConfig {
/** Whether database logging is enabled. */
@Env('DB_LOGGING_ENABLED')
enabled: boolean = false;
/**
* Database logging verbosity. Only applies when `DB_LOGGING_MAX_EXECUTION_TIME` is greater than 0.
*/
@Env('DB_LOGGING_OPTIONS', dbLoggingOptionsSchema)
options: DbLoggingOptions = 'error';
/** Only log queries that run longer than this many milliseconds. Set to 0 to disable slow-query logging. */
@Env('DB_LOGGING_MAX_EXECUTION_TIME')
maxQueryExecutionTime: number = 0;
}
@Config
class PostgresSSLConfig {
/**
* Whether to use SSL/TLS for the Postgres connection.
* Defaults to true if any of the SSL cert/key/CA environment variables are set.
*/
@Env('DB_POSTGRESDB_SSL_ENABLED')
enabled: boolean = false;
/** Path or contents of the CA certificate for Postgres SSL. */
@Env('DB_POSTGRESDB_SSL_CA')
ca: string = '';
/** Path or contents of the client certificate for Postgres SSL. */
@Env('DB_POSTGRESDB_SSL_CERT')
cert: string = '';
/** Path or contents of the client private key for Postgres SSL. */
@Env('DB_POSTGRESDB_SSL_KEY')
key: string = '';
/** Whether to reject Postgres connections when the server certificate cannot be verified. */
@Env('DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED')
rejectUnauthorized: boolean = true;
}
@Config
class PostgresConfig {
/** Postgres database name. */
@Env('DB_POSTGRESDB_DATABASE')
database: string = 'n8n';
/** Postgres database host. */
@Env('DB_POSTGRESDB_HOST')
host: string = 'localhost';
/** Postgres database password. */
@Env('DB_POSTGRESDB_PASSWORD')
password: string = '';
/** Postgres database port. */
@Env('DB_POSTGRESDB_PORT')
port: number = 5432;
/** Postgres user name. */
@Env('DB_POSTGRESDB_USER')
user: string = 'postgres';
/** Postgres schema to use. */
@Env('DB_POSTGRESDB_SCHEMA')
schema: string = 'public';
/** Maximum number of connections in the Postgres connection pool. */
@Env('DB_POSTGRESDB_POOL_SIZE')
poolSize: number = 2;
/** Timeout in milliseconds when establishing a new Postgres connection. */
@Env('DB_POSTGRESDB_CONNECTION_TIMEOUT')
connectionTimeoutMs: number = 20_000;
/** Time in milliseconds after which an idle connection in the pool is closed. */
@Env('DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT')
idleTimeoutMs: number = 30_000;
/** Maximum time in milliseconds for a single query. Queries exceeding this are cancelled. Set to 0 to disable. */
@Env('DB_POSTGRESDB_STATEMENT_TIMEOUT')
statementTimeoutMs: number = 5 * 60 * 1000; // 5 minutes
@Nested
ssl: PostgresSSLConfig;
}
const sqlitePoolSizeSchema = z.coerce.number().int().gte(1);
@Config
export class SqliteConfig {
/** Path to the SQLite database file. */
@Env('DB_SQLITE_DATABASE')
database: string = 'database.sqlite';
/** Number of connections in the SQLite connection pool. Must be at least 1. */
@Env('DB_SQLITE_POOL_SIZE', sqlitePoolSizeSchema)
poolSize: number = 3;
/**
* Whether to run SQLite VACUUM on startup to reclaim space and optimize the file.
*
* @warning Blocking operation; can significantly increase startup time.
*/
@Env('DB_SQLITE_VACUUM_ON_STARTUP')
executeVacuumOnStartup: boolean = false;
}
const dbTypeSchema = z.enum(['sqlite', 'postgresdb']);
type DbType = z.infer<typeof dbTypeSchema>;
@Config
export class DatabaseConfig {
/** Database type: `sqlite` or `postgresdb`. */
@Env('DB_TYPE', dbTypeSchema)
type: DbType = 'sqlite';
/** Prefix prepended to all n8n table names (useful for shared databases). */
@Env('DB_TABLE_PREFIX')
tablePrefix: string = '';
/** Interval in seconds between health-check pings to the database. */
@Env('DB_PING_INTERVAL_SECONDS')
pingIntervalSeconds: number = 2;
@Nested
logging: LoggingConfig;
@Nested
postgresdb: PostgresConfig;
@Nested
sqlite: SqliteConfig;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class DeploymentConfig {
/** Deployment type identifier (for example, `default`, `cloud`). Used for telemetry and feature behavior. */
@Env('N8N_DEPLOYMENT_TYPE')
type: string = 'default';
}
@@ -0,0 +1,30 @@
import { Config, Env, Nested } from '../decorators';
@Config
class PostHogConfig {
/** PostHog project API key for product analytics. */
@Env('N8N_DIAGNOSTICS_POSTHOG_API_KEY')
apiKey: string = 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo';
/** PostHog API host URL. */
@Env('N8N_DIAGNOSTICS_POSTHOG_API_HOST')
apiHost: string = 'https://us.i.posthog.com';
}
@Config
export class DiagnosticsConfig {
/** Whether anonymous diagnostics and telemetry are enabled for this instance. */
@Env('N8N_DIAGNOSTICS_ENABLED')
enabled: boolean = true;
/** Telemetry endpoint config for the frontend (format: key;baseUrl). */
@Env('N8N_DIAGNOSTICS_CONFIG_FRONTEND')
frontendConfig: string = '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io';
/** Telemetry endpoint config for the backend (format: key;baseUrl). */
@Env('N8N_DIAGNOSTICS_CONFIG_BACKEND')
backendConfig: string = '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io';
@Nested
posthogConfig: PostHogConfig;
}
@@ -0,0 +1,12 @@
import { Config, Env } from '../decorators';
@Config
export class DynamicBannersConfig {
/** URL to fetch dynamic banner content from (for example, in-app announcements). */
@Env('N8N_DYNAMIC_BANNERS_ENDPOINT')
endpoint: string = 'https://api.n8n.io/api/banners';
/** Whether to fetch and show dynamic banners (for example, announcements) from the endpoint. */
@Env('N8N_DYNAMIC_BANNERS_ENABLED')
enabled: boolean = true;
}
@@ -0,0 +1,147 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
@Config
class PrometheusMetricsConfig {
/** Whether to enable the `/metrics` endpoint to expose Prometheus metrics. */
@Env('N8N_METRICS')
enable: boolean = false;
/** Prefix for Prometheus metric names. */
@Env('N8N_METRICS_PREFIX')
prefix: string = 'n8n_';
/** Whether to expose system and Node.js metrics. See: https://www.npmjs.com/package/prom-client */
@Env('N8N_METRICS_INCLUDE_DEFAULT_METRICS')
includeDefaultMetrics: boolean = true;
/** Whether to include a label for workflow ID on workflow metrics. */
@Env('N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL')
includeWorkflowIdLabel: boolean = false;
/** Whether to include a label for node type on node metrics. */
@Env('N8N_METRICS_INCLUDE_NODE_TYPE_LABEL')
includeNodeTypeLabel: boolean = false;
/** Whether to include a label for credential type on credential metrics. */
@Env('N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL')
includeCredentialTypeLabel: boolean = false;
/** Whether to expose metrics for API endpoints. See: https://www.npmjs.com/package/express-prom-bundle */
@Env('N8N_METRICS_INCLUDE_API_ENDPOINTS')
includeApiEndpoints: boolean = false;
/** Whether to include a label for the path of API endpoint calls. */
@Env('N8N_METRICS_INCLUDE_API_PATH_LABEL')
includeApiPathLabel: boolean = false;
/** Whether to include a label for the HTTP method of API endpoint calls. */
@Env('N8N_METRICS_INCLUDE_API_METHOD_LABEL')
includeApiMethodLabel: boolean = false;
/** Whether to include a label for the status code of API endpoint calls. */
@Env('N8N_METRICS_INCLUDE_API_STATUS_CODE_LABEL')
includeApiStatusCodeLabel: boolean = false;
/** Whether to include metrics for cache hits and misses. */
@Env('N8N_METRICS_INCLUDE_CACHE_METRICS')
includeCacheMetrics: boolean = false;
/** Whether to include metrics derived from n8n's internal events */
@Env('N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS')
includeMessageEventBusMetrics: boolean = false;
/** Whether to include metrics for jobs in scaling mode. Not supported in multi-main setup. */
@Env('N8N_METRICS_INCLUDE_QUEUE_METRICS')
includeQueueMetrics: boolean = false;
/** How often (in seconds) to update queue metrics. */
@Env('N8N_METRICS_QUEUE_METRICS_INTERVAL')
queueMetricsInterval: number = 20;
/** How often (in seconds) to update active workflow metric */
@Env('N8N_METRICS_ACTIVE_WORKFLOW_METRIC_INTERVAL')
activeWorkflowCountInterval: number = 60;
/** Whether to include a label for workflow name on workflow metrics. */
@Env('N8N_METRICS_INCLUDE_WORKFLOW_NAME_LABEL')
includeWorkflowNameLabel: boolean = false;
/** Whether to include workflow execution statistics as metrics. */
@Env('N8N_METRICS_INCLUDE_WORKFLOW_STATISTICS')
includeWorkflowStatistics: boolean = false;
/** How often (in seconds) to update workflow statistics metrics. */
@Env('N8N_METRICS_WORKFLOW_STATISTICS_INTERVAL')
workflowStatisticsInterval: number = 300;
}
@Config
export class EndpointsConfig {
/** Maximum request payload size in MiB for the API. */
@Env('N8N_PAYLOAD_SIZE_MAX')
payloadSizeMax: number = 16;
/** Maximum size in MiB for a single file in multipart/form-data webhook payloads. */
@Env('N8N_FORMDATA_FILE_SIZE_MAX')
formDataFileSizeMax: number = 200;
@Nested
metrics: PrometheusMetricsConfig;
/** Path segment for REST API endpoints. */
@Env('N8N_ENDPOINT_REST')
rest: string = 'rest';
/** Path segment for form endpoints. */
@Env('N8N_ENDPOINT_FORM')
form: string = 'form';
/** Path segment for test form endpoints. */
@Env('N8N_ENDPOINT_FORM_TEST')
formTest: string = 'form-test';
/** Path segment for waiting form endpoints. */
@Env('N8N_ENDPOINT_FORM_WAIT')
formWaiting: string = 'form-waiting';
/** Path segment for webhook endpoints. */
@Env('N8N_ENDPOINT_WEBHOOK')
webhook: string = 'webhook';
/** Path segment for test webhook endpoints. */
@Env('N8N_ENDPOINT_WEBHOOK_TEST')
webhookTest: string = 'webhook-test';
/** Path segment for waiting webhook endpoints. */
@Env('N8N_ENDPOINT_WEBHOOK_WAIT')
webhookWaiting: string = 'webhook-waiting';
/** Path segment for MCP endpoints. */
@Env('N8N_ENDPOINT_MCP')
mcp: string = 'mcp';
/** Path segment for test MCP endpoints. */
@Env('N8N_ENDPOINT_MCP_TEST')
mcpTest: string = 'mcp-test';
/** Whether to disable n8n's UI (frontend). */
@Env('N8N_DISABLE_UI')
disableUi: boolean = false;
/** Whether to disable production webhooks on the main process, when using webhook-specific processes. */
@Env('N8N_DISABLE_PRODUCTION_MAIN_PROCESS')
disableProductionWebhooksOnMainProcess: boolean = false;
/** Colon-separated list of path segments that should not serve the UI (for example, health or webhook-only routes). */
@Env('N8N_ADDITIONAL_NON_UI_ROUTES')
additionalNonUIRoutes: string = '';
/** Path for the health check endpoint. */
@Env(
'N8N_ENDPOINT_HEALTH',
z.string().transform((val) => (val.startsWith('/') ? val : `/${val}`)),
)
health: string = '/healthz';
}
@@ -0,0 +1,36 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
@Config
class LogWriterConfig {
/** Number of event log files to retain; older files are rotated out. */
@Env('N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT')
keepLogCount: number = 3;
/** Maximum size in KB of a single event log file before rotation. Default: 10 MB. */
@Env('N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB')
maxFileSizeInKB: number = 10240; // 10 MB
/** Base filename for event log files (extension and rotation suffix are added). */
@Env('N8N_EVENTBUS_LOGWRITER_LOGBASENAME')
logBaseName: string = 'n8nEventLog';
}
const recoveryModeSchema = z.enum(['simple', 'extensive']);
type RecoveryMode = z.infer<typeof recoveryModeSchema>;
@Config
export class EventBusConfig {
/** Interval in milliseconds to check for and resend unsent event-bus messages. Set to 0 to disable (may rarely allow duplicate sends when non-zero). */
@Env('N8N_EVENTBUS_CHECKUNSENTINTERVAL')
checkUnsentInterval: number = 0;
/** Endpoint to retrieve n8n version information from */
@Nested
logWriter: LogWriterConfig;
/** After a crash: `extensive` recovers full execution details; `simple` only marks executions as crashed. */
@Env('N8N_EVENTBUS_RECOVERY_MODE', recoveryModeSchema)
crashRecoveryMode: RecoveryMode = 'extensive';
}
@@ -0,0 +1,130 @@
import z from 'zod';
import { Config, Env, Nested } from '../decorators';
@Config
class PruningIntervalsConfig {
/** How often (minutes) execution data should be hard-deleted. */
@Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL')
hardDelete: number = 15;
/** How often (minutes) execution data should be soft-deleted. */
@Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL')
softDelete: number = 60;
}
@Config
class ConcurrencyConfig {
/**
* Max production executions allowed to run concurrently. `-1` means unlimited.
*
* Default for scaling mode is taken from the worker's `--concurrency` flag.
*/
@Env('N8N_CONCURRENCY_PRODUCTION_LIMIT')
productionLimit: number = -1;
/** Max evaluation executions allowed to run concurrently. `-1` means unlimited. */
@Env('N8N_CONCURRENCY_EVALUATION_LIMIT')
evaluationLimit: number = -1;
}
@Config
class QueueRecoveryConfig {
/** How often (minutes) to check for queue recovery. */
@Env('N8N_EXECUTIONS_QUEUE_RECOVERY_INTERVAL')
interval: number = 180;
/** Size of batch of executions to check for queue recovery. */
@Env('N8N_EXECUTIONS_QUEUE_RECOVERY_BATCH')
batchSize: number = 100;
}
@Config
class RecoveryConfig {
/**
* Number of last executions to check when determining if a workflow should be deactivated
* when all of the last N executions have crashed.
*/
@Env('N8N_WORKFLOW_AUTODEACTIVATION_MAX_LAST_EXECUTIONS')
maxLastExecutions: number = 3;
/**
* Whether to automatically deactivate workflows that have all their last executions crashed.
*/
@Env('N8N_WORKFLOW_AUTODEACTIVATION_ENABLED')
workflowDeactivationEnabled: boolean = false;
}
const executionModeSchema = z.enum(['regular', 'queue']);
export type ExecutionMode = z.infer<typeof executionModeSchema>;
@Config
export class ExecutionsConfig {
/** Whether to run executions in regular mode (in-process) or scaling mode (in workers). */
@Env('EXECUTIONS_MODE', executionModeSchema)
mode: ExecutionMode = 'regular';
/**
* How long (seconds) a workflow execution may run for before timeout.
* On timeout, the execution will be forcefully stopped. `-1` for unlimited.
* Currently unlimited by default - this default will change in a future version.
*/
@Env('EXECUTIONS_TIMEOUT')
timeout: number = -1;
/** Upper bound in seconds for execution timeout. Default: 1 hour. */
@Env('EXECUTIONS_TIMEOUT_MAX')
maxTimeout: number = 3600; // 1h
/** Whether to delete past executions on a rolling basis. */
@Env('EXECUTIONS_DATA_PRUNE')
pruneData: boolean = true;
/** How old (hours) a finished execution must be to qualify for soft-deletion. */
@Env('EXECUTIONS_DATA_MAX_AGE')
pruneDataMaxAge: number = 336;
/**
* Max number of finished executions to keep in database. Does not necessarily
* prune to the exact max number. `0` for unlimited.
*/
@Env('EXECUTIONS_DATA_PRUNE_MAX_COUNT')
pruneDataMaxCount: number = 10_000;
/**
* How old (hours) a finished execution must be to qualify for hard-deletion.
* This buffer by default excludes recent executions as the user may need
* them while building a workflow.
*/
@Env('EXECUTIONS_DATA_HARD_DELETE_BUFFER')
pruneDataHardDeleteBuffer: number = 1;
@Nested
pruneDataIntervals: PruningIntervalsConfig;
@Nested
concurrency: ConcurrencyConfig;
@Nested
queueRecovery: QueueRecoveryConfig;
@Nested
recovery: RecoveryConfig;
/** Whether to save execution data for failed production executions. This default can be overridden at a workflow level. */
@Env('EXECUTIONS_DATA_SAVE_ON_ERROR')
saveDataOnError: 'all' | 'none' = 'all';
/** Whether to save execution data for successful production executions. This default can be overridden at a workflow level. */
@Env('EXECUTIONS_DATA_SAVE_ON_SUCCESS')
saveDataOnSuccess: 'all' | 'none' = 'all';
/** Whether to save execution data as each node executes. This default can be overridden at a workflow level. */
@Env('EXECUTIONS_DATA_SAVE_ON_PROGRESS')
saveExecutionProgress: boolean = false;
/** Whether to save execution data for manual executions. This default can be overridden at a workflow level. */
@Env('EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS')
saveDataManualExecutions: boolean = true;
}
@@ -0,0 +1,9 @@
import { ColonSeparatedStringArray } from '../custom-types';
import { Config, Env } from '../decorators';
@Config
export class ExternalHooksConfig {
/** Paths to files that define external lifecycle hooks. Colon-separated for multiple files. */
@Env('EXTERNAL_HOOK_FILES')
files: ColonSeparatedStringArray = [];
}
@@ -0,0 +1,21 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
const releaseChannelSchema = z.enum(['stable', 'beta', 'nightly', 'dev', 'rc']);
type ReleaseChannel = z.infer<typeof releaseChannelSchema>;
@Config
export class GenericConfig {
/** Default timezone for the instance. Can be overridden per workflow. */
@Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York';
/** Release channel (for example, stable, beta, nightly). Affects update checks and some defaults. */
@Env('N8N_RELEASE_TYPE', releaseChannelSchema)
releaseChannel: ReleaseChannel = 'dev';
/** Seconds to wait for graceful shutdown (for example, finishing executions) before the process exits. */
@Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT')
gracefulShutdownTimeout: number = 30;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class HiringBannerConfig {
/** Whether to show the hiring/recruitment message in the browser devtools console. */
@Env('N8N_HIRING_BANNER_ENABLED')
enabled: boolean = true;
}
@@ -0,0 +1,34 @@
import path from 'node:path';
import { Config, Env } from '../decorators';
import { getN8nFolder } from '../utils/utils';
@Config
export class InstanceSettingsConfig {
/**
* Whether to enforce that n8n settings file doesn't have overly wide permissions.
* If set to true, n8n will check the permissions of the settings file and
* attempt change them to 0600 (only owner has rw access) if they are too wide.
*/
@Env('N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS')
enforceSettingsFilePermissions: boolean = true;
/**
* Encryption key to use for encrypting and decrypting credentials.
* If none is provided, a random key will be generated and saved to the settings file on the first launch.
* Can be provided directly via N8N_ENCRYPTION_KEY or via a file path using N8N_ENCRYPTION_KEY_FILE.
*/
@Env('N8N_ENCRYPTION_KEY')
encryptionKey: string = '';
/** User home directory path; falls back to current working directory if not available. */
readonly userHome: string;
/** n8n data directory (for example, ~/.n8n), used for settings, credentials, and local files. */
readonly n8nFolder: string;
constructor() {
this.n8nFolder = getN8nFolder();
this.userHome = path.dirname(this.n8nFolder);
}
}
@@ -0,0 +1,28 @@
import { Config, Env } from '../decorators';
@Config
export class LicenseConfig {
/** URL of the license server used to validate and refresh licenses. */
@Env('N8N_LICENSE_SERVER_URL')
serverUrl: string = 'https://license.n8n.io/v1';
/** Whether to automatically renew licenses before they expire. */
@Env('N8N_LICENSE_AUTO_RENEW_ENABLED')
autoRenewalEnabled: boolean = true;
/** Activation key used to activate or upgrade the instance license. */
@Env('N8N_LICENSE_ACTIVATION_KEY')
activationKey: string = '';
/** Whether to release floating entitlements back to the pool when the instance shuts down. */
@Env('N8N_LICENSE_DETACH_FLOATING_ON_SHUTDOWN')
detachFloatingOnShutdown: boolean = true;
/** Tenant identifier for the license SDK (for example, self-hosted, sandbox, embed, cloud). */
@Env('N8N_LICENSE_TENANT_ID')
tenantId: number = 1;
/** Ephemeral license certificate. See: https://github.com/n8n-io/license-management?tab=readme-ov-file#concept-ephemeral-entitlements */
@Env('N8N_LICENSE_CERT')
cert: string = '';
}
@@ -0,0 +1,136 @@
import { z } from 'zod';
import { CommaSeparatedStringArray } from '../custom-types';
import { Config, Env, Nested } from '../decorators';
/** Scopes (areas of functionality) to filter logs by. */
export const LOG_SCOPES = [
'concurrency',
'external-secrets',
'license',
'mcp',
'multi-main-setup',
'pruning',
'pubsub',
'push',
'quick-connect',
'redis',
'scaling',
'waiting-executions',
'task-runner',
'task-runner-js',
'task-runner-py',
'insights',
'workflow-activation',
'ssh-client',
'data-table',
'cron',
'community-nodes',
'chat-hub',
'breaking-changes',
'circuit-breaker',
'source-control',
'dynamic-credentials',
'workflow-history-compaction',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];
@Config
export class CronLoggingConfig {
/**
* Interval in minutes to log currently active cron jobs. Set to `0` to disable.
*
* @example `N8N_LOG_CRON_ACTIVE_INTERVAL=30` will log active crons every 30 minutes.
*/
@Env('N8N_LOG_CRON_ACTIVE_INTERVAL')
activeInterval: number = 0;
}
@Config
class FileLoggingConfig {
/**
* Max number of log files to keep, or max number of days to keep logs for.
* Once the limit is reached, the oldest log files will be rotated out.
* If using days, append a `d` suffix. Only for `file` log output.
*
* @example `N8N_LOG_FILE_COUNT_MAX=7` will keep at most 7 files.
* @example `N8N_LOG_FILE_COUNT_MAX=7d` will keep at most 7 days worth of files.
*/
@Env('N8N_LOG_FILE_COUNT_MAX')
fileCountMax: number = 100;
/** Max size (in MiB) for each log file. Only for `file` log output. */
@Env('N8N_LOG_FILE_SIZE_MAX')
fileSizeMax: number = 16;
/** Location of the log files inside `~/.n8n`. Only for `file` log output. */
@Env('N8N_LOG_FILE_LOCATION')
location: string = 'logs/n8n.log';
}
const logLevelSchema = z.enum(['error', 'warn', 'info', 'debug', 'silent']);
type LogLevel = z.infer<typeof logLevelSchema>;
@Config
export class LoggingConfig {
/**
* Minimum level of logs to output. Logs with this or higher level will be output;
* logs with lower levels will not. Exception: `silent` disables all logging.
*
* @example `N8N_LOG_LEVEL=info` will output `error`, `warn` and `info` logs, but not `debug`.
*/
@Env('N8N_LOG_LEVEL', logLevelSchema)
level: LogLevel = 'info';
/**
* Where to output logs to. Options are: `console` or `file` or both in a comma separated list.
*
* @example `N8N_LOG_OUTPUT=console,file` will output to both console and file.
*/
@Env('N8N_LOG_OUTPUT')
outputs: CommaSeparatedStringArray<'console' | 'file'> = ['console'];
/**
* What format the logs should have.
* `text` is only printing the human readable messages.
* `json` is printing one JSON object per line containing the message, level,
* timestamp and all the metadata.
*/
@Env('N8N_LOG_FORMAT')
format: 'text' | 'json' = 'text';
@Nested
file: FileLoggingConfig;
@Nested
cron: CronLoggingConfig;
/**
* Scopes to filter logs by. Nothing is filtered by default.
*
* Supported log scopes:
*
* - `concurrency`
* - `external-secrets`
* - `license`
* - `multi-main-setup`
* - `pruning`
* - `pubsub`
* - `push`
* - `redis`
* - `scaling`
* - `waiting-executions`
* - `task-runner-js`
* - `task-runner-py`
* - `workflow-activation`
* - `insights`
* - `chat-hub`
*
* @example
* `N8N_LOG_SCOPES=license`
* `N8N_LOG_SCOPES=license,waiting-executions`
*/
@Env('N8N_LOG_SCOPES')
scopes: CommaSeparatedStringArray<LogScope> = [];
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class MfaConfig {
/** Whether multi-factor authentication (MFA) is enabled for the instance. */
@Env('N8N_MFA_ENABLED')
enabled: boolean = true;
}
@@ -0,0 +1,16 @@
import { Config, Env } from '../decorators';
@Config
export class MultiMainSetupConfig {
/** Whether to enable multi-main setup when using scaling mode (requires license). */
@Env('N8N_MULTI_MAIN_SETUP_ENABLED')
enabled: boolean = false;
/** Time to live in seconds for the leader lock key; the current leader must renew before this expires. */
@Env('N8N_MULTI_MAIN_SETUP_KEY_TTL')
ttl: number = 10;
/** Interval in seconds between leader eligibility checks in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_CHECK_INTERVAL')
interval: number = 3;
}
@@ -0,0 +1,45 @@
import { Config, Env } from '../decorators';
function isStringArray(input: unknown): input is string[] {
return Array.isArray(input) && input.every((item) => typeof item === 'string');
}
class JsonStringArray extends Array<string> {
constructor(str: string) {
super();
let parsed: unknown;
try {
parsed = JSON.parse(str);
} catch {
return [];
}
return isStringArray(parsed) ? parsed : [];
}
}
@Config
export class NodesConfig {
/** Node types to load. If empty, all available nodes are loaded. Example: `["n8n-nodes-base.hackerNews"]`. */
@Env('NODES_INCLUDE')
include: JsonStringArray = [];
/**
* Node types to exclude from loading. Default excludes `ExecuteCommand` and `LocalFileTrigger` for security.
* Set to an empty array to allow all node types.
*
* @example '["n8n-nodes-base.hackerNews"]'
*/
@Env('NODES_EXCLUDE')
exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'];
/** Node type name used as the default error trigger when workflow execution fails. */
@Env('NODES_ERROR_TRIGGER_TYPE')
errorTriggerType: string = 'n8n-nodes-base.errorTrigger';
/** Whether to enable Python execution on the Code node. */
@Env('N8N_PYTHON_ENABLED')
pythonEnabled: boolean = true;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class PersonalizationConfig {
/** Whether to enable personalization features. */
@Env('N8N_PERSONALIZATION_ENABLED')
enabled: boolean = true;
}
@@ -0,0 +1,16 @@
import { Config, Env } from '../decorators';
@Config
export class PublicApiConfig {
/** When true, the public API is disabled and its routes are not registered. */
@Env('N8N_PUBLIC_API_DISABLED')
disabled: boolean = false;
/** URL path segment for the Public API (for example, /api/v1/...). */
@Env('N8N_PUBLIC_API_ENDPOINT')
path: string = 'api';
/** When true, the Swagger UI for the Public API is not served. */
@Env('N8N_PUBLIC_API_SWAGGERUI_DISABLED')
swaggerUiDisabled: boolean = false;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class RedisConfig {
/** Key prefix for all Redis keys used by n8n (avoids clashes when sharing a Redis instance). */
@Env('N8N_REDIS_KEY_PREFIX')
prefix: string = 'n8n';
}
@@ -0,0 +1,73 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
const runnerModeSchema = z.enum(['internal', 'external']);
export type TaskRunnerMode = z.infer<typeof runnerModeSchema>;
@Config
export class TaskRunnersConfig {
/**
* How the task runner runs: `internal` (child process of n8n) or `external` (separate process).
*/
@Env('N8N_RUNNERS_MODE', runnerModeSchema)
mode: TaskRunnerMode = 'internal';
/** URL path segment where the task runner service is exposed (for example, `/runners`). */
@Env('N8N_RUNNERS_PATH')
path: string = '/runners';
/** Shared secret used to authenticate runner processes with the broker. */
@Env('N8N_RUNNERS_AUTH_TOKEN')
authToken: string = '';
/** Port the task runner broker listens on for runner connections. */
@Env('N8N_RUNNERS_BROKER_PORT')
port: number = 5679;
/** IP address the task runner broker binds to. */
@Env('N8N_RUNNERS_BROKER_LISTEN_ADDRESS')
listenAddress: string = '127.0.0.1';
/** Maximum size in bytes of a payload sent to a runner. Default: 1 GiB. */
@Env('N8N_RUNNERS_MAX_PAYLOAD')
maxPayload: number = 1024 * 1024 * 1024;
/** Node.js `--max-old-space-size` value in MB for the runner process. Empty lets Node choose based on memory. */
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = '';
/** Maximum number of tasks a single runner can execute concurrently. */
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 10;
/**
* How long (in seconds) a task is allowed to take for completion, else the
* task will be aborted. (In internal mode, the runner will also be
* restarted.) Must be greater than 0.
*
* Kept high for backwards compatibility - n8n v3 will reduce this to `60`
*/
@Env('N8N_RUNNERS_TASK_TIMEOUT')
taskTimeout: number = 300; // 5 minutes
/**
* How long (in seconds) a task request can wait for a runner to become
* available before timing out. This prevents workflows from hanging
* indefinitely when no runners are available. Must be greater than 0.
*/
@Env('N8N_RUNNERS_TASK_REQUEST_TIMEOUT')
taskRequestTimeout: number = 60;
/** Interval in seconds between heartbeats from runner to broker; missing heartbeats abort the task (and restart the runner in internal mode). Must be > 0. */
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')
heartbeatInterval: number = 30;
/**
* Whether to disable all security measures in the task runner. **Discouraged for production use.**
* Set to `true` for compatibility with modules that rely on insecure JS features.
*/
@Env('N8N_RUNNERS_INSECURE_MODE')
insecureMode: boolean = false;
}
@@ -0,0 +1,134 @@
import { Config, Env, Nested } from '../decorators';
@Config
class HealthConfig {
/**
* Whether to enable worker health endpoints: `/healthz` (liveness) and `/healthz/readiness` (DB and Redis ready).
*/
@Env('QUEUE_HEALTH_CHECK_ACTIVE')
active: boolean = false;
/** Port the worker HTTP server listens on for health checks. */
@Env('QUEUE_HEALTH_CHECK_PORT')
port: number = 5678;
/** IP address the worker server binds to. Use `::` for all interfaces. */
@Env('N8N_WORKER_SERVER_ADDRESS')
address: string = '::';
}
@Config
class RedisConfig {
/** Redis database for Bull queue. */
@Env('QUEUE_BULL_REDIS_DB')
db: number = 0;
/** Redis host for Bull queue. */
@Env('QUEUE_BULL_REDIS_HOST')
host: string = 'localhost';
/** Password to authenticate with Redis. */
@Env('QUEUE_BULL_REDIS_PASSWORD')
password: string = '';
/** Port for Redis to listen on. */
@Env('QUEUE_BULL_REDIS_PORT')
port: number = 6379;
/** Max cumulative timeout (in milliseconds) of connection retries before process exit. */
@Env('QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD')
timeoutThreshold: number = 10_000;
/** Slot refresh timeout (in milliseconds) before a timeout occurs while refreshing slots from the cluster. */
@Env('QUEUE_BULL_REDIS_SLOT_REFRESH_TIMEOUT')
slotsRefreshTimeout: number = 1_000;
/** Slot refresh interval (in milliseconds) between every automatic slot refresh. */
@Env('QUEUE_BULL_REDIS_SLOT_REFRESH_INTERVAL')
slotsRefreshInterval: number = 5_000;
/** Redis username. Redis 6.0 or higher required. */
@Env('QUEUE_BULL_REDIS_USERNAME')
username: string = '';
/** Redis cluster startup nodes, as comma-separated list of `{host}:{port}` pairs. @example 'redis-1:6379,redis-2:6379' */
@Env('QUEUE_BULL_REDIS_CLUSTER_NODES')
clusterNodes: string = '';
/** Whether to enable TLS on Redis connections. */
@Env('QUEUE_BULL_REDIS_TLS')
tls: boolean = false;
/**
* DNS resolution strategy for Redis hostnames on initial client connection.
* - `LOOKUP` (default): Use system DNS resolver to resolve hostnames to IP addresses.
* - `NONE`: Disable DNS resolution and pass hostnames directly to Redis client.
*
* DNS lookups can be error prone, especially in combination with TLS certificates.
* Especially AWS Elasticache cluster connections often lead to invalid certificate errors due to hostname/ip mismatches.
* For AWS ElastiCache clusters with TLS, it is recommended to set this option to `NONE`.
* @see https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls
*/
@Env('QUEUE_BULL_REDIS_DNS_LOOKUP_STRATEGY')
dnsResolveStrategy: 'LOOKUP' | 'NONE' = 'LOOKUP';
/** Whether to enable dual-stack hostname resolution for Redis connections. */
@Env('QUEUE_BULL_REDIS_DUALSTACK')
dualStack: boolean = false;
/** Whether to enable TCP keep-alive on Redis connections. */
@Env('QUEUE_BULL_REDIS_KEEP_ALIVE')
keepAlive: boolean = false;
/** TCP keep-alive initial delay in milliseconds. */
@Env('QUEUE_BULL_REDIS_KEEP_ALIVE_DELAY')
keepAliveDelay: number = 5000;
/** TCP keep-alive interval in milliseconds. */
@Env('QUEUE_BULL_REDIS_KEEP_ALIVE_INTERVAL')
keepAliveInterval: number = 5000;
/** Whether to reconnect to Redis on READONLY errors i.e., failover events. */
@Env('QUEUE_BULL_REDIS_RECONNECT_ON_FAILOVER')
reconnectOnFailover: boolean = true;
}
@Config
class SettingsConfig {
/** How long (in milliseconds) is the lease period for a worker processing a job. */
@Env('QUEUE_WORKER_LOCK_DURATION')
lockDuration: number = 60_000;
/** How often (in milliseconds) a worker must renew the lease. */
@Env('QUEUE_WORKER_LOCK_RENEW_TIME')
lockRenewTime: number = 10_000;
/** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */
@Env('QUEUE_WORKER_STALLED_INTERVAL')
stalledInterval: number = 30_000;
}
@Config
class BullConfig {
/** Prefix for Bull keys on Redis. @example 'bull:jobs:23' */
@Env('QUEUE_BULL_PREFIX')
prefix: string = 'bull';
@Nested
redis: RedisConfig;
/** @deprecated How long (in seconds) a worker must wait for active executions to finish before exiting. Use `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` instead */
@Env('QUEUE_WORKER_TIMEOUT')
gracefulShutdownTimeout: number = 30;
@Nested
settings: SettingsConfig;
}
@Config
export class ScalingModeConfig {
@Nested
health: HealthConfig;
@Nested
bull: BullConfig;
}
@@ -0,0 +1,88 @@
import z from 'zod';
import { Config, Env } from '../decorators';
const crossOriginOpenerPolicySchema = z.enum(['same-origin', 'same-origin-allow-popups']);
@Config
export class SecurityConfig {
/**
* Dirs that the `ReadWriteFile` and `ReadBinaryFiles` nodes are allowed to access. Separate multiple dirs with semicolon `;`.
* Set to an empty string to disable restrictions (insecure, not recommended for production).
*
* @example N8N_RESTRICT_FILE_ACCESS_TO=/home/john/my-n8n-files
*/
@Env('N8N_RESTRICT_FILE_ACCESS_TO')
restrictFileAccessTo: string = '~/.n8n-files';
/**
* Whether to block nodes from accessing files at dirs internally used by n8n:
* - `~/.n8n`
* - `~/.cache/n8n/public`
* - any dirs specified by `N8N_CONFIG_FILES`, `N8N_CUSTOM_EXTENSIONS`, `N8N_BINARY_DATA_STORAGE_PATH`, `N8N_UM_EMAIL_TEMPLATES_INVITE`, and `UM_EMAIL_TEMPLATES_PWRESET`.
*/
@Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES')
blockFileAccessToN8nFiles: boolean = true;
/**
* Regex patterns for files and folders that `ReadWriteFile` and `ReadBinaryFiles` nodes cannot access.
* Separate multiple patterns with semicolons. Default blocks `.git`. Set to empty to disable pattern-based blocking.
*/
@Env('N8N_BLOCK_FILE_PATTERNS')
blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$';
/**
* In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed.
*/
@Env('N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW')
daysAbandonedWorkflow: number = 90;
/**
* Set [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) headers as [helmet.js](https://helmetjs.github.io/#content-security-policy) nested directives object.
* Example: { "frame-ancestors": ["http://localhost:3000"] }
*/
// TODO: create a new type that parses and validates this string into a strongly-typed object
@Env('N8N_CONTENT_SECURITY_POLICY')
contentSecurityPolicy: string = '{}';
/**
* Whether to set the `Content-Security-Policy-Report-Only` header instead of `Content-Security-Policy`.
*/
@Env('N8N_CONTENT_SECURITY_POLICY_REPORT_ONLY')
contentSecurityPolicyReportOnly: boolean = false;
/**
* Configuration for the `Cross-Origin-Opener-Policy` header.
*/
@Env('N8N_CROSS_ORIGIN_OPENER_POLICY', crossOriginOpenerPolicySchema)
crossOriginOpenerPolicy: z.infer<typeof crossOriginOpenerPolicySchema> = 'same-origin';
/**
* Whether to disable HTML sandboxing for webhooks. The sandboxing mechanism uses CSP headers now,
* but the name is kept for backwards compatibility.
*/
@Env('N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX')
disableWebhookHtmlSandboxing: boolean = false;
/**
* Whether to disable bare repositories support in the Git node.
*/
@Env('N8N_GIT_NODE_DISABLE_BARE_REPOS')
disableBareRepos: boolean = true;
/** Whether to allow access to AWS system credentials, e.g. in awsAssumeRole credentials */
@Env('N8N_AWS_SYSTEM_CREDENTIALS_ACCESS_ENABLED')
awsSystemCredentialsAccess: boolean = false;
/**
* Whether to enable hooks (like pre-commit hooks) for the Git node.
*/
@Env('N8N_GIT_NODE_ENABLE_HOOKS')
enableGitNodeHooks: boolean = false;
/**
* Whether to enable arbitrary git config keys.
*/
@Env('N8N_GIT_NODE_ENABLE_ALL_CONFIG_KEYS')
enableGitNodeAllConfigKeys: boolean = false;
}
@@ -0,0 +1,63 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
/** Schema for sample rates (0.0 to 1.0). */
export const sampleRateSchema = z.number({ coerce: true }).min(0).max(1);
@Config
export class SentryConfig {
/** Sentry DSN (data source name) for the backend. */
@Env('N8N_SENTRY_DSN')
backendDsn: string = '';
/** Sentry DSN (data source name) for the frontend. */
@Env('N8N_FRONTEND_SENTRY_DSN')
frontendDsn: string = '';
/**
* Sample rate for Sentry traces (0.0 to 1.0).
* This determines whether tracing is enabled and what percentage of
* transactions are traced.
*
* @default 0 (disabled)
*/
@Env('N8N_SENTRY_TRACES_SAMPLE_RATE', sampleRateSchema)
tracesSampleRate: number = 0;
/**
* Sample rate for Sentry profiling (0.0 to 1.0).
* This determines whether profiling is enabled and what percentage of
* transactions are profiled.
*
* @default 0 (disabled)
*/
@Env('N8N_SENTRY_PROFILES_SAMPLE_RATE', sampleRateSchema)
profilesSampleRate: number = 0;
/**
* Threshold in milliseconds for event loop block detection.
* When the event loop is blocked for longer than this threshold,
* Sentry will report it.
*
* @default 500
*/
@Env('N8N_SENTRY_EVENT_LOOP_BLOCK_THRESHOLD', z.number({ coerce: true }).int().positive())
eventLoopBlockThreshold: number = 500;
/**
* Environment of the n8n instance.
*
* @example 'production'
*/
@Env('ENVIRONMENT')
environment: string = '';
/**
* Name of the deployment, e.g. cloud account name.
*
* @example 'janober'
*/
@Env('DEPLOYMENT_NAME')
deploymentName: string = '';
}
@@ -0,0 +1,76 @@
import { Config, Env, Nested } from '../decorators';
@Config
class SamlConfig {
/** Whether SAML-based single sign-on is enabled. */
@Env('N8N_SSO_SAML_LOGIN_ENABLED')
loginEnabled: boolean = false;
/** Label shown on the login button for SAML (for example, "Sign in with SAML"). */
@Env('N8N_SSO_SAML_LOGIN_LABEL')
loginLabel: string = '';
}
@Config
class OidcConfig {
/** Whether OIDC-based single sign-on is enabled. */
@Env('N8N_SSO_OIDC_LOGIN_ENABLED')
loginEnabled: boolean = false;
}
@Config
class LdapConfig {
/** Whether LDAP-based single sign-on is enabled. */
@Env('N8N_SSO_LDAP_LOGIN_ENABLED')
loginEnabled: boolean = false;
/** Label shown on the login button for LDAP (for example, "Sign in with LDAP"). */
@Env('N8N_SSO_LDAP_LOGIN_LABEL')
loginLabel: string = '';
}
@Config
class ProvisioningConfig {
/** Whether to set the user's instance role from an SSO claim during login. */
@Env('N8N_SSO_SCOPES_PROVISION_INSTANCE_ROLE')
scopesProvisionInstanceRole: boolean = false;
/** Whether to set projectrole mappings from an SSO claim during login. */
@Env('N8N_SSO_SCOPES_PROVISION_PROJECT_ROLES')
scopesProvisionProjectRoles: boolean = false;
/** Name of the OAuth scope to request for SSO provisioning. */
@Env('N8N_SSO_SCOPES_NAME')
scopesName: string = 'n8n';
/** Name of the SSO claim that contains the user's instance role (for provisioning). */
@Env('N8N_SSO_SCOPES_INSTANCE_ROLE_CLAIM_NAME')
scopesInstanceRoleClaimName: string = 'n8n_instance_role';
/** Name of the SSO claim that contains projectrole mappings (for provisioning). */
@Env('N8N_SSO_SCOPES_PROJECTS_ROLES_CLAIM_NAME')
scopesProjectsRolesClaimName: string = 'n8n_projects';
}
@Config
export class SsoConfig {
/** Whether to automatically create user accounts when someone signs in via SSO for the first time. */
@Env('N8N_SSO_JUST_IN_TIME_PROVISIONING')
justInTimeProvisioning: boolean = true;
/** Whether the login screen redirects directly to SSO instead of showing email/password. */
@Env('N8N_SSO_REDIRECT_LOGIN_TO_SSO')
redirectLoginToSso: boolean = true;
@Nested
saml: SamlConfig;
@Nested
oidc: OidcConfig;
@Nested
ldap: LdapConfig;
@Nested
provisioning: ProvisioningConfig;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class TagsConfig {
/** When true, workflow tags are disabled (no tagging UI or filtering by tag). */
@Env('N8N_WORKFLOW_TAGS_DISABLED')
disabled: boolean = false;
}
@@ -0,0 +1,16 @@
import { Config, Env } from '../decorators';
@Config
export class TemplatesConfig {
/** Whether to enable loading and showing workflow templates. */
@Env('N8N_TEMPLATES_ENABLED')
enabled: boolean = true;
/** Base URL for the workflow templates API. */
@Env('N8N_TEMPLATES_HOST')
host: string = 'https://api.n8n.io/api/';
/** Base URL for fetching dynamic (contextual) templates. */
@Env('N8N_DYNAMIC_TEMPLATES_HOST')
dynamicTemplatesHost: string = 'https://dynamic-templates.n8n.io/templates';
}
@@ -0,0 +1,137 @@
import { z } from 'zod';
import { Config, Env, Nested } from '../decorators';
@Config
class SmtpAuth {
/** SMTP login username */
@Env('N8N_SMTP_USER')
user: string = '';
/** SMTP login password */
@Env('N8N_SMTP_PASS')
pass: string = '';
/** SMTP OAuth Service Client */
@Env('N8N_SMTP_OAUTH_SERVICE_CLIENT')
serviceClient: string = '';
/** SMTP OAuth Private Key */
@Env('N8N_SMTP_OAUTH_PRIVATE_KEY')
privateKey: string = '';
}
@Config
class SmtpConfig {
/** SMTP server host */
@Env('N8N_SMTP_HOST')
host: string = '';
/** SMTP server port */
@Env('N8N_SMTP_PORT')
port: number = 465;
/** Whether to use SSL for SMTP */
@Env('N8N_SMTP_SSL')
secure: boolean = true;
/** Whether to use STARTTLS for SMTP when SSL is disabled */
@Env('N8N_SMTP_STARTTLS')
startTLS: boolean = true;
/** How to display sender name */
@Env('N8N_SMTP_SENDER')
sender: string = '';
@Nested
auth: SmtpAuth;
}
@Config
export class TemplateConfig {
/** Overrides default HTML template for inviting new people (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_INVITE')
'user-invited': string = '';
/** Overrides default HTML template for resetting password (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_PWRESET')
'password-reset-requested': string = '';
/** Overrides default HTML template for notifying that a workflow was shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED')
'workflow-shared': string = '';
/** Overrides default HTML template for notifying that a workflow was deactivated (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_AUTODEACTIVATED')
'workflow-deactivated': string = '';
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
'credentials-shared': string = '';
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_PROJECT_SHARED')
'project-shared': string = '';
/** Overrides default HTML template for notifying that a workflow failed in production (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_FAILURE')
'workflow-failure': string = '';
}
const emailModeSchema = z.enum(['', 'smtp']);
type EmailMode = z.infer<typeof emailModeSchema>;
@Config
class EmailConfig {
/** Email delivery method: `smtp` or empty (disabled). */
@Env('N8N_EMAIL_MODE', emailModeSchema)
mode: EmailMode = 'smtp';
@Nested
smtp: SmtpConfig;
@Nested
template: TemplateConfig;
}
const INVALID_JWT_REFRESH_TIMEOUT_WARNING =
'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to be smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0.';
@Config
export class UserManagementConfig {
@Nested
emails: EmailConfig;
/** JWT secret to use. If unset, n8n will generate its own. */
@Env('N8N_USER_MANAGEMENT_JWT_SECRET')
jwtSecret: string = '';
/** How long (in hours) before the JWT expires. */
@Env('N8N_USER_MANAGEMENT_JWT_DURATION_HOURS')
jwtSessionDurationHours: number = 168;
/**
* Security Control: Invite Link Exposure Prevention
*
* When enabled, prevents exposure of invite URLs in API responses to users
* with 'user:create' permission, mitigating account takeover risks via
* invite link leakage (e.g., compromised admin accounts, network interception).
*/
@Env('N8N_INVITE_LINKS_EMAIL_ONLY')
inviteLinksEmailOnly: boolean = false;
/**
* How long (in hours) before expiration to automatically refresh it.
* - `0` means 25% of `N8N_USER_MANAGEMENT_JWT_DURATION_HOURS`.
* - `-1` means it will never refresh. This forces users to log back in after expiration.
*/
@Env('N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS')
jwtRefreshTimeoutHours: number = 0;
sanitize() {
if (this.jwtRefreshTimeoutHours >= this.jwtSessionDurationHours) {
console.warn(INVALID_JWT_REFRESH_TIMEOUT_WARNING);
this.jwtRefreshTimeoutHours = 0;
}
}
}
@@ -0,0 +1,24 @@
import { Config, Env } from '../decorators';
@Config
export class VersionNotificationsConfig {
/** Whether to check for and show in-app notifications about new n8n versions. */
@Env('N8N_VERSION_NOTIFICATIONS_ENABLED')
enabled: boolean = true;
/** URL used to fetch current n8n version information. */
@Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT')
endpoint: string = 'https://api.n8n.io/api/versions/';
/** Whether to fetch and show "What's New" content. Requires version notifications to be enabled. */
@Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENABLED')
whatsNewEnabled: boolean = true;
/** URL used to fetch "What's New" articles. */
@Env('N8N_VERSION_NOTIFICATIONS_WHATS_NEW_ENDPOINT')
whatsNewEndpoint: string = 'https://api.n8n.io/api/whats-new';
/** URL linked from the versions panel (for example, upgrade instructions). */
@Env('N8N_VERSION_NOTIFICATIONS_INFO_URL')
infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
}
@@ -0,0 +1,50 @@
import { Config, Env } from '../decorators';
/**
* Config for the workflow history compaction service, which compacts recent versions and trims old ones to manage storage.
*/
@Config
export class WorkflowHistoryCompactionConfig {
/**
* Minimum age in hours before a workflow version is eligible for optimization.
* Versions compacted are those with `createdAt` between `optimizingMinimumAgeHours - optimizingTimeWindowHours` and `optimizingMinimumAgeHours`.
*/
@Env('N8N_WORKFLOW_HISTORY_OPTIMIZING_MINIMUM_AGE_HOURS')
optimizingMinimumAgeHours: number = 0.25;
/**
* Time window in hours used when selecting versions to optimize.
* Optimization runs roughly every `optimizingTimeWindowHours / 2` hours.
*/
@Env('N8N_WORKFLOW_HISTORY_OPTIMIZING_TIME_WINDOW_HOURS')
optimizingTimeWindowHours: number = 2;
/**
* Minimum age in days before a workflow version is eligible for trimming.
* Versions trimmed are those with `createdAt` between `trimmingMinimumAgeDays - trimmingTimeWindowDays` and `trimmingMinimumAgeDays`.
*/
@Env('N8N_WORKFLOW_HISTORY_TRIMMING_MINIMUM_AGE_DAYS')
trimmingMinimumAgeDays: number = 7;
/**
* Time window in days used when selecting versions to trim. Trimming runs once per day.
*/
@Env('N8N_WORKFLOW_HISTORY_TRIMMING_TIME_WINDOW_DAYS')
trimmingTimeWindowDays: number = 2;
/** Maximum number of workflow versions to process per workflow before waiting `batchDelayMs` before the next workflow. */
@Env('N8N_WORKFLOW_HISTORY_COMPACTION_BATCH_SIZE')
batchSize: number = 100;
/** Delay in milliseconds after processing `batchSize` versions before moving to the next workflow. */
@Env('N8N_WORKFLOW_HISTORY_COMPACTION_BATCH_DELAY_MS')
batchDelayMs: number = 1_000;
/**
* Whether to run a trim pass on startup (for example, to fix existing history or for development).
*
* @warning Blocking; can significantly increase startup time.
*/
@Env('N8N_WORKFLOW_HISTORY_COMPACTION_TRIM_ON_START_UP')
trimOnStartUp: boolean = false;
}
@@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class WorkflowHistoryConfig {
/** How long in hours to keep workflow history versions before pruning. Use `-1` to keep forever. */
@Env('N8N_WORKFLOW_HISTORY_PRUNE_TIME')
pruneTime: number = -1;
}
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
const callerPolicySchema = z.enum(['any', 'none', 'workflowsFromAList', 'workflowsFromSameOwner']);
type CallerPolicy = z.infer<typeof callerPolicySchema>;
@Config
export class WorkflowsConfig {
/** Default name suggested when creating a new workflow. */
@Env('WORKFLOWS_DEFAULT_NAME')
defaultName: string = 'My workflow';
/** Default policy for which workflows are allowed to call this workflow (for example, same owner, any, none). */
@Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', callerPolicySchema)
callerPolicyDefaultOption: CallerPolicy = 'workflowsFromSameOwner';
/** Number of workflows to activate in parallel during startup. */
@Env('N8N_WORKFLOW_ACTIVATION_BATCH_SIZE')
activationBatchSize: number = 1;
/** Whether to build and maintain workflow dependency indexes (for example, for subworkflow callers). */
@Env('N8N_WORKFLOWS_INDEXING_ENABLED')
indexingEnabled: boolean = true;
/** Whether to use the workflow publication service. Still under development. */
@Env('N8N_USE_WORKFLOW_PUBLICATION_SERVICE')
useWorkflowPublicationService: boolean = false;
}
+19
View File
@@ -0,0 +1,19 @@
abstract class StringArray<T extends string> extends Array<T> {
constructor(str: string, delimiter: string) {
super();
const parsed = str.split(delimiter) as this;
return parsed.filter((i) => typeof i === 'string' && i.length);
}
}
export class CommaSeparatedStringArray<T extends string> extends StringArray<T> {
constructor(str: string) {
super(str, ',');
}
}
export class ColonSeparatedStringArray<T extends string = string> extends StringArray<T> {
constructor(str: string) {
super(str, ':');
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'reflect-metadata';
import { Container, Service } from '@n8n/di';
import { readFileSync } from 'fs';
import { z } from 'zod';
// eslint-disable-next-line @typescript-eslint/no-restricted-types
type Class = Function;
type Constructable<T = unknown> = new (rawValue: string) => T;
type PropertyKey = string | symbol;
type PropertyType = number | boolean | string | Class;
interface PropertyMetadata {
type: PropertyType;
envName?: string;
schema?: z.ZodType<unknown>;
}
const globalMetadata = new Map<Class, Map<PropertyKey, PropertyMetadata>>();
const readEnv = (envName: string) => {
if (envName in process.env) return process.env[envName];
// Read the value from a file, if "_FILE" environment variable is defined
const filePath = process.env[`${envName}_FILE`];
if (filePath) {
const value = readFileSync(filePath, 'utf8');
if (value !== value.trim()) {
console.warn(
`[n8n] Warning: The file specified by ${envName}_FILE contains leading or trailing whitespace, which may cause authentication failures.`,
);
}
return value;
}
return undefined;
};
export const Config: ClassDecorator = (ConfigClass: Class) => {
const factory = function (...args: unknown[]) {
const config = new (ConfigClass as new (...a: unknown[]) => Record<PropertyKey, unknown>)(
...args,
);
const classMetadata = globalMetadata.get(ConfigClass);
if (!classMetadata) {
throw new Error('Invalid config class: ' + ConfigClass.name);
}
for (const [key, { type, envName, schema }] of classMetadata) {
if (typeof type === 'function' && globalMetadata.has(type)) {
config[key] = Container.get(type as Constructable);
} else if (envName) {
const value = readEnv(envName);
if (value === undefined) continue;
if (schema) {
const result = schema.safeParse(value);
if (result.error) {
console.warn(
`Invalid value for ${envName} - ${result.error.issues[0].message}. Falling back to default value.`,
);
continue;
}
config[key] = result.data;
} else if (type === Number) {
const parsed = Number(value);
if (isNaN(parsed)) {
console.warn(`Invalid number value for ${envName}: ${value}`);
} else {
config[key] = parsed;
}
} else if (type === Boolean) {
if (['true', '1'].includes(value.toLowerCase())) {
config[key] = true;
} else if (['false', '0'].includes(value.toLowerCase())) {
config[key] = false;
} else {
console.warn(`Invalid boolean value for ${envName}: ${value}`);
}
} else if (type === Date) {
const timestamp = Date.parse(value);
if (isNaN(timestamp)) {
console.warn(`Invalid timestamp value for ${envName}: ${value}`);
} else {
config[key] = new Date(timestamp);
}
} else if (type === String) {
config[key] = value.trim().replace(/^(['"])(.*)\1$/, '$2');
} else {
config[key] = new (type as Constructable)(value);
}
}
}
if (typeof config.sanitize === 'function') config.sanitize();
return config;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service({ factory })(ConfigClass);
};
export const Nested: PropertyDecorator = (target: object, key: PropertyKey) => {
const ConfigClass = target.constructor;
const classMetadata = globalMetadata.get(ConfigClass) ?? new Map<PropertyKey, PropertyMetadata>();
const type = Reflect.getMetadata('design:type', target, key) as PropertyType;
classMetadata.set(key, { type });
globalMetadata.set(ConfigClass, classMetadata);
};
export const Env =
(envName: string, schema?: PropertyMetadata['schema']): PropertyDecorator =>
(target: object, key: PropertyKey) => {
const ConfigClass = target.constructor;
const classMetadata =
globalMetadata.get(ConfigClass) ?? new Map<PropertyKey, PropertyMetadata>();
const type = Reflect.getMetadata('design:type', target, key) as PropertyType;
const isZodSchema = schema instanceof z.ZodType;
if (type === Object && !isZodSchema) {
throw new Error(
`Invalid decorator metadata on key "${key as string}" on ${ConfigClass.name}\n Please use explicit typing on all config fields`,
);
}
classMetadata.set(key, { type, envName, schema });
globalMetadata.set(ConfigClass, classMetadata);
};
+232
View File
@@ -0,0 +1,232 @@
import { z } from 'zod';
import { AiAssistantConfig } from './configs/ai-assistant.config';
import { AiBuilderConfig } from './configs/ai-builder.config';
import { AiConfig } from './configs/ai.config';
import { AuthConfig } from './configs/auth.config';
import { CacheConfig } from './configs/cache.config';
import { ChatHubConfig } from './configs/chat-hub.config';
import { CredentialsConfig } from './configs/credentials.config';
import { DataTableConfig } from './configs/data-table.config';
import { DatabaseConfig } from './configs/database.config';
import { DeploymentConfig } from './configs/deployment.config';
import { DiagnosticsConfig } from './configs/diagnostics.config';
import { DynamicBannersConfig } from './configs/dynamic-banners.config';
import { EndpointsConfig } from './configs/endpoints.config';
import { EventBusConfig } from './configs/event-bus.config';
import { ExecutionsConfig } from './configs/executions.config';
import { ExternalHooksConfig } from './configs/external-hooks.config';
import { GenericConfig } from './configs/generic.config';
import { HiringBannerConfig } from './configs/hiring-banner.config';
import { LicenseConfig } from './configs/license.config';
import { LoggingConfig } from './configs/logging.config';
import { MfaConfig } from './configs/mfa.config';
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
import { NodesConfig } from './configs/nodes.config';
import { PersonalizationConfig } from './configs/personalization.config';
import { PublicApiConfig } from './configs/public-api.config';
import { RedisConfig } from './configs/redis.config';
import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config';
import { SentryConfig } from './configs/sentry.config';
import { SsoConfig } from './configs/sso.config';
import { TagsConfig } from './configs/tags.config';
import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config';
import { VersionNotificationsConfig } from './configs/version-notifications.config';
import { WorkflowHistoryCompactionConfig } from './configs/workflow-history-compaction.config';
import { WorkflowHistoryConfig } from './configs/workflow-history.config';
import { WorkflowsConfig } from './configs/workflows.config';
import { Config, Env, Nested } from './decorators';
export { Config, Env, Nested } from './decorators';
export { AiConfig } from './configs/ai.config';
export { DatabaseConfig, SqliteConfig } from './configs/database.config';
export { InstanceSettingsConfig } from './configs/instance-settings-config';
export { sampleRateSchema } from './configs/sentry.config';
export type { TaskRunnerMode } from './configs/runners.config';
export { TaskRunnersConfig } from './configs/runners.config';
export { SecurityConfig } from './configs/security.config';
export { ExecutionsConfig } from './configs/executions.config';
export { LOG_SCOPES } from './configs/logging.config';
export type { LogScope } from './configs/logging.config';
export { WorkflowsConfig } from './configs/workflows.config';
export * from './custom-types';
export { DeploymentConfig } from './configs/deployment.config';
export { MfaConfig } from './configs/mfa.config';
export { HiringBannerConfig } from './configs/hiring-banner.config';
export { PersonalizationConfig } from './configs/personalization.config';
export { NodesConfig } from './configs/nodes.config';
export { CronLoggingConfig } from './configs/logging.config';
export { WorkflowHistoryCompactionConfig } from './configs/workflow-history-compaction.config';
export { ChatHubConfig } from './configs/chat-hub.config';
const protocolSchema = z.enum(['http', 'https']);
export type Protocol = z.infer<typeof protocolSchema>;
@Config
export class GlobalConfig {
@Nested
auth: AuthConfig;
@Nested
database: DatabaseConfig;
@Nested
credentials: CredentialsConfig;
@Nested
userManagement: UserManagementConfig;
@Nested
versionNotifications: VersionNotificationsConfig;
@Nested
dynamicBanners: DynamicBannersConfig;
@Nested
publicApi: PublicApiConfig;
@Nested
externalHooks: ExternalHooksConfig;
@Nested
templates: TemplatesConfig;
@Nested
eventBus: EventBusConfig;
@Nested
nodes: NodesConfig;
@Nested
workflows: WorkflowsConfig;
@Nested
sentry: SentryConfig;
/** Path n8n is deployed to */
@Env('N8N_PATH')
path: string = '/';
/** Host name n8n can be reached */
@Env('N8N_HOST')
host: string = 'localhost';
/** HTTP port n8n can be reached */
@Env('N8N_PORT')
port: number = 5678;
/** IP address n8n should listen on */
@Env('N8N_LISTEN_ADDRESS')
listen_address: string = '::';
/** HTTP Protocol via which n8n can be reached */
@Env('N8N_PROTOCOL', protocolSchema)
protocol: Protocol = 'http';
@Nested
endpoints: EndpointsConfig;
@Nested
cache: CacheConfig;
@Nested
queue: ScalingModeConfig;
@Nested
logging: LoggingConfig;
@Nested
taskRunners: TaskRunnersConfig;
@Nested
multiMainSetup: MultiMainSetupConfig;
@Nested
generic: GenericConfig;
@Nested
license: LicenseConfig;
@Nested
security: SecurityConfig;
@Nested
executions: ExecutionsConfig;
@Nested
diagnostics: DiagnosticsConfig;
@Nested
aiAssistant: AiAssistantConfig;
@Nested
aiBuilder: AiBuilderConfig;
@Nested
tags: TagsConfig;
@Nested
workflowHistory: WorkflowHistoryConfig;
@Nested
deployment: DeploymentConfig;
@Nested
mfa: MfaConfig;
@Nested
hiringBanner: HiringBannerConfig;
@Nested
personalization: PersonalizationConfig;
@Nested
sso: SsoConfig;
/** Default locale for the UI. */
@Env('N8N_DEFAULT_LOCALE')
defaultLocale: string = 'en';
/** Whether to hide the page that shows active workflows and executions count. */
@Env('N8N_HIDE_USAGE_PAGE')
hideUsagePage: boolean = false;
/** Number of reverse proxies n8n is running behind. */
@Env('N8N_PROXY_HOPS')
proxy_hops: number = 0;
/** SSL key for HTTPS protocol. */
@Env('N8N_SSL_KEY')
ssl_key: string = '';
/** SSL cert for HTTPS protocol. */
@Env('N8N_SSL_CERT')
ssl_cert: string = '';
/** Public URL where the editor is accessible. Also used for emails sent from n8n. */
@Env('N8N_EDITOR_BASE_URL')
editorBaseUrl: string = '';
/** URLs to external frontend hooks files, separated by semicolons. */
@Env('EXTERNAL_FRONTEND_HOOKS_URLS')
externalFrontendHooksUrls: string = '';
@Nested
redis: RedisConfig;
@Nested
ai: AiConfig;
@Nested
dataTable: DataTableConfig;
@Nested
workflowHistoryCompaction: WorkflowHistoryCompactionConfig;
@Nested
chatHub: ChatHubConfig;
}
+11
View File
@@ -0,0 +1,11 @@
import path from 'node:path';
/**
* Computes the n8n folder path based on environment variables.
* This is used by various configs that need to know the n8n installation directory.
*/
export function getN8nFolder(): string {
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
return path.join(userHome, '.n8n');
}
+618
View File
@@ -0,0 +1,618 @@
import { Container } from '@n8n/di';
import fs from 'fs';
import { mock } from 'jest-mock-extended';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { UserManagementConfig } from '../src/configs/user-management.config';
import type { DatabaseConfig } from '../src/index';
import { GlobalConfig } from '../src/index';
jest.mock('fs');
const mockFs = mock<typeof fs>();
fs.readFileSync = mockFs.readFileSync;
const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {});
describe('GlobalConfig', () => {
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
const defaultConfig: GlobalConfig = {
path: '/',
host: 'localhost',
port: 5678,
listen_address: '::',
protocol: 'http',
auth: {
cookie: {
samesite: 'lax',
secure: true,
},
},
defaultLocale: 'en',
hideUsagePage: false,
deployment: {
type: 'default',
},
mfa: {
enabled: true,
},
hiringBanner: {
enabled: true,
},
personalization: {
enabled: true,
},
proxy_hops: 0,
ssl_key: '',
ssl_cert: '',
editorBaseUrl: '',
dataTable: {
maxSize: 50 * 1024 * 1024,
sizeCheckCacheDuration: 5 * 1000,
cleanupIntervalMs: 60 * 1000,
fileMaxAgeMs: 2 * 60 * 1000,
uploadDir: path.join(tmpdir(), 'n8nDataTableUploads'),
},
database: {
logging: {
enabled: false,
maxQueryExecutionTime: 0,
options: 'error',
},
postgresdb: {
database: 'n8n',
host: 'localhost',
password: '',
poolSize: 2,
port: 5432,
schema: 'public',
connectionTimeoutMs: 20_000,
idleTimeoutMs: 30_000,
statementTimeoutMs: 5 * 60 * 1000,
ssl: {
ca: '',
cert: '',
enabled: false,
key: '',
rejectUnauthorized: true,
},
user: 'postgres',
},
sqlite: {
database: 'database.sqlite',
executeVacuumOnStartup: false,
poolSize: 3,
},
tablePrefix: '',
type: 'sqlite',
pingIntervalSeconds: 2,
} as DatabaseConfig,
credentials: {
defaultName: 'My credentials',
overwrite: {
data: '{}',
endpoint: '',
endpointAuthToken: '',
persistence: false,
},
},
userManagement: {
inviteLinksEmailOnly: false,
jwtSecret: '',
jwtSessionDurationHours: 168,
jwtRefreshTimeoutHours: 0,
emails: {
mode: 'smtp',
smtp: {
host: '',
port: 465,
secure: true,
sender: '',
startTLS: true,
auth: {
pass: '',
user: '',
privateKey: '',
serviceClient: '',
},
},
template: {
'credentials-shared': '',
'user-invited': '',
'password-reset-requested': '',
'workflow-deactivated': '',
'workflow-failure': '',
'workflow-shared': '',
'project-shared': '',
},
},
} as UserManagementConfig,
eventBus: {
checkUnsentInterval: 0,
crashRecoveryMode: 'extensive',
logWriter: {
keepLogCount: 3,
logBaseName: 'n8nEventLog',
maxFileSizeInKB: 10240,
},
},
externalHooks: {
files: [],
},
nodes: {
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
pythonEnabled: true,
},
publicApi: {
disabled: false,
path: 'api',
swaggerUiDisabled: false,
},
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
dynamicTemplatesHost: 'https://dynamic-templates.n8n.io/templates',
},
versionNotifications: {
enabled: true,
endpoint: 'https://api.n8n.io/api/versions/',
whatsNewEnabled: true,
whatsNewEndpoint: 'https://api.n8n.io/api/whats-new',
infoUrl: 'https://docs.n8n.io/hosting/installation/updating/',
},
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
},
workflows: {
defaultName: 'My workflow',
callerPolicyDefaultOption: 'workflowsFromSameOwner',
activationBatchSize: 1,
indexingEnabled: true,
useWorkflowPublicationService: false,
},
endpoints: {
metrics: {
enable: false,
prefix: 'n8n_',
includeWorkflowIdLabel: false,
includeWorkflowNameLabel: false,
includeDefaultMetrics: true,
includeMessageEventBusMetrics: false,
includeNodeTypeLabel: false,
includeCacheMetrics: false,
includeApiEndpoints: false,
includeApiPathLabel: false,
includeApiMethodLabel: false,
includeCredentialTypeLabel: false,
includeApiStatusCodeLabel: false,
includeQueueMetrics: false,
queueMetricsInterval: 20,
activeWorkflowCountInterval: 60,
includeWorkflowStatistics: false,
workflowStatisticsInterval: 300,
},
additionalNonUIRoutes: '',
disableProductionWebhooksOnMainProcess: false,
disableUi: false,
form: 'form',
formTest: 'form-test',
formWaiting: 'form-waiting',
mcp: 'mcp',
mcpTest: 'mcp-test',
payloadSizeMax: 16,
formDataFileSizeMax: 200,
rest: 'rest',
webhook: 'webhook',
webhookTest: 'webhook-test',
webhookWaiting: 'webhook-waiting',
health: '/healthz',
},
cache: {
backend: 'auto',
memory: {
maxSize: 3145728,
ttl: 3600000,
},
redis: {
prefix: 'cache',
ttl: 3600000,
},
},
chatHub: {
executionContextTtl: 3600,
maxBufferedChunks: 1000,
streamStateTtl: 300,
},
queue: {
health: {
active: false,
port: 5678,
address: '::',
},
bull: {
redis: {
db: 0,
host: 'localhost',
password: '',
port: 6379,
timeoutThreshold: 10_000,
username: '',
clusterNodes: '',
tls: false,
dualStack: false,
slotsRefreshInterval: 5_000,
slotsRefreshTimeout: 1_000,
dnsResolveStrategy: 'LOOKUP',
keepAlive: false,
keepAliveDelay: 5000,
keepAliveInterval: 5000,
reconnectOnFailover: true,
},
gracefulShutdownTimeout: 30,
prefix: 'bull',
settings: {
lockDuration: 60_000,
lockRenewTime: 10_000,
stalledInterval: 30_000,
},
},
},
taskRunners: {
mode: 'internal',
path: '/runners',
authToken: '',
listenAddress: '127.0.0.1',
maxPayload: 1024 * 1024 * 1024,
port: 5679,
maxOldSpaceSize: '',
maxConcurrency: 10,
taskTimeout: 300,
taskRequestTimeout: 60,
heartbeatInterval: 30,
insecureMode: false,
},
sentry: {
backendDsn: '',
frontendDsn: '',
environment: '',
deploymentName: '',
profilesSampleRate: 0,
tracesSampleRate: 0,
eventLoopBlockThreshold: 500,
},
logging: {
level: 'info',
format: 'text',
outputs: ['console'],
file: {
fileCountMax: 100,
fileSizeMax: 16,
location: 'logs/n8n.log',
},
scopes: [],
cron: {
activeInterval: 0,
},
},
multiMainSetup: {
enabled: false,
ttl: 10,
interval: 3,
},
generic: {
timezone: 'America/New_York',
releaseChannel: 'dev',
gracefulShutdownTimeout: 30,
},
license: {
serverUrl: 'https://license.n8n.io/v1',
autoRenewalEnabled: true,
detachFloatingOnShutdown: true,
activationKey: '',
tenantId: 1,
cert: '',
},
security: {
restrictFileAccessTo: '~/.n8n-files',
blockFileAccessToN8nFiles: true,
blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$',
daysAbandonedWorkflow: 90,
contentSecurityPolicy: '{}',
contentSecurityPolicyReportOnly: false,
crossOriginOpenerPolicy: 'same-origin',
disableWebhookHtmlSandboxing: false,
disableBareRepos: true,
awsSystemCredentialsAccess: false,
enableGitNodeHooks: false,
enableGitNodeAllConfigKeys: false,
},
executions: {
mode: 'regular',
timeout: -1,
maxTimeout: 3600,
pruneData: true,
pruneDataMaxAge: 336,
pruneDataMaxCount: 10_000,
pruneDataHardDeleteBuffer: 1,
pruneDataIntervals: {
hardDelete: 15,
softDelete: 60,
},
concurrency: {
productionLimit: -1,
evaluationLimit: -1,
},
queueRecovery: {
interval: 180,
batchSize: 100,
},
recovery: {
maxLastExecutions: 3,
workflowDeactivationEnabled: false,
},
saveDataOnError: 'all',
saveDataOnSuccess: 'all',
saveExecutionProgress: false,
saveDataManualExecutions: true,
},
diagnostics: {
enabled: true,
frontendConfig: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io',
backendConfig: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io',
posthogConfig: {
apiKey: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo',
apiHost: 'https://us.i.posthog.com',
},
},
aiAssistant: {
baseUrl: '',
},
aiBuilder: {
apiKey: '',
},
tags: {
disabled: false,
},
workflowHistory: {
pruneTime: -1,
},
sso: {
justInTimeProvisioning: true,
redirectLoginToSso: true,
saml: {
loginEnabled: false,
loginLabel: '',
},
oidc: {
loginEnabled: false,
},
ldap: {
loginEnabled: false,
loginLabel: '',
},
provisioning: {
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
scopesName: 'n8n',
scopesInstanceRoleClaimName: 'n8n_instance_role',
scopesProjectsRolesClaimName: 'n8n_projects',
},
},
redis: {
prefix: 'n8n',
},
externalFrontendHooksUrls: '',
// @ts-expect-error structuredClone ignores properties defined as a getter
ai: {
enabled: false,
persistBuilderSessions: false,
timeout: 3600000,
allowSendingParameterValues: true,
},
workflowHistoryCompaction: {
batchDelayMs: 1_000,
batchSize: 100,
optimizingMinimumAgeHours: 0.25,
optimizingTimeWindowHours: 2,
trimmingMinimumAgeDays: 7,
trimmingTimeWindowDays: 2,
trimOnStartUp: false,
},
};
it('should use all default values when no env variables are defined', () => {
process.env = {};
const config = Container.get(GlobalConfig);
// Makes sure the objects are structurally equal while respecting getters,
// which `toEqual` and `toBe` does not do.
expect(defaultConfig).toMatchObject(config);
expect(config).toMatchObject(defaultConfig);
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
it('should use values from env variables when defined', () => {
process.env = {
DB_POSTGRESDB_HOST: 'some-host',
DB_POSTGRESDB_USER: 'n8n',
DB_POSTGRESDB_IDLE_CONNECTION_TIMEOUT: '10000',
DB_TABLE_PREFIX: 'test_',
DB_PING_INTERVAL_SECONDS: '2',
NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
DB_LOGGING_MAX_EXECUTION_TIME: '0',
N8N_METRICS: 'TRUE',
N8N_TEMPLATES_ENABLED: '0',
N8N_DYNAMIC_BANNERS_ENDPOINT: 'https://localhost:5678/api/banners',
N8N_DYNAMIC_BANNERS_ENABLED: 'false',
};
const config = Container.get(GlobalConfig);
expect(structuredClone(config)).toEqual({
...defaultConfig,
database: {
logging: defaultConfig.database.logging,
postgresdb: {
...defaultConfig.database.postgresdb,
host: 'some-host',
user: 'n8n',
idleTimeoutMs: 10_000,
},
sqlite: defaultConfig.database.sqlite,
tablePrefix: 'test_',
type: 'sqlite',
pingIntervalSeconds: 2,
},
endpoints: {
...defaultConfig.endpoints,
metrics: {
...defaultConfig.endpoints.metrics,
enable: true,
},
},
nodes: {
...defaultConfig.nodes,
include: ['n8n-nodes-base.hackerNews'],
},
templates: {
...defaultConfig.templates,
enabled: false,
},
dynamicBanners: {
endpoint: 'https://localhost:5678/api/banners',
enabled: false,
},
});
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
it('should read values from files using _FILE env variables', () => {
const passwordFile = '/path/to/postgres/password';
process.env = {
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
};
mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file');
const config = Container.get(GlobalConfig);
const expected = {
...defaultConfig,
database: {
...defaultConfig.database,
postgresdb: {
...defaultConfig.database.postgresdb,
password: 'password-from-file',
},
},
};
// Makes sure the objects are structurally equal while respecting getters,
// which `toEqual` and `toBe` does not do.
expect(config).toMatchObject(expected);
expect(expected).toMatchObject(config);
expect(mockFs.readFileSync).toHaveBeenCalled();
});
it('should warn when _FILE env variable value contains whitespace', () => {
const passwordFile = '/path/to/postgres/password';
process.env = {
DB_POSTGRESDB_PASSWORD_FILE: passwordFile,
};
mockFs.readFileSync
.calledWith(passwordFile, 'utf8')
.mockReturnValueOnce('password-from-file\n');
const config = Container.get(GlobalConfig);
expect(config.database.postgresdb.password).toBe('password-from-file');
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining(
'DB_POSTGRESDB_PASSWORD_FILE contains leading or trailing whitespace',
),
);
});
it('should handle invalid numbers', () => {
process.env = {
DB_LOGGING_MAX_EXECUTION_TIME: 'abcd',
};
const config = Container.get(GlobalConfig);
expect(config.database.logging.maxQueryExecutionTime).toEqual(0);
expect(consoleWarnMock).toHaveBeenCalledWith(
'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd',
);
});
describe('string unions', () => {
it('on invalid value, should warn and fall back to default value', () => {
process.env = {
N8N_RUNNERS_MODE: 'non-existing-mode',
DB_TYPE: 'postgresdb',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.taskRunners.mode).toEqual('internal');
expect(consoleWarnMock).toHaveBeenCalledWith(
expect.stringContaining(
"Invalid value for N8N_RUNNERS_MODE - Invalid enum value. Expected 'internal' | 'external', received 'non-existing-mode'. Falling back to default value.",
),
);
expect(globalConfig.database.type).toEqual('postgresdb');
});
it('should validate crossOriginOpenerPolicy enum values', () => {
process.env = {
N8N_CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin-allow-popups');
});
it('should warn and fall back to default for invalid crossOriginOpenerPolicy', () => {
process.env = {
N8N_CROSS_ORIGIN_OPENER_POLICY: 'invalid-policy',
};
const globalConfig = Container.get(GlobalConfig);
expect(globalConfig.security.crossOriginOpenerPolicy).toEqual('same-origin');
});
});
describe('health endpoint transformation', () => {
it('should add leading slash if not present', () => {
process.env = {
N8N_ENDPOINT_HEALTH: 'healthz',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/healthz');
});
it('should keep leading slash if already present', () => {
process.env = {
N8N_ENDPOINT_HEALTH: '/custom-health',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/custom-health');
});
it('should add leading slash to paths with multiple segments', () => {
process.env = {
N8N_ENDPOINT_HEALTH: 'api/v1/health',
};
const config = Container.get(GlobalConfig);
expect(config.endpoints.health).toEqual('/api/v1/health');
});
});
});
@@ -0,0 +1,25 @@
import { CommaSeparatedStringArray, ColonSeparatedStringArray } from '../src/custom-types';
describe('CommaSeparatedStringArray', () => {
it('should parse comma-separated string into array', () => {
const result = new CommaSeparatedStringArray('a,b,c');
expect(result).toEqual(['a', 'b', 'c']);
});
it('should handle empty strings', () => {
const result = new CommaSeparatedStringArray('a,b,,,');
expect(result).toEqual(['a', 'b']);
});
});
describe('ColonSeparatedStringArray', () => {
it('should parse colon-separated string into array', () => {
const result = new ColonSeparatedStringArray('a:b:c');
expect(result).toEqual(['a', 'b', 'c']);
});
it('should handle empty strings', () => {
const result = new ColonSeparatedStringArray('a::b:::');
expect(result).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,86 @@
import { Container } from '@n8n/di';
import fs from 'fs';
import { Config, Env } from '../src/decorators';
jest.mock('fs');
const mockFs = jest.mocked(fs);
describe('decorators', () => {
const originalEnv = process.env;
beforeEach(() => {
Container.reset();
process.env = {};
jest.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
it('should throw when explicit typing is missing', () => {
expect(() => {
@Config
class InvalidConfig {
@Env('STRING_VALUE')
value = 'string';
}
Container.get(InvalidConfig);
}).toThrowError(
'Invalid decorator metadata on key "value" on InvalidConfig\n Please use explicit typing on all config fields',
);
});
it('should read value from _FILE env variable', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE_FILE = filePath;
mockFs.readFileSync.mockReturnValueOnce('secret-value');
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('secret-value');
expect(mockFs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8');
});
it('should warn when _FILE env variable value contains whitespace', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE_FILE = filePath;
mockFs.readFileSync.mockReturnValueOnce('secret-value\n');
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('secret-value');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('TEST_VALUE_FILE contains leading or trailing whitespace'),
);
consoleWarnSpy.mockRestore();
});
it('should prefer direct env variable over _FILE variant', () => {
const filePath = '/path/to/secret';
process.env.TEST_VALUE = 'direct-value';
process.env.TEST_VALUE_FILE = filePath;
@Config
class TestConfig {
@Env('TEST_VALUE')
value: string = 'default';
}
const config = Container.get(TestConfig);
expect(config.value).toBe('direct-value');
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,123 @@
import { Container } from '@n8n/di';
import { GlobalConfig } from '../src/index';
beforeEach(() => {
Container.reset();
jest.clearAllMocks();
});
const originalEnv = process.env;
afterEach(() => {
process.env = originalEnv;
});
it('should strip double quotes from string values', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota"',
N8N_HOST: '"localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should strip single quotes from string values', () => {
process.env = {
GENERIC_TIMEZONE: "'America/Bogota'",
N8N_HOST: "'localhost'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should trim whitespace from quoted values', () => {
process.env = {
GENERIC_TIMEZONE: ' "America/Bogota" ',
N8N_HOST: " 'localhost' ",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should trim whitespace from unquoted values', () => {
process.env = {
GENERIC_TIMEZONE: ' America/Bogota ',
N8N_HOST: ' localhost ',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/Bogota');
expect(config.host).toBe('localhost');
});
it('should leave mismatched quotes unchanged', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota\'',
N8N_HOST: '\'localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota\'');
expect(config.host).toBe('\'localhost"');
});
it('should handle empty quotes', () => {
process.env = {
GENERIC_TIMEZONE: '""',
N8N_HOST: "''",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('');
expect(config.host).toBe('');
});
it('should handle single character in quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"A"',
N8N_HOST: "'B'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('A');
expect(config.host).toBe('B');
});
it('should handle values with spaces in quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/New York"',
N8N_HOST: "'my host name'",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('America/New York');
expect(config.host).toBe('my host name');
});
it('should handle nested quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/\'Bogota\'"',
N8N_HOST: '\'"localhost"\'',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe("America/'Bogota'");
expect(config.host).toBe('"localhost"');
});
it('should handle only opening or closing quotes', () => {
process.env = {
GENERIC_TIMEZONE: '"America/Bogota',
N8N_HOST: 'localhost"',
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota');
expect(config.host).toBe('localhost"');
});
it('should handle multiple quote pairs', () => {
process.env = {
GENERIC_TIMEZONE: '""America/Bogota""',
N8N_HOST: "''localhost''",
};
const config = Container.get(GlobalConfig);
expect(config.generic.timezone).toBe('"America/Bogota"'); // should strip only outer quotes
expect(config.host).toBe("'localhost'");
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**", "src/**/__tests__/**"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"types": ["node", "jest"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"references": [{ "path": "../di/tsconfig.build.json" }]
}