Compare commits

...

9 Commits

Author SHA1 Message Date
n8n-assistant[bot]
55ae555ef6
🚀 Release 2.39.2 (#38271)
Co-authored-by: n8n-release-helper[bot] <265227495+n8n-release-helper[bot]@users.noreply.github.com>
2026-09-10 07:22:20 +00:00
n8n-assistant[bot]
d1d58017c8
fix(core): Reuse an existing consent grant after the visitor authenticates mid-flow (backport to release-candidate/2.39.x) (#38248)
Co-authored-by: MHMD <45968454+dou-mhmd@users.noreply.github.com>
2026-09-09 15:59:01 +00:00
n8n-release-tag-merge[bot]
720d9a7f0c Merge tag 'n8n@2.39.1' into release-candidate/2.39.x 2026-09-09 12:25:52 +00:00
n8n-assistant[bot]
1feb9f2dac
fix(core): Memoize pairedItem ancestry traversal (backport to release-candidate/2.39.x) (#38212)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
2026-09-09 12:12:53 +00:00
n8n-assistant[bot]
3e4f082109
🚀 Release 2.39.1 (#38203)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-09-09 11:26:55 +00:00
n8n-assistant[bot]
387eeae868
perf(core): Stop copying user workflows into the Instance AI sandbox (backport to release-candidate/2.39.x) (#38199)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:17:37 +00:00
n8n-assistant[bot]
514e20b83d
fix(core): Repair legacy-format data-encryption keys during bootstrap (backport to release-candidate/2.39.x) (#38142)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
2026-09-08 17:26:32 +00:00
n8n-assistant[bot]
63e2795e01
chore(core): Deprecate internal task runner mode and warn about SSRF default block list changes (no-changelog) (backport to release-candidate/2.39.x) (#38140)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 17:16:30 +00:00
n8n-assistant[bot]
082305a895
feat: Enable the v3 migration report and fix findings table layout (no-changelog) (backport to release-candidate/2.39.x) (#38097)
Co-authored-by: Mike Repeć <mike.repec@n8n.io>
2026-09-08 14:15:22 +00:00
72 changed files with 1759 additions and 144 deletions

View File

@ -1,3 +1,25 @@
## [2.39.2](https://github.com/n8n-io/n8n/compare/n8n@2.39.1...n8n@2.39.2) (2026-09-10)
### Bug Fixes
* **core:** Memoize pairedItem ancestry traversal ([#38212](https://github.com/n8n-io/n8n/issues/38212)) ([1feb9f2](https://github.com/n8n-io/n8n/commit/1feb9f2dac99744748e79b382b6f606d63c0ca5f))
* **core:** Reuse an existing consent grant after the visitor authenticates mid-flow ([#38248](https://github.com/n8n-io/n8n/issues/38248)) ([d1d5801](https://github.com/n8n-io/n8n/commit/d1d58017c81f5d76a3860ba0c216d2faee538617))
### Performance Improvements
* **core:** Stop copying user workflows into the Instance AI sandbox ([#38199](https://github.com/n8n-io/n8n/issues/38199)) ([387eeae](https://github.com/n8n-io/n8n/commit/387eeae8683df9dd316a3809325807af3e54e26c))
## [2.39.1](https://github.com/n8n-io/n8n/compare/n8n@2.39.0...n8n@2.39.1) (2026-09-09)
### Bug Fixes
* **core:** Repair legacy-format data-encryption keys during bootstrap ([#38142](https://github.com/n8n-io/n8n/issues/38142)) ([514e20b](https://github.com/n8n-io/n8n/commit/514e20b83d509bd07618cc48650fe783c4bf74f7))
# [2.39.0](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.39.0) (2026-09-08)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.39.0",
"version": "2.39.2",
"private": true,
"engines": {
"node": ">=24.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/agents",
"version": "0.24.0",
"version": "0.24.2",
"description": "AI agent SDK for n8n's code-first execution engine",
"engines": {
"node": ">=24.0.0"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.29.0",
"version": "0.29.2",
"description": "SDK for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-utilities",
"version": "0.32.0",
"version": "0.32.2",
"description": "Utilities for building AI nodes in n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -19,7 +19,7 @@ export type BreakingChangeVersion = z.infer<typeof breakingChangeVersionSchema>;
* When a new major version is released, update this value and add the
* corresponding breaking-change rules on the backend.
*/
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = null;
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = 'v3';
// Common schemas
const recommendationSchema = z.object({

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-network",
"version": "1.13.0",
"version": "1.13.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/blob-storage",
"version": "1.7.0",
"version": "1.7.2",
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat-hub",
"version": "1.32.0",
"version": "1.32.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "1.21.0",
"version": "1.21.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.47.0",
"version": "0.47.2",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo .tmp-schema-docs",
"gen:migration-index": "node scripts/generate-migration-index.mjs",

View File

@ -130,6 +130,18 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
return await this.find({ where: { type } });
}
async findDataEncryptionKeys(): Promise<DeploymentKey[]> {
return await this.find({ where: { type: 'data_encryption' } });
}
async rewrapLegacyDataEncryptionValue(
id: string,
oldValue: string,
wrappedValue: string,
): Promise<void> {
await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue });
}
async findAndCountForList(
opts: ListDeploymentKeysOptions,
): Promise<{ items: DeploymentKey[]; count: number }> {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "1.39.0",
"version": "1.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -116,7 +116,6 @@ reattached. The setup creates or materializes:
| `node-types/index.txt` | Searchable node-type catalog |
| `src/` | Workflow source files |
| `chunks/` | Reusable source modules |
| `workflows/` | Existing workflows materialized as WorkflowJSON |
| `knowledge-base/` | Best-practice, template, and SDK reference material |
| `.sandbox-initialized` | Setup marker |

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.24.0",
"version": "1.24.2",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",

View File

@ -308,7 +308,7 @@ describe('setupSandboxWorkspace', () => {
);
});
it('always creates workflows/, src/, and chunks/ even when no workflows exist', async () => {
it('always creates src/ and chunks/', async () => {
const runInSandbox: RunInSandboxMock =
vi.fn<
(
@ -330,16 +330,11 @@ describe('setupSandboxWorkspace', () => {
async () => {},
);
// Setup context defaults to an empty workflow list, mirroring a fresh DB.
await setupSandboxWorkspace(createFilesystemWorkspace(writeFile, mkdir), createSetupContext());
const mkdirPaths = mkdir.mock.calls.map(([path]) => path);
expect(mkdirPaths).toEqual(
expect.arrayContaining([
'/home/daytona/workspace/src',
'/home/daytona/workspace/chunks',
'/home/daytona/workspace/workflows',
]),
expect.arrayContaining(['/home/daytona/workspace/src', '/home/daytona/workspace/chunks']),
);
});
@ -422,43 +417,6 @@ describe('setupSandboxWorkspace', () => {
expect(writtenPaths.some((p) => p.includes('/knowledge-base/templates/'))).toBe(true);
});
it('rejects setup file paths that escape the workspace root', async () => {
const runInSandbox: RunInSandboxMock =
vi.fn<
(
...args: [SandboxWorkspace, string, string?]
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
>();
runInSandbox.mockImplementation(async (_workspace, command) => {
await Promise.resolve();
if (command.startsWith('cat ')) {
return { exitCode: 1, stdout: '', stderr: '' };
}
return { exitCode: 0, stdout: '/home/daytona\n', stderr: '' };
});
const readFileViaSandbox: ReadFileViaSandboxMock =
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
readFileViaSandbox.mockResolvedValue(null);
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
runInSandbox,
readFileViaSandbox,
);
const writeFile = vi.fn<
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
>(async () => {});
const context = createSetupContext();
const workflowService = context.workflowService as unknown as {
list: Mock<(...args: [{ limit: number }]) => Promise<{ workflows: Array<{ id: string }> }>>;
get: Mock<(...args: [string]) => Promise<Record<string, unknown>>>;
};
workflowService.list.mockResolvedValue({ workflows: [{ id: '../escape' }] });
workflowService.get.mockResolvedValue({ id: '../escape' });
await expect(
setupSandboxWorkspace(createFilesystemWorkspace(writeFile), context),
).rejects.toThrow('Sandbox workspace setup failed during write-workspace-files');
});
it('does not write the initialized marker when npm install fails', async () => {
const runInSandbox: RunInSandboxMock =
vi.fn<

View File

@ -12,7 +12,6 @@
* package.json # @n8n/workflow-sdk dependency
* tsconfig.json # strict, noEmit, skipLibCheck
* node_modules/@n8n/workflow-sdk/ # full SDK with .d.ts types
* workflows/ # existing n8n workflows as JSON
* node-types/
* index.txt # searchable catalog: nodeType | displayName | description | version
* src/
@ -307,7 +306,7 @@ export function formatNodeCatalogLine(node: SearchableNodeDescription): string {
}
/** Dirs the agent's `list_files` may probe; some providers 404 on missing dirs. */
const ALWAYS_PRESENT_DIRS: readonly string[] = ['src', 'chunks', 'workflows'];
const ALWAYS_PRESENT_DIRS: readonly string[] = ['src', 'chunks'];
async function writeWorkspaceFiles(
workspace: SandboxWorkspace,
@ -464,24 +463,6 @@ export async function setupSandboxWorkspace(
const catalogLines = nodeTypes.map(formatNodeCatalogLine);
files.set('node-types/index.txt', catalogLines.join('\n'));
// Existing workflows as JSON (fetch in parallel)
try {
const { workflows } = await context.workflowService.list({ limit: 100 });
const results = await Promise.allSettled(
workflows.map(async (summary) => {
const detail = await context.workflowService.get(summary.id);
return { id: summary.id, json: JSON.stringify(detail, null, 2) };
}),
);
for (const r of results) {
if (r.status === 'fulfilled') {
files.set(`workflows/${r.value.id}.json`, r.value.json);
}
}
} catch {
// Workflow listing failed — continue without syncing
}
// ── Write workspace files ──────────────────────────────────────────────
await setupStep(

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/mcp-apps",
"version": "0.16.0",
"version": "0.16.2",
"description": "MCP Apps UI resources and server helpers for n8n",
"main": "dist/server/index.js",
"types": "dist/server/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.48.0",
"version": "0.48.2",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/node-engine-compatibility",
"version": "0.7.0",
"version": "0.7.2",
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
"scripts": {
"clean": "rimraf dist .turbo",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "2.39.0",
"version": "2.39.2",
"description": "",
"main": "index.js",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scheduler",
"version": "1.8.0",
"version": "1.8.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "2.39.0",
"version": "2.39.2",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/workflow-sdk",
"version": "0.32.0",
"version": "0.32.1",
"description": "TypeScript SDK for programmatically creating n8n workflows",
"exports": {
".": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "2.39.0",
"version": "2.39.2",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -76,6 +76,13 @@ describe('DeprecationService', () => {
['N8N_OUTBOUND_PROXY_MODE', 'main-only', true],
['N8N_OUTBOUND_PROXY_MODE', 'all', false],
['N8N_OUTBOUND_PROXY_MODE', undefined, false],
['N8N_RUNNERS_MODE', 'internal', true],
['N8N_RUNNERS_MODE', 'external', false],
['N8N_RUNNERS_MODE', undefined, false],
['N8N_SSRF_PROTECTION_ENABLED', 'true', true],
['N8N_SSRF_PROTECTION_ENABLED', '1', true],
['N8N_SSRF_PROTECTION_ENABLED', 'false', false],
['N8N_SSRF_PROTECTION_ENABLED', undefined, false],
])('should detect when %s is `%s`', (envVar, value, mustWarn) => {
toTest(envVar, value, mustWarn);
});
@ -199,6 +206,28 @@ describe('DeprecationService', () => {
});
});
describe('N8N_SSRF_PROTECTION_ENABLED', () => {
beforeEach(() => {
process.env.N8N_SSRF_PROTECTION_ENABLED = 'true';
});
test.each([undefined, 'default', 'DEFAULT , 100.64.0.0/10'])(
'should warn when N8N_SSRF_BLOCKED_IP_RANGES is `%s`',
(ranges) => {
if (ranges === undefined) delete process.env.N8N_SSRF_BLOCKED_IP_RANGES;
else process.env.N8N_SSRF_BLOCKED_IP_RANGES = ranges;
deprecationService.warn();
expect(logger.warn.mock.lastCall?.[0] ?? '').toContain('N8N_SSRF_PROTECTION_ENABLED');
},
);
test('should not warn when N8N_SSRF_BLOCKED_IP_RANGES lists literal ranges only', () => {
process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,192.168.0.0/16';
deprecationService.warn();
expect(logger.warn.mock.lastCall?.[0] ?? '').not.toContain('N8N_SSRF_PROTECTION_ENABLED');
});
});
describe('running outside a container', () => {
const message = 'Running n8n outside a container is deprecated';

View File

@ -73,6 +73,29 @@ export class DeprecationService {
'The default for this variable will change to `false` in a future version. Set it to `true` explicitly to keep installing unverified community packages.',
checkValue: (value?: string) => value === undefined,
},
{
envVar: 'N8N_RUNNERS_MODE',
message:
'The `internal` mode is deprecated and will be removed in a future version. Run task runners as a separate process and set this variable to `external`.',
checkValue: (value?: string) => value === 'internal',
},
{
envVar: 'N8N_SSRF_PROTECTION_ENABLED',
message:
"The built-in blocked IP ranges will expand in a future version to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. To keep the current list, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges instead of the `default` keyword, which always expands to the running version's built-in list.",
checkValue: (value?: string) => ['true', '1'].includes(value?.toLowerCase() ?? ''),
// Literal block lists without the `default` keyword do not pick up the expanded built-in list.
disableIf: () => {
const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES;
return (
ranges !== undefined &&
!ranges
.toLowerCase()
.split(',')
.some((r) => r.trim() === 'default')
);
},
},
{
envVar: 'N8N_RUNNERS_TASK_TIMEOUT',
message:

View File

@ -2,8 +2,10 @@ import { mockInstance, testDb } from '@n8n/backend-test-utils';
import { DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { randomBytes } from 'node:crypto';
import { EncryptionBootstrapService } from '../encryption-bootstrap.service';
import { KeyManagerService } from '../key-manager.service';
const INSTANCE_ENCRYPTION_KEY = 'legacy-encryption-key';
@ -124,4 +126,90 @@ describe('EncryptionBootstrapService (integration)', () => {
}
});
});
describe('legacy DEK repair', () => {
const seed = async (value: string) => {
const repo = Container.get(DeploymentKeyRepository);
await repo.save(
repo.create({ type: 'data_encryption', value, algorithm: 'aes-256-gcm', status: 'active' }),
);
};
const onlyRow = async () => {
const rows = await Container.get(DeploymentKeyRepository).find({
where: { type: 'data_encryption' },
});
expect(rows).toHaveLength(1);
return rows[0];
};
// Seeds an inactive row and returns it — the partial unique index allows
// only one active row per type, so a multi-row test must stay inactive.
const seedRow = async (value: string) => {
const repo = Container.get(DeploymentKeyRepository);
return await repo.save(
repo.create({
type: 'data_encryption',
value,
algorithm: 'aes-256-gcm',
status: 'inactive',
}),
);
};
it.each<[string, (cipher: Cipher, rawKey: string) => string]>([
['raw 2.18.x', (_cipher, rawKey) => rawKey],
['CBC 2.19.x', (cipher, rawKey) => cipher.encryptWithInstanceKey(rawKey)],
])('repairs a seeded %s key losslessly and idempotently', async (_name, build) => {
const cipher = Container.get(Cipher);
const rawKey = randomBytes(32).toString('hex');
await seed(build(cipher, rawKey));
const km = Container.get(KeyManagerService);
await km.repairLegacyDataEncryptionKeys();
const afterFirst = await onlyRow();
expect(cipher.decryptDEKWithInstanceKey(afterFirst.value)).toBe(rawKey);
// A second run leaves the now-GCM value untouched.
await km.repairLegacyDataEncryptionKeys();
const afterSecond = await onlyRow();
expect(afterSecond.value).toBe(afterFirst.value);
});
it('repairs every legacy row in a mixed set and keeps going past a skipped row', async () => {
const cipher = Container.get(Cipher);
const repo = Container.get(DeploymentKeyRepository);
const rawA = randomBytes(32).toString('hex');
const rawB = randomBytes(32).toString('hex');
const rawC = randomBytes(32).toString('hex');
const rawRow = await seedRow(rawA);
const gcmRow = await seedRow(cipher.encryptDEKWithInstanceKey(rawB));
const cbcRow = await seedRow(cipher.encryptWithInstanceKey(rawC));
await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys();
const rows = await repo.find({ where: { type: 'data_encryption' } });
const byId = (id: string) => rows.find((r) => r.id === id)!;
// Both legacy rows are now GCM-readable and recover their exact keys.
expect(cipher.decryptDEKWithInstanceKey(byId(rawRow.id).value)).toBe(rawA);
expect(cipher.decryptDEKWithInstanceKey(byId(cbcRow.id).value)).toBe(rawC);
// The already-GCM row is untouched, byte for byte.
expect(byId(gcmRow.id).value).toBe(gcmRow.value);
});
it('leaves a properly GCM-wrapped key untouched', async () => {
const cipher = Container.get(Cipher);
const rawKey = randomBytes(32).toString('hex');
await seed(cipher.encryptDEKWithInstanceKey(rawKey));
const before = await onlyRow();
await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys();
const after = await onlyRow();
expect(after.value).toBe(before.value);
});
});
});

View File

@ -10,6 +10,7 @@ describe('EncryptionBootstrapService', () => {
beforeEach(() => {
vi.clearAllMocks();
keyManager.repairLegacyDataEncryptionKeys.mockResolvedValue(undefined);
keyManager.bootstrapLegacyCbcKey.mockResolvedValue(undefined);
keyManager.bootstrapGcmKey.mockResolvedValue(undefined);
});
@ -66,6 +67,15 @@ describe('EncryptionBootstrapService', () => {
expect(encryptionKeyProxy.setProvider).toHaveBeenCalledWith(keyManager);
});
it('repairs legacy keys before seeding', async () => {
await createService().run();
expect(keyManager.repairLegacyDataEncryptionKeys).toHaveBeenCalled();
expect(keyManager.repairLegacyDataEncryptionKeys.mock.invocationCallOrder[0]).toBeLessThan(
keyManager.bootstrapGcmKey.mock.invocationCallOrder[0],
);
});
it('bootstraps CBC before GCM', async () => {
const order: string[] = [];
keyManager.bootstrapLegacyCbcKey.mockImplementation(async () => {

View File

@ -3,7 +3,14 @@ import { mockInstance } from '@n8n/backend-test-utils';
import type { DeploymentKey } from '@n8n/db';
import { DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import {
Cipher,
CipherAes256CBC,
CipherAes256GCM,
type EncryptionKeyProxy,
InstanceSettings,
} from 'n8n-core';
import { randomBytes } from 'node:crypto';
import { mock } from 'vitest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -672,4 +679,74 @@ describe('KeyManagerService', () => {
expect(repo.find).toHaveBeenCalledTimes(1);
});
});
describe('repairLegacyDataEncryptionKeys()', () => {
// A real Cipher, so every legacy value is produced by real encryption and the
// re-wrap round-trips for real. Only the repository is mocked.
const realCipher = (encryptionKey: string) =>
new Cipher(
mock<InstanceSettings>({ encryptionKey }),
new CipherAes256GCM(),
new CipherAes256CBC(),
mock<EncryptionKeyProxy>(),
);
const makeRepairService = (encryptionKey = randomBytes(24).toString('base64')) => {
const repo = mock<DeploymentKeyRepository>();
const cipher = realCipher(encryptionKey);
const service = new KeyManagerService(
repo,
cipher,
mock<InstanceSettings>({ encryptionKey }),
mock<Logger>(),
);
return { service, repo, cipher };
};
// A real, freshly generated DEK — never a hand-built constant.
const rawKey = randomBytes(32).toString('hex');
it.each<[string, (cipher: Cipher) => string]>([
['raw 2.18.x', () => rawKey],
['CBC 2.19.x', (cipher) => cipher.encryptWithInstanceKey(rawKey)],
])('recovers a %s key and re-wraps it as GCM', async (_name, build) => {
const { service, repo, cipher } = makeRepairService();
repo.findDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'k', value: build(cipher) })]);
await service.repairLegacyDataEncryptionKeys();
const [id, , wrapped] = repo.rewrapLegacyDataEncryptionValue.mock.calls[0];
expect(id).toBe('k');
// The re-wrapped value unwraps back to the exact same key: lossless.
expect(cipher.decryptDEKWithInstanceKey(wrapped)).toBe(rawKey);
});
it.each<[string, (cipher: Cipher, other: Cipher) => string]>([
['already GCM-wrapped', (cipher) => cipher.encryptDEKWithInstanceKey(rawKey)],
[
'CBC under a different instance key',
(_cipher, other) => other.encryptWithInstanceKey(rawKey),
],
['CBC that unwraps to a non-key', (cipher) => cipher.encryptWithInstanceKey('not-a-hex-key')],
])('leaves a %s value untouched', async (_name, build) => {
const { service, repo, cipher } = makeRepairService();
const other = realCipher(randomBytes(24).toString('base64'));
repo.findDataEncryptionKeys.mockResolvedValue([
makeKey({ id: 'k', value: build(cipher, other) }),
]);
await service.repairLegacyDataEncryptionKeys();
expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled();
});
it('is a no-op when there are no data-encryption keys', async () => {
const { service, repo } = makeRepairService();
repo.findDataEncryptionKeys.mockResolvedValue([]);
await service.repairLegacyDataEncryptionKeys();
expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled();
});
});
});

View File

@ -25,6 +25,7 @@ export class EncryptionBootstrapService {
// the provider is wired for commands too.
if (this.instanceSettings.instanceType === 'main' && this.canSeed) {
try {
await this.keyManager.repairLegacyDataEncryptionKeys();
await this.keyManager.bootstrapLegacyCbcKey(this.instanceSettings.encryptionKey);
await this.keyManager.bootstrapGcmKey();
} catch (error) {

View File

@ -20,6 +20,12 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { isKeyRotationEnabled } from './key-rotation-flag';
/** Raw DEK format: 64 hex chars stored directly as key material (n8n 2.18.x). */
const RAW_DEK_PATTERN = /^[0-9a-f]{64}$/i;
/** AES-256-CBC (OpenSSL Salted__) wrapped DEK, base64 prefix (n8n 2.19.x). */
const CBC_WRAPPED_PREFIX = 'U2FsdGVk';
/**
* How long a process trusts its database view of the active key. A rotation
* done on another instance becomes visible within this window — the accepted
@ -122,6 +128,64 @@ export class KeyManagerService implements IEncryptionKeyProvider {
return keyInfo;
}
/**
* Returns the raw DEK for a legacy-format value, or null if the value is already
* GCM-wrapped or cannot be recovered with this instance key.
*/
private recoverLegacyDek(keyInfo: DeploymentKey): string | null {
const { value } = keyInfo;
// 2.18.x: raw key material, used directly. Re-wrap as-is, no decrypt needed.
if (RAW_DEK_PATTERN.test(value)) {
return value;
}
// 2.19.x: DEK wrapped with the instance key via AES-256-CBC.
if (value.startsWith(CBC_WRAPPED_PREFIX)) {
let recovered: string;
try {
recovered = this.cipher.decryptWithInstanceKey(value);
} catch {
this.logger.warn(
`Failed to decrypt legacy CBC-wrapped DEK ${keyInfo.id} with the instance key, this was probably wrapped with a different instance key`,
);
return null;
}
if (!RAW_DEK_PATTERN.test(recovered)) {
this.logger.warn(`Recovered legacy DEK ${keyInfo.id} is not valid raw key material`);
return null;
}
return recovered;
}
// Already GCM-wrapped or an unknown format: leave untouched.
return null;
}
/**
* Re-wraps data-encryption keys stored in a pre-2.20 legacy format. Early key
* managers stored the DEK either as raw 64-hex key material (2.18.x) or wrapped
* with the instance key via AES-256-CBC (2.19.x). The current unwrap path expects
* a GCM blob, so both fail to decrypt. Both wrap the same 64-hex DEK; recovering
* it and re-wrapping with GCM restores the exact same key, so data stays readable.
* Idempotent: a GCM value matches neither legacy detector and is left untouched.
*/
async repairLegacyDataEncryptionKeys(): Promise<void> {
const rows = await this.deploymentKeyRepository.findDataEncryptionKeys();
for (const row of rows) {
const rawKey = this.recoverLegacyDek(row);
if (rawKey === null) continue;
const wrapped = this.cipher.encryptDEKWithInstanceKey(rawKey);
await this.deploymentKeyRepository.rewrapLegacyDataEncryptionValue(
row.id,
row.value,
wrapped,
);
this.logger.info(`Re-wrapped legacy DEK ${row.id} with the instance key`);
}
}
/**
* Returns the legacy CBC key used to decrypt old rows (no keyId prefix).
*

View File

@ -37,6 +37,8 @@ import './v3/in-memory-binary-data.rule';
import './v3/offload-manual-executions.rule';
import './v3/removed-nodes.rule';
import './v3/removed-nodes-with-replacements.rule';
import './v3/ssrf-default-blocked-ranges.rule';
import './v3/task-runner-internal-mode.rule';
import './v3/task-runner-task-timeout.rule';
import './v3/unverified-packages.rule';
import './v3/workflow-import-url-removed.rule';

View File

@ -0,0 +1,54 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import { SsrfDefaultBlockedRangesRule } from '../ssrf-default-blocked-ranges.rule';
describe('SsrfDefaultBlockedRangesRule', () => {
const createRule = (enabled: boolean) =>
new SsrfDefaultBlockedRangesRule(mock<GlobalConfig>({ ssrfProtection: { enabled } }));
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.N8N_SSRF_BLOCKED_IP_RANGES;
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should be affected when SSRF protection is enabled', async () => {
const result = await createRule(true).detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
it('should be affected when the block list keeps the default keyword', async () => {
process.env.N8N_SSRF_BLOCKED_IP_RANGES = 'Default, 100.64.0.0/10';
const result = await createRule(true).detect();
expect(result.isAffected).toBe(true);
});
it('should not be affected when the block list is literal ranges only', async () => {
process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,127.0.0.0/8';
const result = await createRule(true).detect();
expect(result.isAffected).toBe(false);
});
it('should not be affected when SSRF protection is disabled', async () => {
const result = await createRule(false).detect();
expect(result.isAffected).toBe(false);
expect(result.instanceIssues).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,42 @@
import { TaskRunnerInternalModeRule } from '../task-runner-internal-mode.rule';
describe('TaskRunnerInternalModeRule', () => {
const rule = new TaskRunnerInternalModeRule();
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.N8N_RUNNERS_MODE;
});
afterAll(() => {
process.env = originalEnv;
});
describe('detect()', () => {
it('should be affected when the mode is explicitly internal', async () => {
process.env.N8N_RUNNERS_MODE = 'internal';
const result = await rule.detect();
expect(result.isAffected).toBe(true);
expect(result.instanceIssues).toHaveLength(1);
expect(result.instanceIssues[0].level).toBe('warning');
expect(result.recommendations).toHaveLength(1);
});
it('should not be affected when the mode is external', async () => {
process.env.N8N_RUNNERS_MODE = 'external';
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
it('should not be affected when the mode is not set', async () => {
const result = await rule.detect();
expect(result.isAffected).toBe(false);
});
});
});

View File

@ -0,0 +1,64 @@
import { GlobalConfig } from '@n8n/config';
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class SsrfDefaultBlockedRangesRule implements IBreakingChangeInstanceRule {
constructor(private readonly globalConfig: GlobalConfig) {}
id: string = 'ssrf-default-blocked-ranges-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'SSRF protection blocks more IP ranges by default',
description:
'The built-in blocked IP ranges expand to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. Requests to hosts in these ranges start failing on instances with SSRF protection enabled.',
category: BreakingChangeCategory.environment,
severity: 'medium',
documentationUrl:
'https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security',
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async detect(): Promise<InstanceDetectionReport> {
// Instances that enumerate literal ranges do not pick up the expanded built-in list.
const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES;
const usesDefaultList =
ranges === undefined ||
ranges
.toLowerCase()
.split(',')
.some((r) => r.trim() === 'default');
if (!this.globalConfig.ssrfProtection.enabled || !usesDefaultList) {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues: [
{
title: 'SSRF protection uses the built-in blocked IP ranges',
description:
'N8N_SSRF_PROTECTION_ENABLED is true and N8N_SSRF_BLOCKED_IP_RANGES relies on the built-in list, either because it is not set or because it contains the `default` keyword. After the update, that list also covers 100.64.0.0/10 and IPv6 transition ranges, so workflows calling hosts in these ranges fail.',
level: 'warning',
},
],
recommendations: [
{
action: 'Review internal hosts in the newly blocked ranges',
description:
"Check if any workflow calls hosts in 100.64.0.0/10 or IPv6 transition ranges. Add the ones you still need to N8N_SSRF_ALLOWED_IP_RANGES or N8N_SSRF_ALLOWED_HOSTNAMES. To keep the current list exactly, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges; the `default` keyword always expands to the running version's built-in list.",
},
],
};
}
}

View File

@ -0,0 +1,51 @@
import { BreakingChangeRule } from '@n8n/decorators';
import type {
BreakingChangeRuleMetadata,
IBreakingChangeInstanceRule,
InstanceDetectionReport,
} from '../../types';
import { BreakingChangeCategory } from '../../types';
@BreakingChangeRule({ version: 'v3' })
export class TaskRunnerInternalModeRule implements IBreakingChangeInstanceRule {
id: string = 'task-runner-internal-mode-v3';
getMetadata(): BreakingChangeRuleMetadata {
return {
version: 'v3',
title: 'Internal task runner mode is removed',
description:
'Task runners can no longer run as a child process of n8n. The new version only supports N8N_RUNNERS_MODE=external and fails to start Code node executions when no external task runner connects.',
category: BreakingChangeCategory.infrastructure,
severity: 'medium',
documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners',
};
}
// eslint-disable-next-line @typescript-eslint/require-await
async detect(): Promise<InstanceDetectionReport> {
if (process.env.N8N_RUNNERS_MODE !== 'internal') {
return { isAffected: false, instanceIssues: [], recommendations: [] };
}
return {
isAffected: true,
instanceIssues: [
{
title: 'N8N_RUNNERS_MODE is set to "internal"',
description:
'This instance runs task runners inside the n8n process. The new version rejects this mode, so Code nodes stop executing until an external task runner is connected.',
level: 'warning',
},
],
recommendations: [
{
action: 'Switch to external task runners before upgrading',
description:
'Run the task runner launcher as a separate process or container, set N8N_RUNNERS_MODE=external and share N8N_RUNNERS_AUTH_TOKEN between n8n and the launcher.',
},
],
};
}
}

View File

@ -11,16 +11,29 @@ import { Container } from '@n8n/di';
import type { INode } from 'n8n-workflow';
import { CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_PATH_SUFFIX, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { createMember, createOwner } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import { AuthService } from '@/auth/auth.service';
import { AUTH_COOKIE_NAME } from '@/constants';
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
import { CacheService } from '@/services/cache/cache.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import { UrlService } from '@/services/url.service';
/** Root-level (no `/rest` prefix) agent authenticated as `user` — `authAgentFor` always
* prefixes `/rest`, which 404s against root-level routes like `/oauth/authorize`. */
const rootAgentFor = (user: User) => {
const agent = request.agent(testServer.app);
const token = Container.get(AuthService).issueJWT(user, user.mfaEnabled);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
return agent;
};
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
@ -105,6 +118,7 @@ afterEach(async () => {
'RefreshToken',
'AuthorizationCode',
'OAuthClient',
'UserConsent',
'WebhookEntity',
'SharedWorkflow',
'WorkflowEntity',
@ -405,3 +419,121 @@ describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () =>
expect(result.user?.id).toBe(member.id);
});
});
describe('consent reuse on a second visit', () => {
const pkce = async () => {
const { createHash, randomBytes } = await import('node:crypto');
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
return { codeVerifier, codeChallenge };
};
const authorizeQuery = (resourceUrl: string, codeChallenge: string, state: string) => ({
client_id: resourceUrl,
redirect_uri: resourceUrl,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
resource: resourceUrl,
state,
});
test('a second visit reuses consent when the visitor is already logged in', async () => {
// Control case: confirms tryAutoApproveConsent/tryReuseConsent themselves work when
// the n8n-auth cookie is already present on the very first /oauth/authorize hit.
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode());
const resourceUrl = resourceUrlFor(path);
const authAgent = rootAgentFor(owner);
const { codeChallenge: cc1 } = await pkce();
const first = await authAgent
.get('/oauth/authorize')
.query(authorizeQuery(resourceUrl, cc1, 'state-1'));
expect(first.statusCode).toBe(302);
expect(first.headers.location).toBe('/oauth/consent');
const rawSetCookie: string | string[] = first.headers['set-cookie'] ?? [];
const setCookies = Array.isArray(rawSetCookie) ? rawSetCookie : [rawSetCookie];
const sessionCookie = setCookies
.map((cookie) => cookie.split(';')[0])
.find((cookie) => cookie.startsWith('n8n-oauth-session='));
expect(sessionCookie).toBeDefined();
// `/consent/approve` lives under the `/rest` prefix, unlike the root-level
// `/oauth/*` routes — a separate, `/rest`-prefixed agent for the same user,
// carrying the session cookie the authorize step just set.
const consentAgent = testServer.authAgentFor(owner);
consentAgent.jar.setCookie(sessionCookie ?? '');
const approve = await consentAgent
.post('/consent/approve')
.send({ approved: true, scopes: [] });
expect(approve.statusCode).toBe(200);
const { codeChallenge: cc2 } = await pkce();
const second = await authAgent
.get('/oauth/authorize')
.query(authorizeQuery(resourceUrl, cc2, 'state-2'));
expect(second.statusCode).toBe(302);
expect(second.headers.location).not.toBe('/oauth/consent');
expect(second.headers.location).toContain(resourceUrl);
});
test('a visitor who already consented is auto-approved after authenticating mid-flow', async () => {
// The visitor already has a UserConsent row from a prior visit (e.g. the local grant
// cookie was cleared/expired), but their n8n-auth cookie is gone too — so the very
// first /oauth/authorize hit has no cookie to check
// and tryAutoApproveConsent is skipped. They then log in as part of reaching the
// (auth-gated) consent page. GET /consent/details now retries the reuse check once
// the user is authenticated, instead of unconditionally returning the manual picker.
const path = chatPath();
await createPublishedChatWorkflow(path, chatTriggerNode());
const resourceUrl = resourceUrlFor(path);
// Seed a prior consent for this exact (clientId, user) pair, as a previous visit
// would have left behind.
await Container.get(OAuthClientRepository).upsert(
{
id: resourceUrl,
name: 'chat trigger',
redirectUris: [resourceUrl],
grantTypes: ['authorization_code', 'refresh_token'],
tokenEndpointAuthMethod: 'none',
},
['id'],
);
await Container.get(UserConsentRepository).upsert(
{ userId: owner.id, clientId: resourceUrl, grantedAt: Date.now(), scope: [] },
['userId', 'clientId'],
);
// Not authenticated yet on this first hit — the browser has no n8n-auth cookie.
const { codeChallenge } = await pkce();
const first = await testServer.restlessAgent
.get('/oauth/authorize')
.query(authorizeQuery(resourceUrl, codeChallenge, 'state-1'));
expect(first.statusCode).toBe(302);
expect(first.headers.location).toBe('/oauth/consent');
const rawSetCookie: string | string[] = first.headers['set-cookie'] ?? [];
const setCookies = Array.isArray(rawSetCookie) ? rawSetCookie : [rawSetCookie];
const sessionCookie = setCookies
.map((cookie) => cookie.split(';')[0])
.find((cookie) => cookie.startsWith('n8n-oauth-session='));
expect(sessionCookie).toBeDefined();
// The visitor now logs in (this is the "just logged in" step from the ticket),
// carrying the same in-flight OAuth session cookie into the authenticated request.
const consentAgent = testServer.authAgentFor(owner);
consentAgent.jar.setCookie(sessionCookie ?? '');
const details = await consentAgent.get('/consent/details');
// Desired: already consented, so this should signal auto-approval instead of
// requiring another manual click.
expect(details.body.data?.autoApproved ?? false).toBe(true);
});
});

View File

@ -89,6 +89,7 @@ describe('OAuthConsentService', () => {
expect(result).toEqual({
ok: true,
autoApproved: false,
clientName: 'Test Client',
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
@ -176,6 +177,7 @@ describe('OAuthConsentService', () => {
expect(result).toEqual({
ok: true,
autoApproved: false,
clientName: 'Test Client',
clientId: 'client-123',
redirectUri: 'https://example.com/callback',
@ -284,6 +286,7 @@ describe('OAuthConsentService', () => {
expect(result).toEqual({
ok: true,
autoApproved: false,
clientName: 'Test Client',
clientId: 'client-123',
resourceName: 'My Workflow',
@ -325,6 +328,7 @@ describe('OAuthConsentService', () => {
expect(result).toEqual({
ok: true,
autoApproved: false,
clientName: 'Test Client',
clientId: 'client-123',
resourceName: undefined,

View File

@ -44,6 +44,19 @@ export class OAuthConsentController {
return;
}
if (consentDetails.autoApproved) {
// The session's decision is already made — consume it, same as a manual approval.
this.oauthSessionService.clearSession(res);
res.json({
data: {
autoApproved: true,
redirectUrl: consentDetails.redirectUrl,
uiHints: consentDetails.uiHints,
},
});
return;
}
res.json({
data: {
clientName: consentDetails.clientName,

View File

@ -19,6 +19,14 @@ import { UrlService } from '@/services/url.service';
type ConsentDetailsResult =
| {
ok: true;
autoApproved: true;
redirectUrl: string;
/** Presentational hints for the header icon while the redirect happens. */
uiHints?: ConsentUiHints;
}
| {
ok: true;
autoApproved: false;
clientName: string;
clientId: string;
resourceName?: string;
@ -82,6 +90,20 @@ export class OAuthConsentService {
return { ok: false, reason: 'resource_unavailable' };
}
// Resolved once above and threaded through, so a first-time consent (the
// common case here — a prior consent already short-circuits via
// tryAutoApproveConsent at /oauth/authorize) doesn't pay for the resource
// resolver's DB-backed lookup twice.
const reuse = await this.tryReuseConsent(user, sessionPayload, resource);
if (reuse) {
return {
ok: true,
autoApproved: true,
redirectUrl: reuse.redirectUrl,
uiHints: resource.uiHints,
};
}
if (!(await resource.authorize(user)))
return {
ok: false,
@ -92,6 +114,7 @@ export class OAuthConsentService {
return {
ok: true,
autoApproved: false,
clientName: client.name,
clientId: client.id,
resourceName: resource.displayName,
@ -117,6 +140,7 @@ export class OAuthConsentService {
return {
ok: true,
autoApproved: false,
clientName: client.name,
clientId: client.id,
redirectUri: sessionPayload.redirectUri,
@ -312,13 +336,20 @@ export class OAuthConsentService {
return scopes;
}
/**
* @param resolvedResource The caller's own resolution of `sessionPayload.resource`,
* when it already has one — skips resolving it again here.
*/
async tryReuseConsent(
user: User,
sessionPayload: OAuthSessionPayload,
resolvedResource?: ProtectedResource,
): Promise<{ redirectUrl: string } | null> {
if (!sessionPayload.resource) return null;
const resource = await this.protectedResourceRegistry.getByResourceUrl(sessionPayload.resource);
const resource =
resolvedResource ??
(await this.protectedResourceRegistry.getByResourceUrl(sessionPayload.resource));
if (!resource?.isFirstParty) return null;
const consent = await this.userConsentRepository.findOne({

View File

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "2.39.0",
"version": "2.39.2",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "1.38.0",
"version": "1.38.2",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/composables",
"type": "module",
"version": "1.29.0",
"version": "1.29.2",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/design-system",
"version": "2.38.0",
"version": "2.38.2",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "@n8n/frontend-module-sdk",
"version": "0.9.0",
"version": "0.9.2",
"main": "src/index.ts",
"import": "src/index.ts",
"exports": {

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/i18n",
"type": "module",
"version": "2.39.0",
"version": "2.39.1",
"files": [
"dist"
],

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/rest-api-client",
"type": "module",
"version": "2.39.0",
"version": "2.39.2",
"files": [
"dist"
],

View File

@ -3,7 +3,7 @@ import type { ConsentUiHints } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export interface ConsentDetails {
export interface ConsentDetailsPicker {
clientName: string;
clientId: string;
redirectUri?: string;
@ -23,6 +23,20 @@ export interface ConsentDetails {
isFirstParty?: boolean;
}
export type ConsentDetails =
| {
/**
* A prior consent already covers this request — e.g. the visitor only
* authenticated as part of reaching this page. There is nothing left to
* show a picker for.
*/
autoApproved: true;
redirectUrl: string;
/** Presentation hints from the resource, used for the header icon while redirecting. */
uiHints?: ConsentUiHints;
}
| ({ autoApproved?: false } & ConsentDetailsPicker);
export interface ConsentApprovalResponse {
status: string;
redirectUrl: string;

View File

@ -1,7 +1,7 @@
{
"name": "@n8n/stores",
"type": "module",
"version": "2.39.0",
"version": "2.39.2",
"files": [
"dist"
],

View File

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "2.39.0",
"version": "2.39.2",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"type": "module",

View File

@ -113,7 +113,7 @@ const resetFilters = () => {
};
const shouldBeIconButton = computed(() => {
return !hasFilters.value;
return props.justIcon && !hasFilters.value;
});
watch(filtersLength, (value) => {

View File

@ -0,0 +1,96 @@
import { createPinia, setActivePinia } from 'pinia';
import { useConsentStore } from './consent.store';
import { ResponseError } from '@n8n/rest-api-client/utils';
const { getConsentDetails, approveConsent } = vi.hoisted(() => ({
getConsentDetails: vi.fn(),
approveConsent: vi.fn(),
}));
vi.mock('@n8n/rest-api-client/api/consent', () => ({
getConsentDetails,
approveConsent,
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: vi.fn(() => ({
restApiContext: { baseUrl: 'http://localhost:5678', pushRef: 'test' },
})),
}));
describe('useConsentStore', () => {
let consentStore: ReturnType<typeof useConsentStore>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());
consentStore = useConsentStore();
});
it('stores the resolved details and clears loading', async () => {
const details = { clientName: 'Test Client', clientId: 'client-1', scopes: [] };
getConsentDetails.mockResolvedValue(details);
const result = await consentStore.fetchConsentDetails();
expect(result).toEqual(details);
expect(consentStore.consentDetails).toEqual(details);
expect(consentStore.isLoading).toBe(false);
});
it('sets a resource_unavailable error code on a 422', async () => {
getConsentDetails.mockRejectedValue(new ResponseError('gone', { httpStatusCode: 422 }));
await expect(consentStore.fetchConsentDetails()).rejects.toThrow('gone');
expect(consentStore.errorCode).toBe('resource_unavailable');
expect(consentStore.error).toBe('gone');
expect(consentStore.isLoading).toBe(false);
});
// A component that abandons one fetch (e.g. it re-fetches, or a fresh mount starts
// its own call) must not have its result overwritten by the earlier, now-stale one
// resolving later — regardless of which settles first.
it('ignores a stale call that resolves after a newer one', async () => {
let resolveStale!: (value: unknown) => void;
getConsentDetails.mockReturnValueOnce(
new Promise((resolve) => {
resolveStale = resolve;
}),
);
const staleCall = consentStore.fetchConsentDetails();
const freshDetails = { clientName: 'Fresh Client', clientId: 'client-2', scopes: [] };
getConsentDetails.mockResolvedValueOnce(freshDetails);
await consentStore.fetchConsentDetails();
expect(consentStore.consentDetails).toEqual(freshDetails);
resolveStale({ clientName: 'Stale Client', clientId: 'client-1', scopes: [] });
await staleCall;
expect(consentStore.consentDetails).toEqual(freshDetails);
});
it('ignores a stale call failing after a newer one succeeded', async () => {
let rejectStale!: (reason: unknown) => void;
getConsentDetails.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectStale = reject;
}),
);
const staleCall = consentStore.fetchConsentDetails().catch(() => {});
const freshDetails = { clientName: 'Fresh Client', clientId: 'client-2', scopes: [] };
getConsentDetails.mockResolvedValueOnce(freshDetails);
await consentStore.fetchConsentDetails();
rejectStale(new Error('stale failure'));
await staleCall;
expect(consentStore.consentDetails).toEqual(freshDetails);
expect(consentStore.error).toBeNull();
expect(consentStore.isLoading).toBe(false);
});
});

View File

@ -15,24 +15,36 @@ export const useConsentStore = defineStore(STORES.CONSENT, () => {
const rootStore = useRootStore();
// Guards against a stale call's response overwriting a newer call's, if two are
// ever in flight at once — each call only writes shared state while it's still
// the most recently issued one.
let latestRequestId = 0;
const fetchConsentDetails = async () => {
const requestId = ++latestRequestId;
isLoading.value = true;
error.value = null;
errorCode.value = null;
try {
consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext);
const response = await consentApi.getConsentDetails(rootStore.restApiContext);
if (requestId !== latestRequestId) return consentDetails.value;
consentDetails.value = response;
return consentDetails.value;
} catch (err) {
if (err instanceof ResponseError && err.httpStatusCode === 422) {
errorCode.value = 'resource_unavailable';
} else if (err instanceof ResponseError && err.httpStatusCode === 403) {
errorCode.value = 'forbidden';
if (requestId === latestRequestId) {
if (err instanceof ResponseError && err.httpStatusCode === 422) {
errorCode.value = 'resource_unavailable';
} else if (err instanceof ResponseError && err.httpStatusCode === 403) {
errorCode.value = 'forbidden';
}
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
}
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
throw err;
} finally {
isLoading.value = false;
if (requestId === latestRequestId) {
isLoading.value = false;
}
}
};

View File

@ -5,6 +5,7 @@ import OAuthConsentView from '@/app/views/OAuthConsentView.vue';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
import { nextTick } from 'vue';
vi.mock('@n8n/rest-api-client/api/consent');
@ -119,6 +120,64 @@ describe('OAuthConsentView', () => {
expect(queryByText('Get a list of your workflows')).toBeNull();
});
it('should redirect immediately without showing the picker or a success message when the server auto-approves', async () => {
// The visitor never clicked anything here — a "success" message would be
// confusing for something they didn't consciously trigger, so this must render
// the same blank state as the initial fetch, not the manual-approve success screen.
const redirectUrl = 'https://legitimate-client.com/callback?code=reused';
consentStore.fetchConsentDetails.mockResolvedValue({
autoApproved: true,
redirectUrl,
} as never);
const { queryByTestId, getByTestId } = renderComponent();
await waitAllPromises();
expect(consentStore.approveConsent).not.toHaveBeenCalled();
expect(queryByTestId('consent-allow-button')).toBeNull();
expect(queryByTestId('consent-deny-button')).toBeNull();
expect(queryByTestId('consent-success-screen')).toBeNull();
expect(getByTestId('consent-loading')).toBeVisible();
expect(window.location.href).toBe(redirectUrl);
});
it('should not flash the generic instance-wide picker while the details fetch is pending', async () => {
// Before the fetch resolves, `consentDetails` is still null (its initial state,
// and its value on a fetch error too), so the template must not fall through to
// the generic/default-resource copy — that's not known to be correct yet.
consentStore.consentDetails = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveFetch!: (value: any) => void;
consentStore.fetchConsentDetails.mockImplementation(
async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await new Promise<any>((resolve) => {
resolveFetch = resolve;
}),
);
const { queryByTestId, getByTestId, queryByText } = renderComponent();
await nextTick();
expect(queryByTestId('consent-content')).toBeNull();
expect(queryByText('Test MCP Client wants access to your n8n instance')).toBeNull();
expect(getByTestId('consent-loading')).toBeVisible();
// The real store assigns `consentDetails` as part of resolving the fetch;
// mirror that so the template's store-bound `clientDetails` picks it up too.
const resolvedDetails = {
clientName: 'Test MCP Client',
clientId: 'test-client-id',
scopes: [],
};
consentStore.consentDetails = resolvedDetails;
resolveFetch(resolvedDetails);
await waitAllPromises();
expect(queryByTestId('consent-loading')).toBeNull();
expect(getByTestId('consent-content')).toBeVisible();
});
it('should redirect to home page when deny is clicked', async () => {
consentStore.approveConsent.mockResolvedValue({
status: 'denied',

View File

@ -3,8 +3,8 @@ import { useConsentStore } from '@/app/stores/consent.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { onMounted, computed, ref, watch } from 'vue';
import type { ConsentDetails } from '@n8n/rest-api-client/api/consent';
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
import type { ConsentDetailsPicker } from '@n8n/rest-api-client/api/consent';
import {
N8nButton,
N8nCallout,
@ -31,12 +31,21 @@ const telemetry = useTelemetry();
// Success state:
const waitingForRedirect = ref(false);
// Set instead of `waitingForRedirect` when the server silently reused a prior consent:
// the visitor never clicked anything here, so a "success" message would be confusing —
// this renders the same blank state as the initial fetch, not a message that then flashes.
const autoApprovedRedirect = ref(false);
const detailsResolved = ref(false);
const redirectUriTrusted = ref(false);
const selectedScopes = ref<string[]>([]);
let isActive = true;
onUnmounted(() => {
isActive = false;
});
const error = computed(() => consentStore.error);
const loading = computed(() => consentStore.isLoading);
const resourceName = computed(() => consentStore.consentDetails?.resourceName);
const errorMessage = computed(() => {
if (consentStore.errorCode === 'resource_unavailable') {
@ -47,7 +56,14 @@ const errorMessage = computed(() => {
return consentStore.error;
});
const clientDetails = computed<ConsentDetails | null>(() => consentStore.consentDetails);
// Narrows away the auto-approved redirect signal, which carries none of these fields
// and is handled separately in `onMounted` before this is ever read.
const clientDetails = computed<ConsentDetailsPicker | null>(() => {
const details = consentStore.consentDetails;
return details && !details.autoApproved ? details : null;
});
const resourceName = computed(() => clientDetails.value?.resourceName);
const uiHints = computed(() => consentStore.consentDetails?.uiHints);
// Known clients get their brand mark on the left tile; unknown ones fall back to the MCP glyph.
const clientBrandIcon = computed(() => getClientBrand(clientDetails.value?.clientName ?? '').icon);
// Localized noun for first-party copy, driven by the resource's consentType hint.
@ -139,12 +155,20 @@ const handleClose = () => {
onMounted(async () => {
documentTitle.set(i18n.baseText('oauth.consentView.title'));
try {
await consentStore.fetchConsentDetails();
const details = await consentStore.fetchConsentDetails();
if (!isActive) return;
detailsResolved.value = true;
if (details?.autoApproved && details.redirectUrl) {
autoApprovedRedirect.value = true;
window.location.href = details.redirectUrl;
return;
}
telemetry.track('User viewed MCP consent screen', {
client_name: clientDetails.value?.clientName,
available_scopes_count: availableScopes.value.length,
});
} catch (err) {
if (!isActive) return;
toast.showError(err, i18n.baseText('oauth.consentView.error.fetchDetails'));
}
});
@ -155,18 +179,13 @@ onMounted(async () => {
<div :class="$style['consent-dialog']">
<header :class="$style.header">
<div :class="$style.logo">
<N8nIcon
v-if="clientDetails?.uiHints?.icon"
:icon="clientDetails.uiHints.icon"
size="large"
color="text-dark"
/>
<N8nIcon v-if="uiHints?.icon" :icon="uiHints.icon" size="large" color="text-dark" />
<component
:is="clientBrandIcon"
v-else-if="clientBrandIcon"
:class="$style['brand-icon']"
/>
<N8nIcon v-else icon="mcp" size="large" color="text-dark" />
<N8nIcon v-else-if="detailsResolved || error" icon="mcp" size="large" color="text-dark" />
</div>
<!-- Pending-connection connector: a dashed SVG line marching toward the n8n tile
with a slow muted spinner badge. Decorative. -->
@ -213,6 +232,15 @@ onMounted(async () => {
:content="errorMessage ?? ''"
></N8nNotice>
</div>
<!-- Nothing resolved yet, or the server just silently reused a prior consent:
never guess at generic instance-wide copy, and never announce a "success"
the visitor didn't ask for — the header's own connector spinner already
signals activity while this redirects. -->
<div
v-else-if="autoApprovedRedirect || !detailsResolved"
:class="$style.content"
data-test-id="consent-loading"
/>
<!-- Default content -->
<div v-else :class="$style.content" data-test-id="consent-content">
<N8nHeading v-if="clientDetails?.isFirstParty" tag="h2" size="large" :bold="true">
@ -285,7 +313,10 @@ onMounted(async () => {
</ul>
</div>
</div>
<footer v-if="!waitingForRedirect" :class="$style.footer">
<footer
v-if="!waitingForRedirect && !autoApprovedRedirect && (error || detailsResolved)"
:class="$style.footer"
>
<!-- Third-party clients: the redirect destination, with the trust acknowledgment
below it in the action row so it reads as a step rather than banner small
print. Both are gated on the same `trustRequired` as the Allow button, so the

View File

@ -67,7 +67,7 @@ const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
{
title: i18n.baseText('settings.migrationReport.detail.table.name'),
key: 'name',
width: 200,
width: 240,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.status'),
@ -76,26 +76,27 @@ const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
row.active
? i18n.baseText('settings.migrationReport.detail.table.active')
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
width: 40,
width: 120,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
key: 'issues',
width: 240,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
key: 'numberOfExecutions',
width: 40,
width: 160,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
key: 'lastExecutedAt',
width: 40,
width: 120,
},
{
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
key: 'lastUpdatedAt',
width: 40,
width: 120,
},
];

View File

@ -130,10 +130,9 @@ describe('MigrationRules', () => {
});
// API called with correct context
expect(breakingChangesApi.getReport).toHaveBeenCalledWith(
rootStore.restApiContext,
undefined,
);
expect(breakingChangesApi.getReport).toHaveBeenCalledWith(rootStore.restApiContext, {
version: 'v3',
});
// Loading skeletons are gone
expect(document.querySelectorAll('.el-skeleton').length).toBe(0);
@ -227,7 +226,7 @@ describe('MigrationRules', () => {
expect(screen.getByText('No workflow issues detected')).toBeInTheDocument();
expect(
screen.getByText(
"Your workflows are fully compatible with version 2.0.0. You're good to go!",
"Your workflows are fully compatible with version 3.0.0. You're good to go!",
),
).toBeInTheDocument();
});
@ -315,7 +314,7 @@ describe('MigrationRules', () => {
expect(screen.getByText('No instance issues detected')).toBeInTheDocument();
expect(
screen.getByText(
"Your instance is fully compatible with version 2.0.0. You're good to go!",
"Your instance is fully compatible with version 3.0.0. You're good to go!",
),
).toBeInTheDocument();
});
@ -403,10 +402,9 @@ describe('MigrationRules', () => {
// API called and data reloaded
await waitFor(() => {
expect(breakingChangesApi.refreshReport).toHaveBeenCalledWith(
rootStore.restApiContext,
undefined,
);
expect(breakingChangesApi.refreshReport).toHaveBeenCalledWith(rootStore.restApiContext, {
version: 'v3',
});
expect(screen.getByText('Updated Rule')).toBeInTheDocument();
expect(screen.getByText('10 Workflows')).toBeInTheDocument();
});

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-instance-registry",
"version": "0.4.0",
"version": "0.4.2",
"type": "module",
"main": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/frontend-module-otel",
"version": "0.3.0",
"version": "0.3.2",
"type": "module",
"main": "src/index.ts",
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "2.39.0",
"version": "2.39.2",
"description": "Base nodes of n8n",
"main": "index.js",
"typesVersions": {

View File

@ -0,0 +1,250 @@
/**
* Paired item ancestry traversal benchmarks.
*
* `$('Node').item` / `.itemMatching()` walk an item's `pairedItem` chain back
* through every ancestor, once per expression that uses them. Ancestry is a DAG,
* so an item reachable through many paths must be expanded once, not once per path.
*
* Keep the chains short: `itemMatching` also runs a `getParentNodes` connection
* check that is superlinear in the chain length and would otherwise dominate.
*/
import { bench, describe } from 'vitest';
import { Workflow, WorkflowDataProxy } from 'n8n-workflow';
import type {
IConnections,
IExecuteData,
INode,
INodeExecutionData,
INodeTypes,
IPairedItemData,
IRunExecutionData,
ITaskData,
} from 'n8n-workflow';
import { BENCH_OPTIONS } from '../bench-options';
const MAIN = 'main';
/** An unknown node type leaves the node as given; the traversal reads only run data and connections. */
const nodeTypes: INodeTypes = {
getByName: () => undefined as never,
getByNameAndVersion: () => undefined as never,
getKnownTypes: () => ({}),
};
type ChainSpec = {
/** Items in each node's output; its length is the chain length. */
widths: number[];
pairing: (d: number, i: number, widths: number[]) => IPairedItemData | IPairedItemData[];
};
/** A -> B -> C -> ... with run data attached to every node. */
function buildChain({ widths, pairing }: ChainSpec) {
const nodes: INode[] = [];
const connections: IConnections = {};
const runData: IRunExecutionData['resultData']['runData'] = {};
for (let d = 0; d < widths.length; d++) {
const name = `n${d}`;
nodes.push({
id: name,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [d * 100, 0],
parameters: {},
});
if (d < widths.length - 1) {
connections[name] = { [MAIN]: [[{ node: `n${d + 1}`, type: MAIN, index: 0 }]] };
}
const items: INodeExecutionData[] = [];
for (let i = 0; i < widths[d]!; i++) {
const item: INodeExecutionData = { json: { d, i } };
if (d > 0) item.pairedItem = pairing(d, i, widths);
items.push(item);
}
runData[name] = [
{
startTime: 0,
executionTime: 0,
executionIndex: d,
source: d === 0 ? [] : [{ previousNode: `n${d - 1}`, previousNodeRun: 0 }],
data: { [MAIN]: [items] },
} satisfies ITaskData,
];
}
return { nodes, connections, runData };
}
/** A proxy on the last node of the chain, ready to trace back to the first. */
function proxyForChain(spec: ChainSpec) {
const { nodes, connections, runData } = buildChain(spec);
const activeNode = `n${spec.widths.length - 1}`;
const taskData = runData[activeNode]![0]!;
const inputData = taskData.data![MAIN]![0]!;
const executeData: IExecuteData = {
data: taskData.data!,
node: nodes[nodes.length - 1]!,
source: { [MAIN]: taskData.source },
};
return new WorkflowDataProxy(
new Workflow({ id: 'bench', name: 'bench', nodes, connections, active: false, nodeTypes }),
{ resultData: { runData } } as IRunExecutionData,
0,
0,
activeNode,
inputData,
{},
'manual',
{},
executeData,
).getDataProxy();
}
/** The last node's items carry a single paired item, the proxy's entry point into the walk. */
const isLastNode = (d: number, widths: number[]) => d === widths.length - 1;
/**
* origin -> {bN, cN} -> mN -> {bN+1, cN+1} -> ... : 2^diamonds paths to the last
* merge, where the chain shapes above reach a shared ancestor from one node.
*/
function diamondProxy(diamonds: number) {
const nodes: INode[] = [];
const connections: IConnections = {};
const runData: IRunExecutionData['resultData']['runData'] = {};
const addNode = (name: string) => {
nodes.push({
id: name,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [nodes.length * 100, 0],
parameters: {},
});
};
const link = (from: string, to: string, index: number) => {
connections[from] ??= { [MAIN]: [[]] };
connections[from][MAIN][0]!.push({ node: to, type: MAIN, index });
};
const task = (
source: ITaskData['source'],
pairedItem: IPairedItemData | IPairedItemData[] | undefined,
) =>
[
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
source,
data: { [MAIN]: [[{ json: {}, ...(pairedItem ? { pairedItem } : {}) }]] },
} satisfies ITaskData,
] as ITaskData[];
addNode('origin');
runData.origin = task([], undefined);
let cur = 'origin';
for (let i = 0; i < diamonds; i++) {
const [b, c, m] = [`b${i}`, `c${i}`, `m${i}`];
for (const branch of [b, c]) {
addNode(branch);
link(cur, branch, 0);
runData[branch] = task([{ previousNode: cur, previousNodeRun: 0 }], { item: 0, input: 0 });
}
addNode(m);
link(b, m, 0);
link(c, m, 1);
runData[m] = task(
[
{ previousNode: b, previousNodeRun: 0 },
{ previousNode: c, previousNodeRun: 0 },
],
[
{ item: 0, input: 0 },
{ item: 0, input: 1 },
],
);
cur = m;
}
addNode('end');
link(cur, 'end', 0);
runData.end = task([{ previousNode: cur, previousNodeRun: 0 }], { item: 0, input: 0 });
const taskData = runData.end![0]!;
return new WorkflowDataProxy(
new Workflow({ id: 'bench', name: 'bench', nodes, connections, active: false, nodeTypes }),
{ resultData: { runData } } as IRunExecutionData,
0,
0,
'end',
taskData.data![MAIN]![0]!,
{},
'manual',
{},
{
data: taskData.data!,
node: nodes[nodes.length - 1]!,
source: { [MAIN]: taskData.source },
},
).getDataProxy();
}
describe('Paired item ancestry traversal', () => {
// Every item pairs to the same ancestor twice: 2^depth paths over `depth`
// distinct items. Exponential paths, must stay linear.
const recombining = proxyForChain({
widths: Array<number>(20).fill(1),
pairing: (d, i, widths) =>
isLastNode(d, widths)
? { item: i, input: 0 }
: [
{ item: 0, input: 0 },
{ item: 0, input: 0 },
],
});
bench(
'itemMatching: recombining chain (20 nodes, 2^20 paths)',
() => {
recombining.$('n0').itemMatching(0);
},
BENCH_OPTIONS,
);
// One item paired to 10k inputs that all trace to the same origin: no path is
// walked twice, so every visit is new work.
const FAN = 10_000;
const fanIn = proxyForChain({
widths: [1, FAN, 1, 1],
pairing: (d, i, widths) => {
if (isLastNode(d, widths)) return { item: i, input: 0 };
if (d === 2) return Array.from({ length: FAN }, (_, k) => ({ item: k, input: 0 }));
return { item: 0, input: 0 };
},
});
bench(
`itemMatching: fan-in (one item paired to ${FAN} ancestors)`,
() => {
fanIn.$('n0').itemMatching(0);
},
BENCH_OPTIONS,
);
// Branches converge from two separate parents, so a shared ancestor is reached
// under one state from several routes. Exponential paths, must stay linear.
const diamonds = diamondProxy(14);
bench(
'itemMatching: diamond chain (14 diamonds, 2^14 paths)',
() => {
diamonds.$('origin').itemMatching(0);
},
BENCH_OPTIONS,
);
});

View File

@ -1,6 +1,6 @@
{
"name": "n8n-workflow",
"version": "2.39.0",
"version": "2.39.1",
"description": "Workflow base code of n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",

View File

@ -0,0 +1,42 @@
import type { INodeExecutionData, IPairedItemData, ISourceData } from './interfaces';
type PairedItemMemoResult =
| { ok: true; result: INodeExecutionData }
| { ok: false; error: unknown };
/**
* Memoizes one paired item traversal per (node run output, item) state.
* For a fixed destination, revisiting a state yields the same result or error.
*/
export class PairedItemMemo {
private readonly entries = new Map<string, PairedItemMemoResult>();
/**
* Resolves the memoized result for the given source data and paired item,
* or computes and memoizes a new result if none exists.
*/
resolve(
sourceData: ISourceData,
pairedItem: IPairedItemData,
compute: () => INodeExecutionData,
): INodeExecutionData {
const key = this.toKey(sourceData, pairedItem);
let entry = this.entries.get(key);
if (!entry) {
try {
entry = { ok: true, result: compute() };
} catch (error) {
entry = { ok: false, error };
}
this.entries.set(key, entry);
}
if (entry.ok) return entry.result;
throw entry.error;
}
private toKey(sourceData: ISourceData, pairedItem: IPairedItemData): string {
return `${sourceData.previousNodeRun ?? 0}|${sourceData.previousNodeOutput ?? 0}|${pairedItem.item}|${sourceData.previousNode}`;
}
}

View File

@ -43,6 +43,7 @@ import type { Workflow } from './workflow';
import type { EnvProviderState } from './workflow-data-proxy-env-provider';
import { createEnvProvider, createEnvProviderState } from './workflow-data-proxy-env-provider';
import { getPinDataIfManualExecution } from './workflow-data-proxy-helpers';
import { PairedItemMemo } from './workflow-data-proxy-paired-item-memo';
const isScriptingNode = (nodeName: string, workflow: Workflow) => {
const node = workflow.getNode(nodeName);
@ -1034,7 +1035,26 @@ export class WorkflowDataProxy {
incomingSourceData: ISourceData | null,
initialPairedItem: IPairedItemData,
usedMethodName: PairedItemMethod = PAIRED_ITEM_METHOD.$GET_PAIRED_ITEM,
nodeBeforeLast?: string,
): INodeExecutionData =>
resolvePairedItem(
destinationNodeName,
incomingSourceData,
initialPairedItem,
usedMethodName,
undefined,
// Ancestry is a DAG: branches recombine on shared ancestors (e.g. an
// Aggregate output pairing to all its inputs), so without memoization
// the walk revisits the same item exponentially often.
new PairedItemMemo(),
);
const resolvePairedItem = (
destinationNodeName: string,
incomingSourceData: ISourceData | null,
initialPairedItem: IPairedItemData,
usedMethodName: PairedItemMethod,
nodeBeforeLast: string | undefined,
memo: PairedItemMemo,
): INodeExecutionData => {
// Normalize inputs
const [pairedItem, sourceData] = normalizeInputs(initialPairedItem, incomingSourceData);
@ -1043,6 +1063,26 @@ export class WorkflowDataProxy {
throw createPairedItemNotFound(destinationNodeName, nodeBeforeLast);
}
return memo.resolve(sourceData, pairedItem, () =>
resolvePairedItemUncached(
destinationNodeName,
sourceData,
pairedItem,
usedMethodName,
nodeBeforeLast,
memo,
),
);
};
const resolvePairedItemUncached = (
destinationNodeName: string,
sourceData: ISourceData,
pairedItem: IPairedItemData,
usedMethodName: PairedItemMethod,
nodeBeforeLast: string | undefined,
memo: PairedItemMemo,
): INodeExecutionData => {
const taskData = getTaskData(sourceData);
const outputData = getNodeOutput(taskData, sourceData, nodeBeforeLast);
const item = outputData[pairedItem.item];
@ -1074,12 +1114,13 @@ export class WorkflowDataProxy {
try {
return createResultOk(
getPairedItem(
resolvePairedItem(
destinationNodeName,
nextSource,
{ ...nextPairedItem, input: inputIndex },
usedMethodName,
sourceData.previousNode,
memo,
),
);
} catch (error) {

View File

@ -0,0 +1,55 @@
import type { INodeExecutionData } from '../src/interfaces';
import { PairedItemMemo } from '../src/workflow-data-proxy-paired-item-memo';
describe('PairedItemMemo', () => {
const source = { previousNode: 'A', previousNodeRun: 1, previousNodeOutput: 2 };
const item: INodeExecutionData = { json: {} };
test('computes once per state and replays the result', () => {
const memo = new PairedItemMemo();
const compute = vi.fn(() => item);
expect(memo.resolve(source, { item: 0 }, compute)).toBe(item);
expect(memo.resolve(source, { item: 0 }, compute)).toBe(item);
expect(compute).toHaveBeenCalledTimes(1);
});
test('replays the same error without recomputing', () => {
const memo = new PairedItemMemo();
const error = new Error('boom');
const compute = vi.fn(() => {
throw error;
});
expect(() => memo.resolve(source, { item: 0 }, compute)).toThrow(error);
expect(() => memo.resolve(source, { item: 0 }, compute)).toThrow(error);
expect(compute).toHaveBeenCalledTimes(1);
});
test('treats omitted run and output as 0', () => {
const memo = new PairedItemMemo();
const compute = vi.fn(() => item);
memo.resolve({ previousNode: 'A' }, { item: 0 }, compute);
memo.resolve(
{ previousNode: 'A', previousNodeRun: 0, previousNodeOutput: 0 },
{ item: 0 },
compute,
);
expect(compute).toHaveBeenCalledTimes(1);
});
test.each([
['node', { ...source, previousNode: 'B' }, { item: 0 }],
['run', { ...source, previousNodeRun: 0 }, { item: 0 }],
['output', { ...source, previousNodeOutput: 0 }, { item: 0 }],
['item', source, { item: 1 }],
])('keys a different %s as a distinct state', (_, otherSource, otherItem) => {
const memo = new PairedItemMemo();
const compute = vi.fn(() => item);
memo.resolve(source, { item: 0 }, compute);
memo.resolve(otherSource, otherItem, compute);
expect(compute).toHaveBeenCalledTimes(2);
});
});

View File

@ -9,6 +9,8 @@ import {
type IDataObject,
type IExecuteData,
type INode,
type INodeExecutionData,
type IPairedItemData,
type IPinData,
type IRun,
type IWorkflowBase,
@ -2264,3 +2266,360 @@ describe('WorkflowDataProxy', () => {
});
});
});
describe('WorkflowDataProxy → pairedItem traversal through recombining ancestry', () => {
// Models a polling loop whose Aggregate-style node pairs its single output
// item to all of its inputs: ancestry branches fan out at every pass and
// recombine on the shared ancestor, forming a DAG. Traversal must resolve
// this in polynomial time - a tree walk would take ROWS^PASSES steps.
const ROWS = 8;
const PASSES = 25;
const makeWorkflow = (): IWorkflowBase => ({
id: '1',
name: 'test',
nodes: (['Start', 'Rows', 'Agg', 'End'] as const).map((name, i) => ({
id: `uuid-${i}`,
name,
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [i * 100, 0] as [number, number],
parameters: {},
})),
connections: {
Start: { main: [[{ node: 'Rows', type: NodeConnectionTypes.Main, index: 0 }]] },
Rows: { main: [[{ node: 'Agg', type: NodeConnectionTypes.Main, index: 0 }]] },
Agg: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
},
active: false,
activeVersionId: null,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const taskData = (source: Array<{ previousNode: string; previousNodeRun?: number } | null>) => ({
startTime: 0,
executionTime: 0,
executionIndex: 0,
source,
data: { main: [[]] as INodeExecutionData[][] },
});
const makeRun = (
startItems: INodeExecutionData[],
rowsPairing: (row: number) => number,
): IRun => {
const start = taskData([null]);
start.data.main[0] = startItems;
const rowsRuns = [];
const aggRuns = [];
for (let pass = 0; pass < PASSES; pass++) {
const rows = taskData([
pass === 0 ? { previousNode: 'Start' } : { previousNode: 'Agg', previousNodeRun: pass - 1 },
]);
// only pass 0 is sourced from Start (multiple items); later passes are
// sourced from Agg, which emits a single item
rows.data.main[0] = Array.from({ length: ROWS }, (_, row) => ({
json: { row },
pairedItem: { item: pass === 0 ? rowsPairing(row) : 0 },
}));
rowsRuns.push(rows);
const agg = taskData([{ previousNode: 'Rows', previousNodeRun: pass }]);
agg.data.main[0] = [
{ json: { pass }, pairedItem: Array.from({ length: ROWS }, (_, item) => ({ item })) },
];
aggRuns.push(agg);
}
const end = taskData([{ previousNode: 'Agg', previousNodeRun: PASSES - 1 }]);
end.data.main[0] = [{ json: {}, pairedItem: { item: 0 } }];
return {
data: createRunExecutionData({
resultData: { runData: { Start: [start], Rows: rowsRuns, Agg: aggRuns, End: [end] } },
}),
mode: 'manual',
startedAt: new Date(),
status: 'success',
storedAt: 'db',
};
};
test('resolves when all branches recombine on one ancestor item', () => {
const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], () => 0);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(proxy.$('Start').item.json).toEqual({ origin: true });
});
test('still detects ambiguity when branches resolve to different ancestor items', () => {
const startItems = Array.from({ length: ROWS }, (_, item) => ({
json: { item },
pairedItem: { item },
}));
// each row pairs back to its own Start item, so resolution is ambiguous
const run = makeRun(startItems, (row) => row);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
});
});
describe('WorkflowDataProxy → pairedItem traversal with sourceOverwrite routes', () => {
// One item pairs to the same ancestor node run twice: once through its task's
// default source and once through a `sourceOverwrite` redirect. The two routes
// must resolve as distinct states.
const makeWorkflow = (): IWorkflowBase => ({
id: '1',
name: 'test',
nodes: (['Start', 'Mid', 'Join', 'End'] as const).map((name, i) => ({
id: `uuid-${i}`,
name,
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [i * 100, 0] as [number, number],
parameters: {},
})),
connections: {
Start: { main: [[{ node: 'Mid', type: NodeConnectionTypes.Main, index: 0 }]] },
Mid: { main: [[{ node: 'Join', type: NodeConnectionTypes.Main, index: 0 }]] },
Join: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
},
active: false,
activeVersionId: null,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const midRun = (outputs: INodeExecutionData[][]) => ({
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [{ previousNode: 'Start' }],
data: { main: outputs },
});
// Mid run N emits one item paired to Start item `midRunPairing[N]`
const midRunsPairedTo = (midRunPairing: number[]) =>
midRunPairing.map((startItem) => midRun([[{ json: {}, pairedItem: { item: startItem } }]]));
const defaultJoinPairing: IPairedItemData[] = [
{ item: 0 },
{ item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } },
];
const makeRun = (
startItems: INodeExecutionData[],
midRuns: ReturnType<typeof midRun>[],
joinPairing: IPairedItemData[] = defaultJoinPairing,
): IRun => {
const start = {
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [null],
data: { main: [startItems] },
};
const join = {
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [{ previousNode: 'Mid', previousNodeRun: 0 }],
data: { main: [[{ json: {}, pairedItem: joinPairing }]] },
};
const end = {
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [{ previousNode: 'Join' }],
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
};
return {
data: createRunExecutionData({
resultData: { runData: { Start: [start], Mid: midRuns, Join: [join], End: [end] } },
}),
mode: 'manual',
startedAt: new Date(),
status: 'success',
storedAt: 'db',
};
};
test('resolves when the default and overwritten routes agree on the ancestor item', () => {
const run = makeRun(
[{ json: { origin: true }, pairedItem: { item: 0 } }],
midRunsPairedTo([0, 0]),
);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(proxy.$('Start').item.json).toEqual({ origin: true });
});
test('detects ambiguity when the overwritten route reaches a different ancestor item', () => {
const startItems = [
{ json: { item: 0 }, pairedItem: { item: 0 } },
{ json: { item: 1 }, pairedItem: { item: 1 } },
];
const run = makeRun(startItems, midRunsPairedTo([0, 1]));
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
});
test('returns the resolving route when another route fails, also on its memoized revisit', () => {
const startItems = [{ json: { origin: true }, pairedItem: { item: 0 } }];
// Mid run 1 has no pairedItem, so the overwritten route cannot resolve.
// It is listed twice so the second visit replays the memoized error.
const brokenRoute = { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } };
const midRuns = [...midRunsPairedTo([0]), midRun([[{ json: {} }]])];
const okFirst = getProxyFromFixture(
makeWorkflow(),
makeRun(startItems, midRuns, [{ item: 0 }, brokenRoute, brokenRoute]),
'End',
);
expect(okFirst.$('Start').item.json).toEqual({ origin: true });
const okLast = getProxyFromFixture(
makeWorkflow(),
makeRun(startItems, midRuns, [brokenRoute, brokenRoute, { item: 0 }]),
'End',
);
expect(okLast.$('Start').item.json).toEqual({ origin: true });
const allBroken = getProxyFromFixture(
makeWorkflow(),
makeRun(startItems, midRuns, [brokenRoute, brokenRoute]),
'End',
);
expect(() => allBroken.$('Start').item).toThrowError(
"Paired item data for item from node 'Mid' is unavailable",
);
});
test('treats the same item index on different outputs of one run as distinct states', () => {
const startItems = [
{ json: { item: 0 }, pairedItem: { item: 0 } },
{ json: { item: 1 }, pairedItem: { item: 1 } },
];
// Mid run 0 output 0 item 0 pairs to Start 0, output 1 item 0 pairs to Start 1
const midRuns = [
midRun([[{ json: {}, pairedItem: { item: 0 } }], [{ json: {}, pairedItem: { item: 1 } }]]),
];
const run = makeRun(startItems, midRuns, [
{
item: 0,
sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 0 },
},
{
item: 0,
sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 1 },
},
]);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
});
});
describe('WorkflowDataProxy → pairedItem traversal through a diamond', () => {
// Start fans out to Left and Right, which join again in Merge. A Start item is
// reached under one state from two different parent nodes.
const makeWorkflow = (): IWorkflowBase => ({
id: '1',
name: 'test',
nodes: (['Start', 'Left', 'Right', 'Merge', 'End'] as const).map((name, i) => ({
id: `uuid-${i}`,
name,
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [i * 100, 0] as [number, number],
parameters: {},
})),
connections: {
Start: {
main: [
[
{ node: 'Left', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'Right', type: NodeConnectionTypes.Main, index: 0 },
],
],
},
Left: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 0 }]] },
Right: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 1 }]] },
Merge: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
},
active: false,
activeVersionId: null,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const task = (source: Array<{ previousNode: string } | null>, items: INodeExecutionData[]) => ({
startTime: 0,
executionTime: 0,
executionIndex: 0,
source,
data: { main: [items] },
});
const makeRun = (
startItems: INodeExecutionData[],
leftItem: number,
rightItem: number,
): IRun => ({
data: createRunExecutionData({
resultData: {
runData: {
Start: [task([null], startItems)],
Left: [task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: leftItem } }])],
Right: [
task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: rightItem } }]),
],
Merge: [
task(
[{ previousNode: 'Left' }, { previousNode: 'Right' }],
[
{
json: {},
pairedItem: [
{ item: 0, input: 0 },
{ item: 0, input: 1 },
],
},
],
),
],
End: [task([{ previousNode: 'Merge' }], [{ json: {}, pairedItem: { item: 0 } }])],
},
},
}),
mode: 'manual',
startedAt: new Date(),
status: 'success',
storedAt: 'db',
});
test('resolves when both routes reach one ancestor item', () => {
const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], 0, 0);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(proxy.$('Start').item.json).toEqual({ origin: true });
});
test('detects ambiguity when the routes reach different ancestor items', () => {
const startItems = [0, 1].map((item) => ({ json: { item }, pairedItem: { item } }));
const run = makeRun(startItems, 0, 1);
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
});
});