/**
* n8n Version Detection and Version-Aware Settings Filtering
*
* This module provides version detection for n8n instances and filters
* workflow settings based on what the target n8n version supports.
*
* VERSION HISTORY for workflowSettings in n8n Public API:
* - All versions: 7 core properties (saveExecutionProgress, saveManualExecutions,
* saveDataErrorExecution, saveDataSuccessExecution, executionTimeout,
* errorWorkflow, timezone)
* - 1.37.0+: Added executionOrder
* - 1.119.0+: Added callerPolicy, callerIds, timeSavedPerExecution, availableInMCP
*
* References:
* - https://github.com/n8n-io/n8n/pull/21297 (PR adding 4 new properties in 1.119.0)
* - https://community.n8n.io/t/n8n-api-update-workflow-does-not-accept-executionorder-setting/44512
*/
import axios from 'axios';
import { logger } from '../utils/logger';
import { N8nVersionInfo, N8nSettingsResponse } from '../types/n8n-api';
// Cache version info per base URL with TTL to handle server upgrades
interface CachedVersion {
info: N8nVersionInfo;
fetchedAt: number;
}
// Cache TTL: 5 minutes - allows for server upgrades without requiring restart
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
const versionCache = new Map<string, CachedVersion>();
// Settings properties supported by each n8n version range
// These are CUMULATIVE - each version adds to the previous
const SETTINGS_BY_VERSION = {
// Core properties supported by all versions
core: [
'saveExecutionProgress',
'saveManualExecutions',
'saveDataErrorExecution',
'saveDataSuccessExecution',
'executionTimeout',
'errorWorkflow',
'timezone',
],
// Added in n8n 1.37.0
v1_37_0: [
'executionOrder',
],
// Added in n8n 1.119.0 (PR #21297)
v1_119_0: [
'callerPolicy',
'callerIds',
'timeSavedPerExecution',
'availableInMCP',
],
};
/**
* Parse version string into structured version info
*/
export function parseVersion(versionString: string): N8nVersionInfo | null {
// Handle formats like "1.119.0", "1.37.0-beta.1", "0.200.0", "v1.2.3"
// Support optional 'v' prefix for robustness
const match = versionString.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
version: versionString,
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
};
}
/**
* Compare two versions: returns -1 if a < b, 0 if equal, 1 if a > b
*/
export function compareVersions(a: N8nVersionInfo, b: N8nVersionInfo): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
/**
* Check if version meets minimum requirement
*/
export function versionAtLeast(version: N8nVersionInfo, major: number, minor: number, patch = 0): boolean {
const target = { version: '', major, minor, patch };
return compareVersions(version, target) >= 0;
}
/**
* Get supported settings properties for a given n8n version
*/
export function getSupportedSettingsProperties(version: N8nVersionInfo): Set<string> {
const supported = new Set<string>(SETTINGS_BY_VERSION.core);
// Add executionOrder if >= 1.37.0
if (versionAtLeast(version, 1, 37, 0)) {
SETTINGS_BY_VERSION.v1_37_0.forEach(prop => supported.add(prop));
}
// Add new properties if >= 1.119.0
if (versionAtLeast(version, 1, 119, 0)) {
SETTINGS_BY_VERSION.v1_119_0.forEach(prop => supported.add(prop));
}
return supported;
}
/**
* Fetch n8n version from /rest/settings endpoint
*
* This endpoint is available on all n8n instances and doesn't require authentication.
* Note: There's a security concern about this being unauthenticated (see n8n community),
* but it's the only reliable way to get version info.
*/
export async function fetchN8nVersion(baseUrl: string): Promise<N8nVersionInfo | null> {
// Check cache first (with TTL)
const cached = versionCache.get(baseUrl);
if (cached && Date.now() - cached.fetchedAt < VERSION_CACHE_TTL_MS) {
logger.debug(`Using cached n8n version for ${baseUrl}: ${cached.info.version}`);
return cached.info;
}
try {
// Remove /api/v1 suffix if present to get base URL
const cleanBaseUrl = baseUrl.replace(/\/api\/v\d+\/?$/, '').replace(/\/$/, '');
const settingsUrl = `${cleanBaseUrl}/rest/settings`;
logger.debug(`Fetching n8n version from ${settingsUrl}`);
const response = await axios.get<N8nSettingsResponse>(settingsUrl, {
timeout: 5000,
validateStatus: (status: number) => status < 500,
});
if (response.status === 200 && response.data) {
// n8n wraps the settings in a "data" property
const settings = response.data.data;
if (!settings) {
logger.warn('No data in settings response');
return null;
}
// n8n can return version in different fields - validate type
const versionString = typeof settings.n8nVersion === 'string'
? settings.n8nVersion
: typeof settings.versionCli === 'string'
? settings.versionCli
: null;
if (versionString) {
const versionInfo = parseVersion(versionString);
if (versionInfo) {
// Cache the result with timestamp
versionCache.set(baseUrl, { info: versionInfo, fetchedAt: Date.now() });
logger.debug(`Detected n8n version: ${versionInfo.version}`);
return versionInfo;
}
}
}
logger.warn(`Could not determine n8n version from ${settingsUrl}`);
return null;
} catch (error) {
logger.warn(`Failed to fetch n8n version: ${error instanceof Error ? error.message : 'Unknown error'}`);
return null;
}
}
/**
* Clear version cache (useful for testing or when server changes)
*/
export function clearVersionCache(): void {
versionCache.clear();
}
/**
* Get cached version for a base URL (or null if not cached or expired)
*/
export function getCachedVersion(baseUrl: string): N8nVersionInfo | null {
const cached = versionCache.get(baseUrl);
if (cached && Date.now() - cached.fetchedAt < VERSION_CACHE_TTL_MS) {
return cached.info;
}
return null;
}
/**
* Set cached version (useful for testing or when version is known)
*/
export function setCachedVersion(baseUrl: string, version: N8nVersionInfo): void {
versionCache.set(baseUrl, { info: version, fetchedAt: Date.now() });
}
/**
* Clean workflow settings for API update based on n8n version
*
* This function filters workflow settings to only include properties
* that the target n8n version supports, preventing "additional properties" errors.
*
* @param settings - The workflow settings to clean
* @param version - The target n8n version (if null, returns settings unchanged)
* @returns Cleaned settings object
*/
export function cleanSettingsForVersion(
settings: Record<string, unknown> | undefined,
version: N8nVersionInfo | null
): Record<string, unknown> {
if (!settings || typeof settings !== 'object') {
return {};
}
// If version unknown, return settings unchanged (let the API decide)
if (!version) {
return settings;
}
const supportedProperties = getSupportedSettingsProperties(version);
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(settings)) {
if (supportedProperties.has(key)) {
cleaned[key] = value;
} else {
logger.debug(`Filtered out unsupported settings property: ${key} (n8n ${version.version})`);
}
}
return cleaned;
}
// Export version thresholds for testing
export const VERSION_THRESHOLDS = {
EXECUTION_ORDER: { major: 1, minor: 37, patch: 0 },
CALLER_POLICY: { major: 1, minor: 119, patch: 0 },
};