mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Wire expression-runtime behind N8N_EXPRESSION_ENGINE=vm flag (no-changelog) (#26528)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6fa0d2e0a7
commit
2614154b71
13 changed files with 402 additions and 16 deletions
|
|
@ -2,10 +2,19 @@
|
|||
"name": "@n8n/expression-runtime",
|
||||
"version": "0.3.0",
|
||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "dist/cjs/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/cjs/index.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && pnpm build:runtime",
|
||||
"build": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json && pnpm build:runtime",
|
||||
"build:runtime": "node esbuild.config.js",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest --watch --silent false",
|
||||
|
|
@ -32,6 +41,7 @@
|
|||
"transliteration": "2.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/luxon": "3.2.0",
|
||||
"@types/md5": "^2.3.5",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
import ivm from 'isolated-vm';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { RuntimeBridge, BridgeConfig } from '../types';
|
||||
import { DEFAULT_BRIDGE_CONFIG, TimeoutError, MemoryLimitError } from '../types';
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
|
||||
|
||||
/**
|
||||
* Read the runtime IIFE bundle by walking up from `__dirname` until
|
||||
* `dist/bundle/runtime.iife.js` is found.
|
||||
*
|
||||
* This works regardless of where the compiled output lives:
|
||||
* - `src/bridge/` (vitest running against source)
|
||||
* - `dist/cjs/bridge/` (CJS build)
|
||||
*/
|
||||
async function readRuntimeBundle(): Promise<string> {
|
||||
let dir = __dirname;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
try {
|
||||
return await readFile(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
|
||||
} catch {}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find runtime bundle (${BUNDLE_RELATIVE_PATH}) in any parent of ${__dirname}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* IsolatedVmBridge - Runtime bridge using isolated-vm for secure expression evaluation.
|
||||
|
|
@ -110,16 +128,14 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
|
||||
try {
|
||||
// Load runtime bundle (includes vendor libraries + proxy system)
|
||||
// Path: dist/bundle/runtime.iife.js
|
||||
const runtimeBundlePath = path.join(__dirname, '../../dist/bundle/runtime.iife.js');
|
||||
const runtimeBundle = await readFile(runtimeBundlePath, 'utf-8');
|
||||
const runtimeBundle = await readRuntimeBundle();
|
||||
|
||||
// Evaluate bundle in isolate context
|
||||
// This makes all exported globals available (DateTime, extend, extendOptional, SafeObject, SafeError, createDeepLazyProxy, resetDataProxies, __data)
|
||||
await this.context.eval(runtimeBundle);
|
||||
|
||||
if (this.config.debug) {
|
||||
console.log('[IsolatedVmBridge] Runtime bundle loaded from:', runtimeBundlePath);
|
||||
console.log('[IsolatedVmBridge] Runtime bundle loaded');
|
||||
}
|
||||
|
||||
// Verify vendor libraries loaded correctly
|
||||
|
|
|
|||
10
packages/@n8n/expression-runtime/tsconfig.build.cjs.json
Normal file
10
packages/@n8n/expression-runtime/tsconfig.build.cjs.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/modern/tsconfig.cjs.json"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist/cjs",
|
||||
"tsBuildInfoFile": "dist/cjs/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/__tests__/**"]
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
"extends": ["./tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
"outDir": "dist/esm",
|
||||
"tsBuildInfoFile": "dist/esm/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/__tests__/**"]
|
||||
|
|
@ -27,7 +27,8 @@
|
|||
"@n8n/i18n*": ["../@n8n/i18n/src*"],
|
||||
"@n8n/stores*": ["../@n8n/stores/src*"],
|
||||
"@n8n/api-types*": ["../../@n8n/api-types/src*"],
|
||||
"@n8n/utils*": ["../../@n8n/utils/src*"]
|
||||
"@n8n/utils*": ["../../@n8n/utils/src*"],
|
||||
"@n8n/expression-runtime": ["./vite/expression-runtime-stub.ts"]
|
||||
},
|
||||
// TODO: remove all options below this line
|
||||
"useUnknownInCatchVariables": false
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ const packagesDir = resolve(__dirname, '..', '..');
|
|||
const alias = [
|
||||
{ find: '@', replacement: resolve(__dirname, 'src') },
|
||||
{ find: 'stream', replacement: 'stream-browserify' },
|
||||
// Stub out @n8n/expression-runtime for browser build (it pulls in isolated-vm, a Node.js-only native module)
|
||||
{
|
||||
find: '@n8n/expression-runtime',
|
||||
replacement: resolve(__dirname, 'vite/expression-runtime-stub.ts'),
|
||||
},
|
||||
// Ensure bare imports resolve to sources (not dist)
|
||||
{ find: '@n8n/i18n', replacement: resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src') },
|
||||
{ find: '@n8n/chat-hub', replacement: resolve(packagesDir, '@n8n', 'chat-hub', 'src') },
|
||||
|
|
|
|||
52
packages/frontend/editor-ui/vite/expression-runtime-stub.ts
Normal file
52
packages/frontend/editor-ui/vite/expression-runtime-stub.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Browser stub for @n8n/expression-runtime.
|
||||
* The real implementation uses isolated-vm (a Node.js-only native module).
|
||||
* IS_FRONTEND guards in expression.ts prevent these from ever being instantiated.
|
||||
*/
|
||||
|
||||
export class ExpressionEvaluator {
|
||||
constructor(_config?: unknown) {
|
||||
throw new Error('ExpressionEvaluator is not available in browser environments');
|
||||
}
|
||||
}
|
||||
|
||||
export class IsolatedVmBridge {
|
||||
constructor(_config?: unknown) {
|
||||
throw new Error('IsolatedVmBridge is not available in browser environments');
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpressionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public context: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export class MemoryLimitError extends Error {}
|
||||
export class TimeoutError extends Error {}
|
||||
export class SecurityViolationError extends Error {}
|
||||
// Note: SyntaxError not re-exported to avoid shadowing built-in
|
||||
|
||||
export class RuntimeError extends Error {}
|
||||
|
||||
export function extend() {}
|
||||
export function extendOptional() {}
|
||||
export const EXTENSION_OBJECTS: unknown[] = [];
|
||||
export class ExpressionExtensionError extends Error {}
|
||||
|
||||
export const DEFAULT_BRIDGE_CONFIG = {};
|
||||
|
||||
// Type-only exports (resolved by TypeScript, erased at runtime)
|
||||
export type IExpressionEvaluator = never;
|
||||
export type EvaluatorConfig = never;
|
||||
export type WorkflowData = Record<string, unknown>;
|
||||
export type EvaluateOptions = never;
|
||||
export type RuntimeBridge = never;
|
||||
export type BridgeConfig = never;
|
||||
export type ObservabilityProvider = never;
|
||||
export type MetricsAPI = never;
|
||||
export type TracesAPI = never;
|
||||
export type Span = never;
|
||||
export type LogsAPI = never;
|
||||
|
|
@ -52,6 +52,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@n8n/errors": "workspace:*",
|
||||
"@n8n/expression-runtime": "workspace:*",
|
||||
"@n8n/tournament": "1.0.6",
|
||||
"ast-types": "0.16.1",
|
||||
"callsites": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { ApplicationError } from '@n8n/errors';
|
||||
import type { IExpressionEvaluator } from '@n8n/expression-runtime';
|
||||
import { MemoryLimitError, SecurityViolationError, TimeoutError } from '@n8n/expression-runtime';
|
||||
import { DateTime, Duration, Interval } from 'luxon';
|
||||
|
||||
import { UnexpectedError } from './errors';
|
||||
import { ExpressionExtensionError } from './errors/expression-extension.error';
|
||||
import { ExpressionError } from './errors/expression.error';
|
||||
import { evaluateExpression, setErrorHandler } from './expression-evaluator-proxy';
|
||||
import { sanitizer, sanitizerName } from './expression-sandboxing';
|
||||
import {
|
||||
DollarSignValidator,
|
||||
PrototypeSanitizer,
|
||||
ThisSanitizer,
|
||||
sanitizer,
|
||||
sanitizerName,
|
||||
} from './expression-sandboxing';
|
||||
import { isExpression } from './expressions/expression-helpers';
|
||||
import { extend, extendOptional } from './extensions';
|
||||
import { extendSyntax } from './extensions/expression-extension';
|
||||
|
|
@ -165,8 +174,86 @@ const createSafeErrorSubclass = <T extends ErrorConstructor>(ErrorClass: T): T =
|
|||
};
|
||||
|
||||
export class Expression {
|
||||
// Feature gate for expression engine selection
|
||||
private static expressionEngine: 'current' | 'vm' = (() => {
|
||||
if (typeof process === 'undefined') return 'current';
|
||||
const env = process.env.N8N_EXPRESSION_ENGINE;
|
||||
if (env === 'vm' || env === 'current') return env;
|
||||
if (env) {
|
||||
console.warn(
|
||||
`Unknown N8N_EXPRESSION_ENGINE="${env}", falling back to "current". Valid values: current, vm`,
|
||||
);
|
||||
}
|
||||
return 'current';
|
||||
})();
|
||||
|
||||
private static vmEvaluator?: IExpressionEvaluator;
|
||||
|
||||
constructor(private readonly timezone: string) {}
|
||||
|
||||
/**
|
||||
* Check if VM evaluator should be used for evaluation.
|
||||
* @private
|
||||
*/
|
||||
private static shouldUseVm(): boolean {
|
||||
return this.expressionEngine === 'vm' && !IS_FRONTEND && !!this.vmEvaluator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VM evaluator (if feature flag is enabled).
|
||||
* Should be called once during application startup.
|
||||
* Only available in Node.js environments (not in browser).
|
||||
*/
|
||||
static async initializeVmEvaluator(): Promise<void> {
|
||||
if (this.expressionEngine !== 'vm' || IS_FRONTEND) return;
|
||||
|
||||
if (!this.vmEvaluator) {
|
||||
// Dynamic import to avoid loading expression-runtime in browser environments
|
||||
const { ExpressionEvaluator, IsolatedVmBridge } = await import('@n8n/expression-runtime');
|
||||
const bridge = new IsolatedVmBridge({ timeout: 5000 });
|
||||
this.vmEvaluator = new ExpressionEvaluator({
|
||||
bridge,
|
||||
hooks: {
|
||||
before: [ThisSanitizer],
|
||||
after: [PrototypeSanitizer, DollarSignValidator],
|
||||
},
|
||||
});
|
||||
await this.vmEvaluator.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the VM evaluator and release resources.
|
||||
* Should be called during application shutdown or test teardown.
|
||||
*/
|
||||
static async disposeVmEvaluator(): Promise<void> {
|
||||
if (this.vmEvaluator) {
|
||||
await this.vmEvaluator.dispose();
|
||||
this.vmEvaluator = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active expression evaluation implementation.
|
||||
* Used for testing and verification.
|
||||
*/
|
||||
static getActiveImplementation(): 'current' | 'vm' {
|
||||
if (this.shouldUseVm()) return 'vm';
|
||||
return 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the expression engine programmatically.
|
||||
*
|
||||
* WARNING: This is a global setting — switching engines mid-execution could
|
||||
* cause a workflow to evaluate some expressions with one engine and some with
|
||||
* another. Only use this in benchmarks and tests, never in production code.
|
||||
* In production, set `N8N_EXPRESSION_ENGINE` before process startup instead.
|
||||
*/
|
||||
static setExpressionEngine(engine: 'current' | 'vm'): void {
|
||||
this.expressionEngine = engine;
|
||||
}
|
||||
|
||||
static initializeGlobalContext(data: IDataObject) {
|
||||
/**
|
||||
* Denylist
|
||||
|
|
@ -435,6 +522,46 @@ export class Expression {
|
|||
}
|
||||
|
||||
private renderExpression(expression: string, data: IWorkflowDataProxyData) {
|
||||
// Use VM evaluator if engine is set to 'vm' and we're not in the browser
|
||||
if (Expression.expressionEngine === 'vm' && !IS_FRONTEND) {
|
||||
if (!Expression.vmEvaluator) {
|
||||
throw new UnexpectedError(
|
||||
'N8N_EXPRESSION_ENGINE=vm is enabled but VM evaluator is not initialized. Call Expression.initializeVmEvaluator() during application startup.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = Expression.vmEvaluator.evaluate(expression, data);
|
||||
return result as string | null | (() => unknown);
|
||||
} catch (error) {
|
||||
if (isExpressionError(error)) throw error;
|
||||
|
||||
if (error instanceof TimeoutError) {
|
||||
const wrapped = new ExpressionError('Expression timed out');
|
||||
// Assign cause manually because ExecutionBaseError drops it if it's an instance of Error
|
||||
wrapped.cause = error;
|
||||
throw wrapped;
|
||||
}
|
||||
if (error instanceof MemoryLimitError) {
|
||||
const wrapped = new ExpressionError('Expression exceeded memory limit');
|
||||
// Assign cause manually because ExecutionBaseError drops it if it's an instance of Error
|
||||
wrapped.cause = error;
|
||||
throw wrapped;
|
||||
}
|
||||
if (error instanceof SecurityViolationError) {
|
||||
const wrapped = new ExpressionError(error.message);
|
||||
// Assign cause manually because ExecutionBaseError drops it if it's an instance of Error
|
||||
wrapped.cause = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
if (isSyntaxError(error)) throw new ExpressionError('invalid syntax');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current implementation
|
||||
try {
|
||||
return evaluateExpression(expression, data);
|
||||
} catch (error) {
|
||||
|
|
|
|||
141
packages/workflow/test/expression-vm-errors.test.ts
Normal file
141
packages/workflow/test/expression-vm-errors.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { TimeoutError, MemoryLimitError, SecurityViolationError } from '@n8n/expression-runtime';
|
||||
import type { IExpressionEvaluator } from '@n8n/expression-runtime';
|
||||
|
||||
import { ExpressionError } from '../src/errors/expression.error';
|
||||
import { Expression } from '../src/expression';
|
||||
import { Workflow } from '../src/workflow';
|
||||
import * as Helpers from './helpers';
|
||||
|
||||
/**
|
||||
* Tests that VM-specific error types from @n8n/expression-runtime
|
||||
* are caught and wrapped in workflow ExpressionError instances.
|
||||
*
|
||||
* The runtime package defines its own ExpressionError class hierarchy
|
||||
* (TimeoutError, MemoryLimitError, SecurityViolationError), which is
|
||||
* different from packages/workflow's ExpressionError. Without explicit
|
||||
* handling, these errors bypass the isExpressionError() check and
|
||||
* propagate as raw runtime errors.
|
||||
*/
|
||||
describe('Expression VM error handling', () => {
|
||||
const nodeTypes = Helpers.NodeTypes();
|
||||
const workflow = new Workflow({
|
||||
id: '1',
|
||||
nodes: [
|
||||
{
|
||||
name: 'node',
|
||||
typeVersion: 1,
|
||||
type: 'test.set',
|
||||
id: 'uuid-1234',
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
let originalEngine: 'current' | 'vm';
|
||||
let originalEvaluator: IExpressionEvaluator | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEngine = Expression.getActiveImplementation();
|
||||
originalEvaluator = (Expression as any).vmEvaluator;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Expression.setExpressionEngine(originalEngine);
|
||||
(Expression as any).vmEvaluator = originalEvaluator;
|
||||
});
|
||||
|
||||
function setVmEvaluator(evaluator: Partial<IExpressionEvaluator>) {
|
||||
Expression.setExpressionEngine('vm');
|
||||
(Expression as any).vmEvaluator = evaluator;
|
||||
}
|
||||
|
||||
const evaluate = (expr: string) =>
|
||||
workflow.expression.getParameterValue(expr, null, 0, 0, 'node', [], 'manual', {});
|
||||
|
||||
it('should wrap TimeoutError in ExpressionError', () => {
|
||||
const timeoutError = new TimeoutError('Expression timed out after 5000ms', {});
|
||||
setVmEvaluator({
|
||||
evaluate: () => {
|
||||
throw timeoutError;
|
||||
},
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
evaluate('={{ $json.id }}');
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ExpressionError);
|
||||
expect((caught as ExpressionError).message).toBe('Expression timed out');
|
||||
expect((caught as ExpressionError).cause).toBe(timeoutError);
|
||||
});
|
||||
|
||||
it('should wrap MemoryLimitError in ExpressionError', () => {
|
||||
const memoryError = new MemoryLimitError('Expression exceeded memory limit of 128MB', {});
|
||||
setVmEvaluator({
|
||||
evaluate: () => {
|
||||
throw memoryError;
|
||||
},
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
evaluate('={{ $json.id }}');
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ExpressionError);
|
||||
expect((caught as ExpressionError).message).toBe('Expression exceeded memory limit');
|
||||
expect((caught as ExpressionError).cause).toBe(memoryError);
|
||||
});
|
||||
|
||||
it('should wrap SecurityViolationError in ExpressionError', () => {
|
||||
const securityError = new SecurityViolationError(
|
||||
'Cannot access "constructor" due to security concerns',
|
||||
{},
|
||||
);
|
||||
setVmEvaluator({
|
||||
evaluate: () => {
|
||||
throw securityError;
|
||||
},
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
evaluate('={{ $json.id }}');
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ExpressionError);
|
||||
expect((caught as ExpressionError).message).toBe(
|
||||
'Cannot access "constructor" due to security concerns',
|
||||
);
|
||||
expect((caught as ExpressionError).cause).toBe(securityError);
|
||||
});
|
||||
|
||||
it('should convert built-in SyntaxError to ExpressionError', () => {
|
||||
setVmEvaluator({
|
||||
evaluate: () => {
|
||||
throw new SyntaxError('Unexpected token');
|
||||
},
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
evaluate('={{ $json.id }}');
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ExpressionError);
|
||||
expect((caught as ExpressionError).message).toBe('invalid syntax');
|
||||
});
|
||||
});
|
||||
14
packages/workflow/test/setup-vm-evaluator.ts
Normal file
14
packages/workflow/test/setup-vm-evaluator.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Expression } from '../src/expression';
|
||||
|
||||
// Only runs when N8N_EXPRESSION_ENGINE=vm is set.
|
||||
// Initializes the VM evaluator once per vitest worker before all tests,
|
||||
// and disposes it after.
|
||||
if (process.env.N8N_EXPRESSION_ENGINE === 'vm') {
|
||||
beforeAll(async () => {
|
||||
await Expression.initializeVmEvaluator();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Expression.disposeVmEvaluator();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig({ include: ['test/**/*.test.ts'] });
|
||||
export default createVitestConfig({
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup-vm-evaluator.ts'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,9 @@ importers:
|
|||
specifier: 2.3.5
|
||||
version: 2.3.5
|
||||
devDependencies:
|
||||
'@n8n/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@types/lodash':
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.17
|
||||
|
|
@ -3917,6 +3920,9 @@ importers:
|
|||
'@n8n/errors':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/errors
|
||||
'@n8n/expression-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/expression-runtime
|
||||
'@n8n/tournament':
|
||||
specifier: 1.0.6
|
||||
version: 1.0.6
|
||||
|
|
|
|||
Loading…
Reference in a new issue