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
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:
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
@@ -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 project–role 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 project–role 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;
|
||||
}
|
||||
@@ -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, ':');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'");
|
||||
});
|
||||
@@ -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__/**"]
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
Reference in New Issue
Block a user